@porulle/core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/dist/auth/auth-schema-guard.d.ts +2444 -0
  2. package/dist/auth/auth-schema-guard.d.ts.map +1 -0
  3. package/dist/auth/auth-schema-guard.js +113 -0
  4. package/dist/auth/auth-schema.d.ts +228 -0
  5. package/dist/auth/auth-schema.d.ts.map +1 -1
  6. package/dist/auth/auth-schema.js +24 -2
  7. package/dist/auth/middleware.d.ts +1 -0
  8. package/dist/auth/middleware.d.ts.map +1 -1
  9. package/dist/auth/middleware.js +13 -5
  10. package/dist/auth/org.d.ts +8 -2
  11. package/dist/auth/org.d.ts.map +1 -1
  12. package/dist/auth/org.js +12 -4
  13. package/dist/auth/organization-guard.d.ts +5 -0
  14. package/dist/auth/organization-guard.d.ts.map +1 -0
  15. package/dist/auth/organization-guard.js +124 -0
  16. package/dist/auth/permissions.d.ts +10 -0
  17. package/dist/auth/permissions.d.ts.map +1 -1
  18. package/dist/auth/permissions.js +31 -7
  19. package/dist/auth/role-authority.d.ts +44 -0
  20. package/dist/auth/role-authority.d.ts.map +1 -0
  21. package/dist/auth/role-authority.js +85 -0
  22. package/dist/auth/setup.d.ts.map +1 -1
  23. package/dist/auth/setup.js +17 -0
  24. package/dist/auth/strict-org-resolution.d.ts.map +1 -1
  25. package/dist/auth/strict-org-resolution.js +9 -1
  26. package/dist/auth/types.d.ts +1 -1
  27. package/dist/auth/types.d.ts.map +1 -1
  28. package/dist/config/defaults.d.ts.map +1 -1
  29. package/dist/config/defaults.js +2 -0
  30. package/dist/config/types.d.ts +30 -4
  31. package/dist/config/types.d.ts.map +1 -1
  32. package/dist/hooks/checkout.d.ts.map +1 -1
  33. package/dist/hooks/checkout.js +10 -9
  34. package/dist/index.d.ts +6 -5
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +4 -3
  37. package/dist/interfaces/rest/audit-middleware.d.ts.map +1 -1
  38. package/dist/interfaces/rest/audit-middleware.js +2 -0
  39. package/dist/interfaces/rest/customer-portal.d.ts.map +1 -1
  40. package/dist/interfaces/rest/customer-portal.js +9 -8
  41. package/dist/interfaces/rest/route-coverage.d.ts +14 -0
  42. package/dist/interfaces/rest/route-coverage.d.ts.map +1 -0
  43. package/dist/interfaces/rest/route-coverage.js +81 -0
  44. package/dist/interfaces/rest/router.d.ts +7 -2
  45. package/dist/interfaces/rest/router.d.ts.map +1 -1
  46. package/dist/interfaces/rest/router.js +8 -4
  47. package/dist/interfaces/rest/routes/admin/compensation-failures.d.ts.map +1 -1
  48. package/dist/interfaces/rest/routes/admin/compensation-failures.js +7 -5
  49. package/dist/interfaces/rest/routes/admin/custom-field-proposals.js +2 -2
  50. package/dist/interfaces/rest/routes/admin/permissions.d.ts +1 -1
  51. package/dist/interfaces/rest/routes/admin/permissions.d.ts.map +1 -1
  52. package/dist/interfaces/rest/routes/admin/permissions.js +2 -0
  53. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
  54. package/dist/interfaces/rest/routes/admin/staff.js +126 -66
  55. package/dist/interfaces/rest/routes/admin-jobs.js +3 -3
  56. package/dist/interfaces/rest/routes/audit.js +3 -3
  57. package/dist/interfaces/rest/routes/carts.d.ts.map +1 -1
  58. package/dist/interfaces/rest/routes/carts.js +13 -10
  59. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  60. package/dist/interfaces/rest/routes/catalog.js +10 -2
  61. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  62. package/dist/interfaces/rest/routes/checkout.js +30 -35
  63. package/dist/interfaces/rest/routes/documents.d.ts.map +1 -1
  64. package/dist/interfaces/rest/routes/documents.js +10 -5
  65. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  66. package/dist/interfaces/rest/routes/inventory.js +2 -0
  67. package/dist/interfaces/rest/routes/media.d.ts.map +1 -1
  68. package/dist/interfaces/rest/routes/media.js +7 -3
  69. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  70. package/dist/interfaces/rest/routes/orders.js +26 -8
  71. package/dist/interfaces/rest/routes/pricing.js +2 -2
  72. package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
  73. package/dist/interfaces/rest/routes/promotions.js +13 -10
  74. package/dist/interfaces/rest/routes/search.d.ts.map +1 -1
  75. package/dist/interfaces/rest/routes/search.js +2 -1
  76. package/dist/interfaces/rest/schemas/carts.d.ts +7 -0
  77. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  78. package/dist/interfaces/rest/schemas/customer-portal.d.ts +35 -0
  79. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  80. package/dist/interfaces/rest/schemas/orders.d.ts +204 -0
  81. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  82. package/dist/interfaces/rest/schemas/responses.d.ts +71 -0
  83. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  84. package/dist/interfaces/rest/utils.d.ts +15 -0
  85. package/dist/interfaces/rest/utils.d.ts.map +1 -1
  86. package/dist/interfaces/rest/utils.js +45 -2
  87. package/dist/kernel/compensation/executor.js +2 -2
  88. package/dist/kernel/database/tx-context.d.ts +8 -0
  89. package/dist/kernel/database/tx-context.d.ts.map +1 -1
  90. package/dist/kernel/database/tx-context.js +13 -0
  91. package/dist/kernel/hooks/create-context.d.ts +2 -0
  92. package/dist/kernel/hooks/create-context.d.ts.map +1 -1
  93. package/dist/kernel/hooks/create-context.js +1 -0
  94. package/dist/kernel/hooks/types.d.ts +2 -0
  95. package/dist/kernel/hooks/types.d.ts.map +1 -1
  96. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  97. package/dist/kernel/plugin/manifest.js +9 -5
  98. package/dist/modules/analytics/service.js +2 -2
  99. package/dist/modules/audit/service.js +3 -3
  100. package/dist/modules/cart/repository/index.d.ts +13 -7
  101. package/dist/modules/cart/repository/index.d.ts.map +1 -1
  102. package/dist/modules/cart/repository/index.js +16 -10
  103. package/dist/modules/cart/schema.d.ts +17 -0
  104. package/dist/modules/cart/schema.d.ts.map +1 -1
  105. package/dist/modules/cart/schema.js +4 -0
  106. package/dist/modules/cart/service.d.ts +22 -9
  107. package/dist/modules/cart/service.d.ts.map +1 -1
  108. package/dist/modules/cart/service.js +57 -33
  109. package/dist/modules/catalog/brand-service.js +7 -7
  110. package/dist/modules/catalog/category-service.js +7 -7
  111. package/dist/modules/catalog/entity-service.d.ts +14 -7
  112. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  113. package/dist/modules/catalog/entity-service.js +109 -31
  114. package/dist/modules/catalog/read-policy.d.ts +9 -0
  115. package/dist/modules/catalog/read-policy.d.ts.map +1 -0
  116. package/dist/modules/catalog/read-policy.js +8 -0
  117. package/dist/modules/catalog/repository/index.d.ts +3 -2
  118. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  119. package/dist/modules/catalog/repository/index.js +14 -4
  120. package/dist/modules/catalog/service.d.ts +15 -13
  121. package/dist/modules/catalog/service.d.ts.map +1 -1
  122. package/dist/modules/catalog/service.js +25 -18
  123. package/dist/modules/customers/module.d.ts.map +1 -1
  124. package/dist/modules/customers/module.js +1 -0
  125. package/dist/modules/customers/service.d.ts +2 -0
  126. package/dist/modules/customers/service.d.ts.map +1 -1
  127. package/dist/modules/customers/service.js +21 -20
  128. package/dist/modules/documents/module.d.ts.map +1 -1
  129. package/dist/modules/documents/module.js +1 -0
  130. package/dist/modules/documents/service.d.ts +18 -4
  131. package/dist/modules/documents/service.d.ts.map +1 -1
  132. package/dist/modules/documents/service.js +54 -16
  133. package/dist/modules/fulfillment/module.d.ts.map +1 -1
  134. package/dist/modules/fulfillment/module.js +1 -0
  135. package/dist/modules/fulfillment/service.d.ts +2 -0
  136. package/dist/modules/fulfillment/service.d.ts.map +1 -1
  137. package/dist/modules/fulfillment/service.js +15 -6
  138. package/dist/modules/inventory/service.d.ts.map +1 -1
  139. package/dist/modules/inventory/service.js +15 -14
  140. package/dist/modules/media/service.js +6 -6
  141. package/dist/modules/orders/guest-access.d.ts +27 -0
  142. package/dist/modules/orders/guest-access.d.ts.map +1 -0
  143. package/dist/modules/orders/guest-access.js +46 -0
  144. package/dist/modules/orders/module.d.ts.map +1 -1
  145. package/dist/modules/orders/module.js +1 -0
  146. package/dist/modules/orders/quote.d.ts.map +1 -1
  147. package/dist/modules/orders/quote.js +1 -0
  148. package/dist/modules/orders/repository/index.d.ts +1 -1
  149. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  150. package/dist/modules/orders/repository/index.js +2 -2
  151. package/dist/modules/orders/schema.d.ts +17 -0
  152. package/dist/modules/orders/schema.d.ts.map +1 -1
  153. package/dist/modules/orders/schema.js +5 -3
  154. package/dist/modules/orders/service.d.ts +16 -7
  155. package/dist/modules/orders/service.d.ts.map +1 -1
  156. package/dist/modules/orders/service.js +184 -55
  157. package/dist/modules/pricing/module.d.ts.map +1 -1
  158. package/dist/modules/pricing/module.js +1 -0
  159. package/dist/modules/pricing/service.d.ts +2 -0
  160. package/dist/modules/pricing/service.d.ts.map +1 -1
  161. package/dist/modules/pricing/service.js +12 -11
  162. package/dist/modules/promotions/module.d.ts.map +1 -1
  163. package/dist/modules/promotions/module.js +1 -0
  164. package/dist/modules/promotions/service.d.ts +3 -0
  165. package/dist/modules/promotions/service.d.ts.map +1 -1
  166. package/dist/modules/promotions/service.js +19 -11
  167. package/dist/modules/search/adapter.d.ts +6 -0
  168. package/dist/modules/search/adapter.d.ts.map +1 -1
  169. package/dist/modules/search/module.d.ts.map +1 -1
  170. package/dist/modules/search/module.js +1 -0
  171. package/dist/modules/search/service.d.ts +1 -0
  172. package/dist/modules/search/service.d.ts.map +1 -1
  173. package/dist/modules/search/service.js +57 -6
  174. package/dist/modules/settings/module.d.ts.map +1 -1
  175. package/dist/modules/settings/module.js +1 -0
  176. package/dist/modules/settings/service.d.ts +3 -0
  177. package/dist/modules/settings/service.d.ts.map +1 -1
  178. package/dist/modules/settings/service.js +6 -4
  179. package/dist/modules/shipping/service.js +9 -9
  180. package/dist/modules/tax/module.d.ts.map +1 -1
  181. package/dist/modules/tax/module.js +1 -0
  182. package/dist/modules/tax/service.d.ts +3 -0
  183. package/dist/modules/tax/service.d.ts.map +1 -1
  184. package/dist/modules/tax/service.js +11 -9
  185. package/dist/modules/webhooks/hook.js +2 -2
  186. package/dist/modules/webhooks/module.d.ts.map +1 -1
  187. package/dist/modules/webhooks/module.js +1 -0
  188. package/dist/modules/webhooks/service.d.ts +2 -0
  189. package/dist/modules/webhooks/service.d.ts.map +1 -1
  190. package/dist/modules/webhooks/service.js +4 -4
  191. package/dist/runtime/server.d.ts.map +1 -1
  192. package/dist/runtime/server.js +10 -2
  193. package/dist/test-utils/create-pglite-adapter.d.ts.map +1 -1
  194. package/dist/test-utils/create-pglite-adapter.js +35 -13
  195. package/dist/test-utils/create-test-config.d.ts.map +1 -1
  196. package/dist/test-utils/create-test-config.js +4 -0
  197. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  198. package/dist/test-utils/rest-api-test-utils.js +2 -0
  199. package/dist/test-utils/test-actors.js +2 -2
  200. package/dist/utils/id.d.ts +2 -0
  201. package/dist/utils/id.d.ts.map +1 -1
  202. package/dist/utils/id.js +11 -0
  203. package/package.json +6 -5
  204. package/src/auth/auth-schema-guard.ts +143 -0
  205. package/src/auth/auth-schema.ts +50 -19
  206. package/src/auth/middleware.ts +17 -5
  207. package/src/auth/org.ts +15 -3
  208. package/src/auth/organization-guard.ts +158 -0
  209. package/src/auth/permissions.ts +31 -8
  210. package/src/auth/role-authority.ts +107 -0
  211. package/src/auth/setup.ts +17 -0
  212. package/src/auth/strict-org-resolution.ts +7 -1
  213. package/src/auth/types.ts +1 -1
  214. package/src/config/defaults.ts +2 -0
  215. package/src/config/types.ts +29 -4
  216. package/src/hooks/checkout.ts +19 -12
  217. package/src/index.ts +16 -6
  218. package/src/interfaces/rest/audit-middleware.ts +2 -0
  219. package/src/interfaces/rest/customer-portal.ts +31 -24
  220. package/src/interfaces/rest/route-coverage.ts +103 -0
  221. package/src/interfaces/rest/router.ts +16 -10
  222. package/src/interfaces/rest/routes/admin/compensation-failures.ts +8 -5
  223. package/src/interfaces/rest/routes/admin/custom-field-proposals.ts +2 -2
  224. package/src/interfaces/rest/routes/admin/permissions.ts +3 -1
  225. package/src/interfaces/rest/routes/admin/staff.ts +161 -72
  226. package/src/interfaces/rest/routes/admin-jobs.ts +3 -3
  227. package/src/interfaces/rest/routes/audit.ts +3 -3
  228. package/src/interfaces/rest/routes/carts.ts +17 -7
  229. package/src/interfaces/rest/routes/catalog.ts +11 -0
  230. package/src/interfaces/rest/routes/checkout.ts +41 -38
  231. package/src/interfaces/rest/routes/documents.ts +15 -1
  232. package/src/interfaces/rest/routes/inventory.ts +2 -0
  233. package/src/interfaces/rest/routes/media.ts +8 -3
  234. package/src/interfaces/rest/routes/orders.ts +27 -8
  235. package/src/interfaces/rest/routes/pricing.ts +2 -2
  236. package/src/interfaces/rest/routes/promotions.ts +12 -9
  237. package/src/interfaces/rest/routes/search.ts +3 -1
  238. package/src/interfaces/rest/utils.ts +61 -2
  239. package/src/kernel/compensation/executor.ts +2 -2
  240. package/src/kernel/database/tx-context.ts +25 -0
  241. package/src/kernel/hooks/create-context.ts +3 -0
  242. package/src/kernel/hooks/types.ts +2 -0
  243. package/src/kernel/plugin/manifest.ts +9 -5
  244. package/src/modules/analytics/service.ts +2 -2
  245. package/src/modules/audit/service.ts +3 -3
  246. package/src/modules/cart/repository/index.ts +22 -8
  247. package/src/modules/cart/schema.ts +4 -0
  248. package/src/modules/cart/service.ts +62 -27
  249. package/src/modules/catalog/brand-service.ts +7 -7
  250. package/src/modules/catalog/category-service.ts +7 -7
  251. package/src/modules/catalog/entity-service.ts +117 -35
  252. package/src/modules/catalog/read-policy.ts +16 -0
  253. package/src/modules/catalog/repository/index.ts +19 -3
  254. package/src/modules/catalog/service.ts +46 -31
  255. package/src/modules/customers/module.ts +2 -0
  256. package/src/modules/customers/service.ts +23 -22
  257. package/src/modules/documents/module.ts +2 -0
  258. package/src/modules/documents/service.ts +74 -11
  259. package/src/modules/fulfillment/module.ts +2 -0
  260. package/src/modules/fulfillment/service.ts +17 -6
  261. package/src/modules/inventory/service.ts +15 -24
  262. package/src/modules/media/service.ts +6 -6
  263. package/src/modules/orders/guest-access.ts +55 -0
  264. package/src/modules/orders/module.ts +1 -0
  265. package/src/modules/orders/quote.ts +1 -0
  266. package/src/modules/orders/repository/index.ts +2 -0
  267. package/src/modules/orders/schema.ts +5 -3
  268. package/src/modules/orders/service.ts +302 -52
  269. package/src/modules/pricing/module.ts +2 -0
  270. package/src/modules/pricing/service.ts +14 -16
  271. package/src/modules/promotions/module.ts +2 -0
  272. package/src/modules/promotions/service.ts +25 -16
  273. package/src/modules/search/adapter.ts +6 -0
  274. package/src/modules/search/module.ts +1 -0
  275. package/src/modules/search/service.ts +55 -6
  276. package/src/modules/settings/module.ts +2 -0
  277. package/src/modules/settings/service.ts +8 -4
  278. package/src/modules/shipping/service.ts +9 -9
  279. package/src/modules/tax/module.ts +1 -0
  280. package/src/modules/tax/service.ts +13 -9
  281. package/src/modules/webhooks/hook.ts +2 -2
  282. package/src/modules/webhooks/module.ts +2 -0
  283. package/src/modules/webhooks/service.ts +6 -4
  284. package/src/runtime/server.ts +16 -2
  285. package/src/test-utils/create-pglite-adapter.ts +34 -12
  286. package/src/test-utils/create-test-config.ts +10 -6
  287. package/src/test-utils/rest-api-test-utils.ts +2 -0
  288. package/src/test-utils/test-actors.ts +2 -2
  289. package/src/utils/id.ts +20 -0
@@ -4,7 +4,7 @@ import type { Kernel } from "../../../../runtime/kernel.js";
4
4
  import type { CommerceConfig } from "../../../../config/types.js";
5
5
  import type { DrizzleDatabase } from "../../../../kernel/database/drizzle-db.js";
6
6
  import { member, user, invitation } from "../../../../auth/auth-schema.js";
7
- import { resolveOrgId } from "../../../../auth/org.js";
7
+ import { resolveOrgIdForCommerce } from "../../../../auth/org.js";
8
8
  import { makeId } from "../../../../utils/id.js";
9
9
  import {
10
10
  listStaffRoute,
@@ -15,6 +15,15 @@ import {
15
15
  updateStaffRoleRoute,
16
16
  revokeStaffRoute,
17
17
  } from "../../schemas/admin-staff.js";
18
+ import type { Actor } from "../../../../auth/types.js";
19
+ import {
20
+ canGrantRole,
21
+ granterForRole,
22
+ isCompositeRole,
23
+ isOwnerRole,
24
+ roleRank,
25
+ validRoles,
26
+ } from "../../../../auth/role-authority.js";
18
27
  import { type AppEnv, requirePerm } from "../../utils.js";
19
28
 
20
29
  /**
@@ -34,39 +43,28 @@ export function adminStaffRoutes(kernel: Kernel) {
34
43
  router.use("/staff/invitations", requirePerm("staff:manage"));
35
44
  router.use("/staff/roles", requirePerm("staff:manage"));
36
45
 
37
- function validRoles(): Set<string> {
38
- return new Set([
39
- ...Object.keys(config.auth?.roles ?? {}),
40
- "owner",
41
- "admin",
42
- "member",
43
- ]);
44
- }
45
-
46
46
  function invalidRole(c: { json: (d: unknown, s: number) => unknown }, role: string) {
47
47
  return c.json(
48
48
  {
49
49
  error: {
50
50
  code: "VALIDATION_FAILED",
51
- message: `Unknown role "${role}". Valid roles: ${[...validRoles()].join(", ")}.`,
51
+ message: `Unknown role "${role}". Valid roles: ${[...validRoles(config)].join(", ")}.`,
52
52
  },
53
53
  },
54
54
  422,
55
55
  );
56
56
  }
57
57
 
58
- const BUILTIN_ROLE_RANK: Record<string, number> = {
59
- owner: 3,
60
- admin: 2,
61
- };
62
- const CUSTOM_ROLE_RANK = 1;
63
-
64
- function roleRank(role: string): number {
65
- return BUILTIN_ROLE_RANK[role] ?? CUSTOM_ROLE_RANK;
58
+ function rankOf(role: string | null | undefined): number {
59
+ return roleRank(config, role);
66
60
  }
67
61
 
68
- function canGrantRole(actorRole: string, targetRole: string): boolean {
69
- return roleRank(actorRole) >= roleRank(targetRole);
62
+ function actorCanGrantRole(actor: Actor | null, targetRole: string): boolean {
63
+ return canGrantRole(
64
+ config,
65
+ actor ? { role: actor.role, permissions: actor.permissions } : null,
66
+ targetRole,
67
+ );
70
68
  }
71
69
 
72
70
  function insufficientPrivilege(
@@ -84,16 +82,81 @@ export function adminStaffRoutes(kernel: Kernel) {
84
82
  );
85
83
  }
86
84
 
87
- async function countOwners(orgId: string): Promise<number> {
88
- const rows = await db
89
- .select({ id: member.id })
90
- .from(member)
91
- .where(and(eq(member.organizationId, orgId), eq(member.role, "owner")));
92
- return rows.length;
85
+ /**
86
+ * Runs a membership mutation with the organization's member rows locked, so
87
+ * the last-owner check and the write it guards cannot be interleaved.
88
+ *
89
+ * Counting owners and then mutating as two statements let two owners revoke
90
+ * each other concurrently: both read `count = 2`, both proceed, and the
91
+ * organization is left with none. The lock makes the second transaction
92
+ * re-read after the first commits, so it sees the count it must respect.
93
+ *
94
+ * The whole member set is locked, not just the owner rows, because an owner
95
+ * is any member whose role string contains `owner` — a predicate SQL cannot
96
+ * express as cheaply as the JS filter below. Membership writes are rare
97
+ * administrative actions, so serialising them per organization is free.
98
+ * `ORDER BY id` fixes the lock order and keeps two concurrent revocations
99
+ * from deadlocking each other.
100
+ */
101
+ async function withMembershipLock<T>(
102
+ orgId: string,
103
+ run: (tx: DrizzleDatabase, ownerIds: string[]) => Promise<T>,
104
+ ): Promise<T> {
105
+ return kernel.database.transaction(async (raw) => {
106
+ const tx = raw as DrizzleDatabase;
107
+ const locked = await tx
108
+ .select({ id: member.id, role: member.role })
109
+ .from(member)
110
+ .where(eq(member.organizationId, orgId))
111
+ .orderBy(member.id)
112
+ .for("update");
113
+ const ownerIds = locked
114
+ .filter((row) => isOwnerRole(row.role))
115
+ .map((row) => row.id);
116
+ return run(tx, ownerIds);
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Cancels the member's pending invitations that their role can no longer
122
+ * grant. Acceptance re-checks the inviter's authority too, so this is not
123
+ * what closes the takeover — it keeps the invitation list honest and stops a
124
+ * dead grant sitting there for the rest of its seven-day life.
125
+ *
126
+ * `newRole` is null when the membership is being revoked outright.
127
+ */
128
+ async function cancelUngrantableInvitations(
129
+ orgId: string,
130
+ inviterUserId: string,
131
+ newRole: string | null,
132
+ ): Promise<void> {
133
+ const pending = await db
134
+ .select({ id: invitation.id, role: invitation.role })
135
+ .from(invitation)
136
+ .where(
137
+ and(
138
+ eq(invitation.organizationId, orgId),
139
+ eq(invitation.inviterId, inviterUserId),
140
+ eq(invitation.status, "pending"),
141
+ ),
142
+ );
143
+
144
+ const dead = pending.filter(
145
+ (row) =>
146
+ newRole === null ||
147
+ !canGrantRole(config, granterForRole(config, newRole), row.role ?? "member"),
148
+ );
149
+
150
+ for (const row of dead) {
151
+ await db
152
+ .update(invitation)
153
+ .set({ status: "canceled" })
154
+ .where(eq(invitation.id, row.id));
155
+ }
93
156
  }
94
157
 
95
158
  router.openapi(listStaffRoute, async (c) => {
96
- const orgId = resolveOrgId(c.get("actor"));
159
+ const orgId = resolveOrgIdForCommerce(c.get("actor"), config);
97
160
  const rows = await db
98
161
  .select({
99
162
  id: member.id,
@@ -113,10 +176,10 @@ export function adminStaffRoutes(kernel: Kernel) {
113
176
  router.openapi(createStaffRoute, async (c) => {
114
177
  const body = c.req.valid("json") as { userId: string; role: string };
115
178
  const actor = c.get("actor");
116
- const orgId = resolveOrgId(actor);
179
+ const orgId = resolveOrgIdForCommerce(actor, config);
117
180
 
118
- if (!validRoles().has(body.role)) return invalidRole(c, body.role);
119
- if (!canGrantRole(actor!.role, body.role)) return insufficientPrivilege(c, body.role);
181
+ if (isCompositeRole(body.role) || !validRoles(config).has(body.role)) return invalidRole(c, body.role);
182
+ if (!actorCanGrantRole(actor, body.role)) return insufficientPrivilege(c, body.role);
120
183
 
121
184
  const users = await db.select().from(user).where(eq(user.id, body.userId));
122
185
  if (users.length === 0) {
@@ -151,10 +214,10 @@ export function adminStaffRoutes(kernel: Kernel) {
151
214
  router.openapi(inviteStaffRoute, async (c) => {
152
215
  const body = c.req.valid("json") as { email: string; role: string };
153
216
  const actor = c.get("actor");
154
- const orgId = resolveOrgId(actor);
155
-
156
- if (!validRoles().has(body.role)) return invalidRole(c, body.role);
217
+ const orgId = resolveOrgIdForCommerce(actor, config);
157
218
 
219
+ if (isCompositeRole(body.role) || !validRoles(config).has(body.role)) return invalidRole(c, body.role);
220
+ if (!actorCanGrantRole(actor, body.role)) return insufficientPrivilege(c, body.role);
158
221
  const rows = await db
159
222
  .insert(invitation)
160
223
  .values({
@@ -164,14 +227,14 @@ export function adminStaffRoutes(kernel: Kernel) {
164
227
  role: body.role,
165
228
  status: "pending",
166
229
  expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
167
- inviterId: actor!.userId,
230
+ inviterId: actor?.userId ?? "system",
168
231
  })
169
232
  .returning();
170
233
  return c.json({ data: rows[0] }, 201);
171
234
  });
172
235
 
173
236
  router.openapi(listStaffInvitationsRoute, async (c) => {
174
- const orgId = resolveOrgId(c.get("actor"));
237
+ const orgId = resolveOrgIdForCommerce(c.get("actor"), config);
175
238
  const rows = await db
176
239
  .select()
177
240
  .from(invitation)
@@ -192,67 +255,93 @@ export function adminStaffRoutes(kernel: Kernel) {
192
255
  router.openapi(updateStaffRoleRoute, async (c) => {
193
256
  const body = c.req.valid("json") as { role: string };
194
257
  const actor = c.get("actor");
195
- const orgId = resolveOrgId(actor);
258
+ const orgId = resolveOrgIdForCommerce(actor, config);
196
259
  const id = c.req.param("id");
197
260
 
198
- if (!validRoles().has(body.role)) return invalidRole(c, body.role);
199
- if (!canGrantRole(actor!.role, body.role)) return insufficientPrivilege(c, body.role);
200
-
201
- const rows = await db
202
- .select()
203
- .from(member)
204
- .where(and(eq(member.organizationId, orgId), eq(member.id, id)));
205
- const target = rows[0];
206
- if (!target) {
261
+ if (isCompositeRole(body.role) || !validRoles(config).has(body.role)) return invalidRole(c, body.role);
262
+ if (!actorCanGrantRole(actor, body.role)) return insufficientPrivilege(c, body.role);
263
+
264
+ const outcome = await withMembershipLock(orgId, async (tx, ownerIds) => {
265
+ const rows = await tx
266
+ .select()
267
+ .from(member)
268
+ .where(and(eq(member.organizationId, orgId), eq(member.id, id)));
269
+ const target = rows[0];
270
+ if (!target) return { kind: "not-found" } as const;
271
+
272
+ // SEC-18/R-02: the actor must also outrank (or equal) the target's CURRENT
273
+ // role, else an admin could demote an owner they do not outrank.
274
+ if (rankOf(actor!.role) < rankOf(target.role)) {
275
+ return { kind: "outranked", role: target.role } as const;
276
+ }
277
+
278
+ if (isOwnerRole(target.role) && !isOwnerRole(body.role) && ownerIds.length <= 1) {
279
+ return { kind: "last-owner" } as const;
280
+ }
281
+
282
+ const updated = await tx
283
+ .update(member)
284
+ .set({ role: body.role })
285
+ .where(eq(member.id, id))
286
+ .returning();
287
+ return { kind: "updated", target, row: updated[0] } as const;
288
+ });
289
+
290
+ if (outcome.kind === "not-found") {
207
291
  return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
208
292
  }
209
-
210
- // SEC-18/R-02: the actor must also outrank (or equal) the target's CURRENT
211
- // role, else an admin could demote an owner they do not outrank.
212
- if (!canGrantRole(actor!.role, target.role)) return insufficientPrivilege(c, target.role);
213
-
214
- if (target.role === "owner" && body.role !== "owner" && (await countOwners(orgId)) <= 1) {
293
+ if (outcome.kind === "outranked") return insufficientPrivilege(c, outcome.role);
294
+ if (outcome.kind === "last-owner") {
215
295
  return c.json(
216
296
  { error: { code: "VALIDATION_FAILED", message: "Cannot demote the organization's last owner." } },
217
297
  422,
218
298
  );
219
299
  }
220
300
 
221
- const updated = await db
222
- .update(member)
223
- .set({ role: body.role })
224
- .where(eq(member.id, id))
225
- .returning();
226
- return c.json({ data: updated[0] });
301
+ await cancelUngrantableInvitations(orgId, outcome.target.userId, body.role);
302
+ return c.json({ data: outcome.row });
227
303
  });
228
304
 
229
305
  // @ts-expect-error -- openapi handler union return type
230
306
  router.openapi(revokeStaffRoute, async (c) => {
231
307
  const actor = c.get("actor");
232
- const orgId = resolveOrgId(actor);
308
+ const orgId = resolveOrgIdForCommerce(actor, config);
233
309
  const id = c.req.param("id");
234
310
 
235
- const rows = await db
236
- .select()
237
- .from(member)
238
- .where(and(eq(member.organizationId, orgId), eq(member.id, id)));
239
- const target = rows[0];
240
- if (!target) {
311
+ const outcome = await withMembershipLock(orgId, async (tx, ownerIds) => {
312
+ const rows = await tx
313
+ .select()
314
+ .from(member)
315
+ .where(and(eq(member.organizationId, orgId), eq(member.id, id)));
316
+ const target = rows[0];
317
+ if (!target) return { kind: "not-found" } as const;
318
+
319
+ // SEC-18/R-02: the actor must outrank (or equal) the target's current role
320
+ // to revoke it — an admin cannot revoke an owner.
321
+ if (rankOf(actor!.role) < rankOf(target.role)) {
322
+ return { kind: "outranked", role: target.role } as const;
323
+ }
324
+
325
+ if (isOwnerRole(target.role) && ownerIds.length <= 1) {
326
+ return { kind: "last-owner" } as const;
327
+ }
328
+
329
+ await tx.delete(member).where(eq(member.id, id));
330
+ return { kind: "deleted", target } as const;
331
+ });
332
+
333
+ if (outcome.kind === "not-found") {
241
334
  return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
242
335
  }
243
-
244
- // SEC-18/R-02: the actor must outrank (or equal) the target's current role
245
- // to revoke it — an admin cannot revoke an owner.
246
- if (!canGrantRole(actor!.role, target.role)) return insufficientPrivilege(c, target.role);
247
-
248
- if (target.role === "owner" && (await countOwners(orgId)) <= 1) {
336
+ if (outcome.kind === "outranked") return insufficientPrivilege(c, outcome.role);
337
+ if (outcome.kind === "last-owner") {
249
338
  return c.json(
250
339
  { error: { code: "VALIDATION_FAILED", message: "Cannot revoke the organization's last owner." } },
251
340
  422,
252
341
  );
253
342
  }
254
343
 
255
- await db.delete(member).where(eq(member.id, id));
344
+ await cancelUngrantableInvitations(orgId, outcome.target.userId, null);
256
345
  return c.json({ data: { deleted: true } });
257
346
  });
258
347
 
@@ -5,7 +5,7 @@ import type { Kernel } from "../../../runtime/kernel.js";
5
5
  import { commerceJobs } from "../../../kernel/jobs/schema.js";
6
6
  import { listFailedJobsRoute, retryJobRoute } from "../schemas/admin-jobs.js";
7
7
  import { type AppEnv, requirePerm } from "../utils.js";
8
- import { resolveOrgId } from "../../../auth/org.js";
8
+ import { resolveOrgIdForCommerce } from "../../../auth/org.js";
9
9
 
10
10
  type Db = PgDatabase<PgQueryResultHKT, Record<string, unknown>>;
11
11
 
@@ -17,7 +17,7 @@ export function adminJobRoutes(kernel: Kernel) {
17
17
 
18
18
  router.openapi(listFailedJobsRoute, async (c) => {
19
19
  const actor = c.get("actor");
20
- const orgId = resolveOrgId(actor);
20
+ const orgId = resolveOrgIdForCommerce(actor, kernel.config);
21
21
  const limit = Math.min(Number(c.req.query("limit") ?? "50"), 100);
22
22
  const conditions = [eq(commerceJobs.status, "failed")];
23
23
  // Scope to org unless wildcard admin
@@ -37,7 +37,7 @@ export function adminJobRoutes(kernel: Kernel) {
37
37
  // @ts-expect-error -- openapi handler union return type
38
38
  router.openapi(retryJobRoute, async (c) => {
39
39
  const actor = c.get("actor");
40
- const orgId = resolveOrgId(actor);
40
+ const orgId = resolveOrgIdForCommerce(actor, kernel.config);
41
41
  const id = c.req.param("id");
42
42
  const conditions = [eq(commerceJobs.id, id)];
43
43
  // Scope to org unless wildcard admin
@@ -2,7 +2,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { Kernel } from "../../../runtime/kernel.js";
3
3
  import { listAuditRoute, listEntityAuditRoute } from "../schemas/audit.js";
4
4
  import { type AppEnv, requirePerm } from "../utils.js";
5
- import { resolveOrgId } from "../../../auth/org.js";
5
+ import { resolveOrgIdForCommerce } from "../../../auth/org.js";
6
6
 
7
7
  export function auditRoutes(kernel: Kernel) {
8
8
  const router = new OpenAPIHono<AppEnv>();
@@ -15,7 +15,7 @@ export function auditRoutes(kernel: Kernel) {
15
15
 
16
16
  router.openapi(listAuditRoute, async (c) => {
17
17
  const actor = c.get("actor");
18
- const orgId = resolveOrgId(actor);
18
+ const orgId = resolveOrgIdForCommerce(actor, kernel.config);
19
19
  const entries = await kernel.services.audit.list({
20
20
  organizationId: orgId,
21
21
  entityType: c.req.query("entityType"),
@@ -37,7 +37,7 @@ export function auditRoutes(kernel: Kernel) {
37
37
 
38
38
  router.openapi(listEntityAuditRoute, async (c) => {
39
39
  const actor = c.get("actor");
40
- const orgId = resolveOrgId(actor);
40
+ const orgId = resolveOrgIdForCommerce(actor, kernel.config);
41
41
  const entries = await kernel.services.audit.listForEntity({
42
42
  organizationId: orgId,
43
43
  entityType: c.req.param("entityType"),
@@ -2,16 +2,22 @@ import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { Kernel } from "../../../runtime/kernel.js";
3
3
  import type { CreateCartInput, AddCartItemInput } from "../../../modules/cart/schemas.js";
4
4
  import { createCartRoute, addCartItemRoute, updateCartItemQuantityRoute, getCartRoute, removeCartItemRoute, listCartsRoute, recoverCartRoute } from "../schemas/carts.js";
5
- import { type AppEnv, mapErrorToResponse, mapErrorToStatus, parsePagination } from "../utils.js";
5
+ import { type AppEnv, mapErrorToResponse, mapErrorToStatus, parsePagination, requireMethodPerm, requirePerm } from "../utils.js";
6
6
 
7
7
  export function cartRoutes(kernel: Kernel) {
8
8
  const router = new OpenAPIHono<AppEnv>();
9
9
 
10
+ router.use("/", requireMethodPerm(["GET"], "cart:manage"));
11
+ router.use("/:id/recover", requirePerm("cart:manage"));
12
+
10
13
  // @ts-expect-error -- openapi() enforces strict response typing but our handler
11
14
  // returns union responses (201 | 400 | 422). The route definition documents the
12
15
  // contract; the handler returns dynamic status.
13
16
  router.openapi(createCartRoute, async (c) => {
14
17
  const actor = c.get("actor");
18
+ if (!actor) {
19
+ return c.json({ error: { code: "NOT_FOUND", message: "Cart not found." } }, 404);
20
+ }
15
21
  const result = await kernel.services.cart.create(c.req.valid("json") as CreateCartInput, actor);
16
22
  if (!result.ok)
17
23
  return c.json(
@@ -74,17 +80,12 @@ export function cartRoutes(kernel: Kernel) {
74
80
  // @ts-expect-error -- openapi handler union return type
75
81
  router.openapi(getCartRoute, async (c) => {
76
82
  const actor = c.get("actor");
77
- // Guest carts are gated by the cart secret; clients pass it as either
78
- // ?secret=... or X-Cart-Secret. Customer carts ignore the secret and
79
- // require ownership instead.
80
- const secretParam = c.req.query("secret");
81
83
  const secretHeader = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret");
82
- const secret = secretParam ?? secretHeader ?? undefined;
83
84
  const result = await kernel.services.cart.getById(
84
85
  c.req.param("id"),
85
86
  actor,
86
87
  undefined,
87
- secret,
88
+ secretHeader ?? undefined,
88
89
  );
89
90
  if (!result.ok)
90
91
  return c.json(
@@ -98,9 +99,12 @@ export function cartRoutes(kernel: Kernel) {
98
99
  // returns union responses (201 | 400 | 422). The route definition documents the
99
100
  // contract; the handler returns dynamic status.
100
101
  router.openapi(addCartItemRoute, async (c) => {
102
+ const secret = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret") ?? undefined;
101
103
  const result = await kernel.services.cart.addItem(
102
104
  { ...c.req.valid("json"), cartId: c.req.param("id") } as AddCartItemInput,
103
105
  c.get("actor"),
106
+ undefined,
107
+ secret,
104
108
  );
105
109
  if (!result.ok)
106
110
  return c.json(
@@ -115,6 +119,7 @@ export function cartRoutes(kernel: Kernel) {
115
119
  // contract; the handler returns dynamic status.
116
120
  router.openapi(updateCartItemQuantityRoute, async (c) => {
117
121
  const body = c.req.valid("json");
122
+ const secret = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret") ?? undefined;
118
123
  const result = await kernel.services.cart.updateQuantity(
119
124
  {
120
125
  cartId: c.req.param("id"),
@@ -122,6 +127,8 @@ export function cartRoutes(kernel: Kernel) {
122
127
  quantity: body.quantity,
123
128
  },
124
129
  c.get("actor"),
130
+ undefined,
131
+ secret,
125
132
  );
126
133
  if (!result.ok)
127
134
  return c.json(
@@ -133,10 +140,13 @@ export function cartRoutes(kernel: Kernel) {
133
140
 
134
141
  // @ts-expect-error -- openapi handler union return type
135
142
  router.openapi(removeCartItemRoute, async (c) => {
143
+ const secret = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret") ?? undefined;
136
144
  const result = await kernel.services.cart.removeItem(
137
145
  c.req.param("id"),
138
146
  c.req.param("itemId"),
139
147
  c.get("actor"),
148
+ undefined,
149
+ secret,
140
150
  );
141
151
  if (!result.ok)
142
152
  return c.json(
@@ -64,8 +64,17 @@ import {
64
64
  export function catalogRoutes(kernel: Kernel) {
65
65
  const router = new OpenAPIHono<AppEnv>();
66
66
 
67
+ router.use("/entities/*", requirePerm("catalog:read"));
68
+ router.use("/categories", requirePerm("catalog:read"));
69
+ router.use("/brands", requirePerm("catalog:read"));
70
+
67
71
  router.use("/entities/:id/custom-fields/:fieldName/approve", requirePerm("catalog:update"));
68
72
  router.use("/entities/:id/custom-fields/:fieldName/reject", requirePerm("catalog:update"));
73
+ router.use("/categories/:categoryId", requirePerm("catalog:update"));
74
+ router.use("/categories/:categoryId/*", requirePerm("catalog:update"));
75
+ router.use("/brands/:brandId", requirePerm("catalog:update"));
76
+ router.use("/brands/:brandId/*", requirePerm("catalog:update"));
77
+ router.use("/options/:optionTypeId/values", requirePerm("catalog:update"));
69
78
 
70
79
  // @ts-expect-error -- openapi handler union return type
71
80
  router.openapi(listFieldOwnershipRoute, async (c) => {
@@ -159,6 +168,7 @@ export function catalogRoutes(kernel: Kernel) {
159
168
  const full = await kernel.services.catalog.getById(
160
169
  item.id,
161
170
  includeOptions,
171
+ c.get("actor"),
162
172
  );
163
173
  return full.ok ? full.value : item;
164
174
  }),
@@ -358,6 +368,7 @@ export function catalogRoutes(kernel: Kernel) {
358
368
  const result = await kernel.services.catalog.getAttributes(
359
369
  c.req.param("id"),
360
370
  c.req.param("locale"),
371
+ c.get("actor"),
361
372
  );
362
373
  if (!result.ok)
363
374
  return c.json(
@@ -22,8 +22,8 @@ import type { PluginDb } from "../../../kernel/database/plugin-types.js";
22
22
  import { type AppEnv, mapErrorToResponse, mapErrorToStatus } from "../utils.js";
23
23
  import { isCommerceError } from "../../../kernel/errors.js";
24
24
  import { assertPermission } from "../../../auth/permissions.js";
25
- import { resolveOrgId } from "../../../auth/org.js";
26
- import { makeDeterministicId, makeId } from "../../../utils/id.js";
25
+ import { resolveOrgIdForCommerce } from "../../../auth/org.js";
26
+ import { makeDeterministicId, makeId, makeIdempotencyScope } from "../../../utils/id.js";
27
27
  import type { ShippingAddress } from "../../../modules/shipping/calculator.js";
28
28
  import type { Actor } from "../../../auth/types.js";
29
29
 
@@ -85,50 +85,48 @@ export function checkoutRoutes(kernel: Kernel) {
85
85
  const body = c.req.valid("json");
86
86
 
87
87
  const actor = c.get("actor");
88
+ const cartSecret = c.req.header("x-cart-secret") ?? c.req.header("X-Cart-Secret") ?? undefined;
88
89
 
89
- // Idempotent replay: a re-submitted checkout (offline POS queue, network
90
- // retry) returns the already-created order BEFORE the pipeline runs — no
91
- // double payment authorization, no duplicate order.
90
+ // Idempotent replay is authorized by the order service before payment runs.
91
+ // This preflight only preserves the no-double-payment fast path; the create
92
+ // primitive repeats the same binding for direct callers and race losers.
93
+ let replay: Awaited<ReturnType<typeof kernel.services.orders.getByIdempotencyKey>> | undefined;
92
94
  if (body.idempotencyKey) {
93
- const replay = await kernel.services.orders.getByIdempotencyKey(
95
+ replay = await kernel.services.orders.getByIdempotencyKey(
94
96
  body.idempotencyKey,
95
97
  actor,
98
+ undefined,
99
+ cartSecret,
96
100
  );
97
- if (replay.ok && replay.value) {
98
- // IDOR guard: an idempotency key must only replay the requester's OWN
99
- // order. Without this, a same-org customer who supplies/guesses another
100
- // customer's key would receive that customer's order (PII, totals, lines).
101
- let canActForOthers = false;
102
- try {
103
- assertPermission(actor, "customers:read");
104
- canActForOthers = true;
105
- } catch {
106
- canActForOthers = false;
107
- }
108
- const ownCustomer = actor
109
- ? await resolveCheckoutCustomerUuid(kernel.services.customers, actor, undefined)
110
- : undefined;
111
- const replayCustomer = replay.value.customerId ?? null;
112
- const ownsOrder =
113
- (replayCustomer !== null && replayCustomer === ownCustomer) ||
114
- (replayCustomer === null && !ownCustomer);
115
- if (canActForOthers || ownsOrder) {
116
- return c.json({ data: replay.value }, 201);
117
- }
118
- return c.json(
119
- {
120
- error: {
121
- code: "IDEMPOTENCY_CONFLICT",
122
- message: "Idempotency key does not belong to this account.",
123
- },
124
- },
125
- 409,
126
- );
101
+ if (!replay.ok) {
102
+ return c.json(mapErrorToResponse(replay.error), mapErrorToStatus(replay.error));
127
103
  }
104
+ if (replay.value) return c.json({ data: replay.value }, 201);
128
105
  }
129
106
 
107
+ // Authenticate the cart once at the HTTP boundary. Guest checkout is
108
+ // authorized by the cart secret; customer checkout is authorized by the
109
+ // customer cart ownership check. The hook pipeline uses its explicit
110
+ // internal cart-read path instead of carrying this credential inward.
111
+ const cartAccess = await kernel.services.cart.getById(
112
+ body.cartId,
113
+ actor,
114
+ undefined,
115
+ cartSecret,
116
+ );
117
+ if (!cartAccess.ok) {
118
+ return c.json(
119
+ mapErrorToResponse(cartAccess.error),
120
+ mapErrorToStatus(cartAccess.error),
121
+ );
122
+ }
123
+ const idempotencyScope = body.idempotencyKey
124
+ ? await makeIdempotencyScope(actor, cartSecret, body.cartId)
125
+ : undefined;
130
126
  const orderId = body.idempotencyKey
131
- ? await makeDeterministicId(`checkout:${resolveOrgId(actor)}:${body.idempotencyKey}`)
127
+ ? await makeDeterministicId(
128
+ `checkout:${resolveOrgIdForCommerce(actor, kernel.config)}:${idempotencyScope}:${body.idempotencyKey}`,
129
+ )
132
130
  : makeId();
133
131
  const checkoutData: CheckoutData = {
134
132
  checkoutId: makeId(),
@@ -203,6 +201,7 @@ export function checkoutRoutes(kernel: Kernel) {
203
201
  context: { moduleName: "checkout" },
204
202
  origin: "rest",
205
203
  database: { db: kernel.database.db as PluginDb },
204
+ commerceConfig: kernel.config,
206
205
  });
207
206
 
208
207
  let pipelineStage = "validate-and-calculate";
@@ -293,6 +292,7 @@ export function checkoutRoutes(kernel: Kernel) {
293
292
  pipelineStage = "create-order";
294
293
  const order = await kernel.services.orders.create(orderPayload, actor, undefined, {
295
294
  trustedPricing: true,
295
+ ...(cartSecret !== undefined ? { guestCredential: cartSecret } : {}),
296
296
  });
297
297
 
298
298
  if (!order.ok) {
@@ -379,7 +379,10 @@ export function checkoutRoutes(kernel: Kernel) {
379
379
  // Validation, payment authorization, or compensated completion may fail
380
380
  // after the cart's atomic active -> checking_out claim. Release only
381
381
  // that transient state so the shopper can correct the problem and retry.
382
- await kernel.services.cart.releaseCheckoutClaim(body.cartId).catch(
382
+ // Releasing is scoped to this attempt's claim token: an attempt that lost
383
+ // the claim never held it, so its failure cannot reopen a cart another
384
+ // checkout is still working through.
385
+ await kernel.services.cart.releaseCheckoutClaim(body.cartId, checkoutData.checkoutId).catch(
383
386
  (releaseError: unknown) => {
384
387
  console.error("[checkout] Failed to release cart claim:", {
385
388
  cartId: body.cartId,