@porulle/core 0.8.0 → 0.10.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 (162) hide show
  1. package/dist/auth/middleware.d.ts.map +1 -1
  2. package/dist/auth/middleware.js +26 -7
  3. package/dist/auth/setup.d.ts.map +1 -1
  4. package/dist/auth/setup.js +7 -5
  5. package/dist/config/defaults.d.ts.map +1 -1
  6. package/dist/config/defaults.js +3 -1
  7. package/dist/config/types.d.ts +13 -3
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/generated/plugin-manifest.d.ts +3 -0
  10. package/dist/generated/plugin-manifest.d.ts.map +1 -1
  11. package/dist/generated/plugin-manifest.js +1 -0
  12. package/dist/hooks/checkout-completion.d.ts.map +1 -1
  13. package/dist/hooks/checkout-completion.js +2 -2
  14. package/dist/hooks/checkout.d.ts.map +1 -1
  15. package/dist/hooks/checkout.js +1 -1
  16. package/dist/index.d.ts +5 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/interfaces/rest/router.d.ts.map +1 -1
  20. package/dist/interfaces/rest/router.js +2 -0
  21. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
  22. package/dist/interfaces/rest/routes/admin/staff.js +37 -3
  23. package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
  24. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  25. package/dist/interfaces/rest/routes/checkout.js +82 -22
  26. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  27. package/dist/interfaces/rest/routes/inventory.js +13 -0
  28. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  29. package/dist/interfaces/rest/routes/orders.js +13 -0
  30. package/dist/interfaces/rest/schemas/catalog.d.ts +391 -0
  31. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  32. package/dist/interfaces/rest/schemas/orders.js +2 -2
  33. package/dist/interfaces/rest/schemas/responses.d.ts +85 -0
  34. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  35. package/dist/interfaces/rest/schemas/search.d.ts +17 -0
  36. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  37. package/dist/kernel/database/scoped-db.d.ts +2 -1
  38. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  39. package/dist/kernel/database/scoped-db.js +74 -15
  40. package/dist/kernel/errors.d.ts +12 -0
  41. package/dist/kernel/errors.d.ts.map +1 -1
  42. package/dist/kernel/errors.js +16 -0
  43. package/dist/kernel/jobs/adapter.d.ts +27 -8
  44. package/dist/kernel/jobs/adapter.d.ts.map +1 -1
  45. package/dist/kernel/jobs/adapter.js +1 -2
  46. package/dist/kernel/jobs/drizzle-adapter.d.ts +13 -3
  47. package/dist/kernel/jobs/drizzle-adapter.d.ts.map +1 -1
  48. package/dist/kernel/jobs/drizzle-adapter.js +46 -5
  49. package/dist/kernel/jobs/runner.d.ts +2 -1
  50. package/dist/kernel/jobs/runner.d.ts.map +1 -1
  51. package/dist/kernel/jobs/runner.js +100 -15
  52. package/dist/kernel/jobs/types.d.ts +13 -0
  53. package/dist/kernel/jobs/types.d.ts.map +1 -1
  54. package/dist/kernel/plugin/manifest.d.ts +5 -0
  55. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  56. package/dist/kernel/plugin/manifest.js +9 -0
  57. package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
  58. package/dist/modules/analytics/drizzle-adapter.js +16 -3
  59. package/dist/modules/cart/service.d.ts.map +1 -1
  60. package/dist/modules/cart/service.js +3 -2
  61. package/dist/modules/catalog/entity-service.d.ts +1 -0
  62. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  63. package/dist/modules/catalog/entity-service.js +49 -3
  64. package/dist/modules/catalog/repository/index.d.ts +2 -2
  65. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  66. package/dist/modules/catalog/repository/index.js +9 -1
  67. package/dist/modules/catalog/schema.d.ts +51 -0
  68. package/dist/modules/catalog/schema.d.ts.map +1 -1
  69. package/dist/modules/catalog/schema.js +12 -1
  70. package/dist/modules/catalog/schemas.d.ts +3 -1
  71. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  72. package/dist/modules/channels/adapter.d.ts +117 -0
  73. package/dist/modules/channels/adapter.d.ts.map +1 -0
  74. package/dist/modules/channels/adapter.js +3 -0
  75. package/dist/modules/customers/service.d.ts.map +1 -1
  76. package/dist/modules/customers/service.js +6 -0
  77. package/dist/modules/inventory/repository/index.js +4 -4
  78. package/dist/modules/inventory/service.d.ts.map +1 -1
  79. package/dist/modules/inventory/service.js +7 -3
  80. package/dist/modules/orders/schema.d.ts +17 -0
  81. package/dist/modules/orders/schema.d.ts.map +1 -1
  82. package/dist/modules/orders/schema.js +4 -0
  83. package/dist/modules/orders/service.d.ts +16 -2
  84. package/dist/modules/orders/service.d.ts.map +1 -1
  85. package/dist/modules/orders/service.js +226 -26
  86. package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
  87. package/dist/modules/orders/stale-order-cleanup.js +37 -16
  88. package/dist/modules/pricing/service.d.ts.map +1 -1
  89. package/dist/modules/pricing/service.js +5 -5
  90. package/dist/modules/promotions/repository/index.d.ts +1 -1
  91. package/dist/modules/promotions/repository/index.d.ts.map +1 -1
  92. package/dist/modules/promotions/repository/index.js +6 -3
  93. package/dist/modules/promotions/service.d.ts +1 -0
  94. package/dist/modules/promotions/service.d.ts.map +1 -1
  95. package/dist/modules/promotions/service.js +1 -1
  96. package/dist/modules/webhooks/repository/index.d.ts +1 -1
  97. package/dist/modules/webhooks/repository/index.d.ts.map +1 -1
  98. package/dist/modules/webhooks/repository/index.js +16 -10
  99. package/dist/runtime/kernel.d.ts.map +1 -1
  100. package/dist/runtime/kernel.js +14 -2
  101. package/dist/runtime/server.d.ts +1 -4
  102. package/dist/runtime/server.d.ts.map +1 -1
  103. package/dist/runtime/server.js +16 -34
  104. package/dist/test-utils/create-test-config.d.ts.map +1 -1
  105. package/dist/test-utils/create-test-config.js +2 -0
  106. package/dist/test-utils/order-test-helpers.d.ts +12 -0
  107. package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
  108. package/dist/test-utils/order-test-helpers.js +18 -0
  109. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  110. package/dist/test-utils/rest-api-test-utils.js +1 -0
  111. package/dist/test-utils/test-actors.js +1 -1
  112. package/dist/testing.d.ts +1 -0
  113. package/dist/testing.d.ts.map +1 -1
  114. package/dist/testing.js +1 -0
  115. package/package.json +1 -1
  116. package/src/auth/middleware.ts +26 -7
  117. package/src/auth/setup.ts +10 -4
  118. package/src/config/defaults.ts +3 -1
  119. package/src/config/types.ts +16 -3
  120. package/src/generated/plugin-capabilities.d.ts +1 -0
  121. package/src/generated/plugin-manifest.ts +1 -0
  122. package/src/generated/plugin-repositories.d.ts +1 -0
  123. package/src/hooks/checkout-completion.ts +22 -16
  124. package/src/hooks/checkout.ts +11 -8
  125. package/src/index.ts +31 -1
  126. package/src/interfaces/rest/router.ts +2 -0
  127. package/src/interfaces/rest/routes/admin/staff.ts +45 -3
  128. package/src/interfaces/rest/routes/checkout.ts +94 -28
  129. package/src/interfaces/rest/routes/inventory.ts +14 -0
  130. package/src/interfaces/rest/routes/orders.ts +11 -0
  131. package/src/interfaces/rest/schemas/orders.ts +2 -2
  132. package/src/kernel/database/scoped-db.ts +83 -15
  133. package/src/kernel/errors.ts +20 -0
  134. package/src/kernel/jobs/adapter.ts +39 -8
  135. package/src/kernel/jobs/drizzle-adapter.ts +72 -9
  136. package/src/kernel/jobs/runner.ts +194 -72
  137. package/src/kernel/jobs/types.ts +25 -0
  138. package/src/kernel/plugin/manifest.ts +15 -0
  139. package/src/modules/analytics/drizzle-adapter.ts +19 -3
  140. package/src/modules/cart/service.ts +3 -2
  141. package/src/modules/catalog/entity-service.ts +37 -2
  142. package/src/modules/catalog/repository/index.ts +13 -3
  143. package/src/modules/catalog/schema.ts +33 -18
  144. package/src/modules/catalog/schemas.ts +3 -1
  145. package/src/modules/channels/adapter.ts +139 -0
  146. package/src/modules/customers/service.ts +6 -0
  147. package/src/modules/inventory/repository/index.ts +4 -4
  148. package/src/modules/inventory/service.ts +12 -2
  149. package/src/modules/orders/schema.ts +4 -0
  150. package/src/modules/orders/service.ts +404 -64
  151. package/src/modules/orders/stale-order-cleanup.ts +52 -27
  152. package/src/modules/pricing/service.ts +5 -6
  153. package/src/modules/promotions/repository/index.ts +6 -2
  154. package/src/modules/promotions/service.ts +2 -0
  155. package/src/modules/webhooks/repository/index.ts +16 -14
  156. package/src/runtime/kernel.ts +16 -2
  157. package/src/runtime/server.ts +22 -42
  158. package/src/test-utils/create-test-config.ts +2 -0
  159. package/src/test-utils/order-test-helpers.ts +26 -0
  160. package/src/test-utils/rest-api-test-utils.ts +1 -0
  161. package/src/test-utils/test-actors.ts +1 -1
  162. package/src/testing.ts +1 -0
@@ -55,6 +55,35 @@ export function adminStaffRoutes(kernel: Kernel) {
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;
66
+ }
67
+
68
+ function canGrantRole(actorRole: string, targetRole: string): boolean {
69
+ return roleRank(actorRole) >= roleRank(targetRole);
70
+ }
71
+
72
+ function insufficientPrivilege(
73
+ c: { json: (d: unknown, s: number) => unknown },
74
+ targetRole: string,
75
+ ) {
76
+ return c.json(
77
+ {
78
+ error: {
79
+ code: "FORBIDDEN",
80
+ message: `Cannot assign role "${targetRole}": your role does not have sufficient privilege.`,
81
+ },
82
+ },
83
+ 403,
84
+ );
85
+ }
86
+
58
87
  async function countOwners(orgId: string): Promise<number> {
59
88
  const rows = await db
60
89
  .select({ id: member.id })
@@ -83,9 +112,11 @@ export function adminStaffRoutes(kernel: Kernel) {
83
112
  // @ts-expect-error -- openapi handler union return type
84
113
  router.openapi(createStaffRoute, async (c) => {
85
114
  const body = c.req.valid("json") as { userId: string; role: string };
86
- const orgId = resolveOrgId(c.get("actor"));
115
+ const actor = c.get("actor");
116
+ const orgId = resolveOrgId(actor);
87
117
 
88
118
  if (!validRoles().has(body.role)) return invalidRole(c, body.role);
119
+ if (!canGrantRole(actor!.role, body.role)) return insufficientPrivilege(c, body.role);
89
120
 
90
121
  const users = await db.select().from(user).where(eq(user.id, body.userId));
91
122
  if (users.length === 0) {
@@ -160,10 +191,12 @@ export function adminStaffRoutes(kernel: Kernel) {
160
191
  // @ts-expect-error -- openapi handler union return type
161
192
  router.openapi(updateStaffRoleRoute, async (c) => {
162
193
  const body = c.req.valid("json") as { role: string };
163
- const orgId = resolveOrgId(c.get("actor"));
194
+ const actor = c.get("actor");
195
+ const orgId = resolveOrgId(actor);
164
196
  const id = c.req.param("id");
165
197
 
166
198
  if (!validRoles().has(body.role)) return invalidRole(c, body.role);
199
+ if (!canGrantRole(actor!.role, body.role)) return insufficientPrivilege(c, body.role);
167
200
 
168
201
  const rows = await db
169
202
  .select()
@@ -174,6 +207,10 @@ export function adminStaffRoutes(kernel: Kernel) {
174
207
  return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
175
208
  }
176
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
+
177
214
  if (target.role === "owner" && body.role !== "owner" && (await countOwners(orgId)) <= 1) {
178
215
  return c.json(
179
216
  { error: { code: "VALIDATION_FAILED", message: "Cannot demote the organization's last owner." } },
@@ -191,7 +228,8 @@ export function adminStaffRoutes(kernel: Kernel) {
191
228
 
192
229
  // @ts-expect-error -- openapi handler union return type
193
230
  router.openapi(revokeStaffRoute, async (c) => {
194
- const orgId = resolveOrgId(c.get("actor"));
231
+ const actor = c.get("actor");
232
+ const orgId = resolveOrgId(actor);
195
233
  const id = c.req.param("id");
196
234
 
197
235
  const rows = await db
@@ -203,6 +241,10 @@ export function adminStaffRoutes(kernel: Kernel) {
203
241
  return c.json({ error: { code: "NOT_FOUND", message: "Staff member not found." } }, 404);
204
242
  }
205
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
+
206
248
  if (target.role === "owner" && (await countOwners(orgId)) <= 1) {
207
249
  return c.json(
208
250
  { error: { code: "VALIDATION_FAILED", message: "Cannot revoke the organization's last owner." } },
@@ -21,8 +21,58 @@ import type { AfterHook, BeforeHook, ServiceContainer } from "../../../kernel/ho
21
21
  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
+ import { assertPermission } from "../../../auth/permissions.js";
24
25
  import { makeId } from "../../../utils/id.js";
25
26
  import type { ShippingAddress } from "../../../modules/shipping/calculator.js";
27
+ import type { Actor } from "../../../auth/types.js";
28
+
29
+ /**
30
+ * SEC-07 — resolve the customer profile a checkout order is attributed to.
31
+ * A self-service actor may ONLY attribute the order to its own customer
32
+ * profile; only actors with org-level `customers:read` (staff/clienteling) may
33
+ * name an arbitrary `customerId`. Returns undefined for guests / unresolved
34
+ * (guest checkout). Exported for unit testing.
35
+ */
36
+ export async function resolveCheckoutCustomerUuid(
37
+ customers: Kernel["services"]["customers"],
38
+ actor: Actor | null,
39
+ customerId: string | undefined,
40
+ ): Promise<string | undefined> {
41
+ const actorUserId = actor?.userId;
42
+ let canActForOthers = false;
43
+ if (actorUserId) {
44
+ try {
45
+ assertPermission(actor, "customers:read");
46
+ canActForOthers = true;
47
+ } catch {
48
+ canActForOthers = false;
49
+ }
50
+ }
51
+
52
+ if (customerId) {
53
+ const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
54
+ if (uuidRe.test(customerId)) {
55
+ const byId = await customers.getById(customerId, actor);
56
+ // Honor a profile UUID only if it is the actor's own, or the actor may
57
+ // act for other customers.
58
+ if (byId.ok && (canActForOthers || byId.value.userId === actorUserId)) {
59
+ return byId.value.id;
60
+ }
61
+ }
62
+ if (canActForOthers) {
63
+ // Staff may resolve/create a customer by a supplied user_id.
64
+ const byUser = await customers.getByUserId(customerId, actor);
65
+ if (byUser.ok) return byUser.value.id;
66
+ }
67
+ }
68
+
69
+ // Self-service default: the authenticated actor's own customer profile.
70
+ if (actorUserId && !canActForOthers) {
71
+ const own = await customers.getByUserId(actorUserId, actor);
72
+ if (own.ok) return own.value.id;
73
+ }
74
+ return undefined;
75
+ }
26
76
 
27
77
  export function checkoutRoutes(kernel: Kernel) {
28
78
  const router = new OpenAPIHono<AppEnv>();
@@ -44,7 +94,35 @@ export function checkoutRoutes(kernel: Kernel) {
44
94
  actor,
45
95
  );
46
96
  if (replay.ok && replay.value) {
47
- return c.json({ data: replay.value }, 201);
97
+ // IDOR guard: an idempotency key must only replay the requester's OWN
98
+ // order. Without this, a same-org customer who supplies/guesses another
99
+ // customer's key would receive that customer's order (PII, totals, lines).
100
+ let canActForOthers = false;
101
+ try {
102
+ assertPermission(actor, "customers:read");
103
+ canActForOthers = true;
104
+ } catch {
105
+ canActForOthers = false;
106
+ }
107
+ const ownCustomer = actor
108
+ ? await resolveCheckoutCustomerUuid(kernel.services.customers, actor, undefined)
109
+ : undefined;
110
+ const replayCustomer = replay.value.customerId ?? null;
111
+ const ownsOrder =
112
+ (replayCustomer !== null && replayCustomer === ownCustomer) ||
113
+ (replayCustomer === null && !ownCustomer);
114
+ if (canActForOthers || ownsOrder) {
115
+ return c.json({ data: replay.value }, 201);
116
+ }
117
+ return c.json(
118
+ {
119
+ error: {
120
+ code: "IDEMPOTENCY_CONFLICT",
121
+ message: "Idempotency key does not belong to this account.",
122
+ },
123
+ },
124
+ 409,
125
+ );
48
126
  }
49
127
  }
50
128
 
@@ -135,32 +213,14 @@ export function checkoutRoutes(kernel: Kernel) {
135
213
  context,
136
214
  );
137
215
 
138
- // Resolve customer profile UUID from customerId (may be a profile UUID or a Better Auth user_id)
139
- let customerUuid: string | undefined = undefined;
140
- if (processed.customerId) {
141
- const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
142
- if (uuidRe.test(processed.customerId)) {
143
- // Looks like a profile UUID — try direct lookup (no auto-create)
144
- const byIdResult = await kernel.services.customers.getById(
145
- processed.customerId,
146
- actor,
147
- );
148
- if (byIdResult.ok) {
149
- customerUuid = byIdResult.value.id;
150
- }
151
- }
152
- if (!customerUuid) {
153
- // Fall back to user_id lookup (auto-creates customer profile if needed)
154
- const byUserIdResult = await kernel.services.customers.getByUserId(
155
- processed.customerId,
156
- actor,
157
- );
158
- if (byUserIdResult.ok) {
159
- customerUuid = byUserIdResult.value.id;
160
- }
161
- }
162
- // If both lookups fail, we still allow guest checkout (customerUuid remains undefined)
163
- }
216
+ // SEC-07: resolve the order's customer server-side. A self-service actor
217
+ // can only attribute the order to its own profile; a client-supplied
218
+ // foreign customerId is ignored unless the actor is staff.
219
+ const customerUuid = await resolveCheckoutCustomerUuid(
220
+ kernel.services.customers,
221
+ actor,
222
+ processed.customerId,
223
+ );
164
224
 
165
225
  const orderPayload = {
166
226
  ...(body.idempotencyKey !== undefined
@@ -209,7 +269,12 @@ export function checkoutRoutes(kernel: Kernel) {
209
269
  : {}),
210
270
  };
211
271
 
212
- const order = await kernel.services.orders.create(orderPayload, actor);
272
+ // Checkout is a trusted, already-server-priced pipeline (resolveCurrentPrices
273
+ // + promotions/tax) and reserves stock in its own after-hooks — so it hands
274
+ // the order primitive precomputed totals rather than re-deriving them.
275
+ const order = await kernel.services.orders.create(orderPayload, actor, undefined, {
276
+ trustedPricing: true,
277
+ });
213
278
 
214
279
  if (!order.ok) {
215
280
  return c.json(
@@ -221,6 +286,7 @@ export function checkoutRoutes(kernel: Kernel) {
221
286
  if (order.ok && (processed.appliedPromotions?.length ?? 0) > 0) {
222
287
  await kernel.services.promotions.recordUsage({
223
288
  promotions: processed.appliedPromotions ?? [],
289
+ organizationId: order.value.organizationId,
224
290
  orderId: order.value.id,
225
291
  ...(customerUuid !== undefined
226
292
  ? { customerId: customerUuid }
@@ -17,6 +17,14 @@ export function inventoryRoutes(kernel: Kernel) {
17
17
  // Inventory levels listing requires inventory:read
18
18
  router.use("/levels", requirePerm("inventory:read"));
19
19
 
20
+ // Warehouse config + reservation are staff operations, never public. Gate them
21
+ // at the route (reserve/release are also called internally by checkout with the
22
+ // customer's actor, so the service itself must stay callable — the boundary is
23
+ // the HTTP route). Blocks anonymous (401) and customers lacking the perm (403).
24
+ router.use("/warehouses", requirePerm("inventory:read"));
25
+ router.use("/reserve", requirePerm("inventory:adjust"));
26
+ router.use("/release", requirePerm("inventory:adjust"));
27
+
20
28
  // @ts-expect-error -- openapi handler union return type
21
29
  router.openapi(listInventoryLevelsRoute, async (c) => {
22
30
  const actor = c.get("actor");
@@ -78,6 +86,12 @@ export function inventoryRoutes(kernel: Kernel) {
78
86
  router.openapi(createWarehouseRoute, async (c) => {
79
87
  const body = c.req.valid("json") as Parameters<typeof kernel.services.inventory.createWarehouse>[0];
80
88
  const actor = c.get("actor");
89
+ // Creating a warehouse (write) needs the higher inventory:adjust perm; the
90
+ // router.use above already requires inventory:read for the /warehouses path.
91
+ const perms = (actor as { permissions?: string[] } | null)?.permissions ?? [];
92
+ if (!(perms.includes("inventory:adjust") || perms.includes("inventory:*") || perms.includes("*:*"))) {
93
+ return c.json({ error: { code: "FORBIDDEN", message: "Permission 'inventory:adjust' is required." } }, 403);
94
+ }
81
95
  const result = await kernel.services.inventory.createWarehouse(body, actor);
82
96
  if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
83
97
  return c.json({ data: result.value }, 201);
@@ -3,6 +3,7 @@ import type { Kernel } from "../../../runtime/kernel.js";
3
3
  import { changeOrderStatusRoute, listOrdersRoute, orderLookupRoute, getOrderRoute, getOrderFulfillmentsRoute, createOrderRoute, refundOrderRoute, captureOrderRoute, createOrderFulfillmentRoute, addOrderLineItemRoute, updateOrderLineItemRoute, removeOrderLineItemRoute, refundOrderLinesRoute, undoOrderRefundRoute, listOrderRefundsRoute, refundCapStatusRoute, createOrderNoteRoute, listOrderNotesRoute, deleteOrderNoteRoute, orderTimelineRoute } from "../schemas/orders.js";
4
4
  import { type AppEnv, isUUID, mapErrorToResponse, mapErrorToStatus, parsePagination } from "../utils.js";
5
5
  import type { CreateOrderInput } from "../../../modules/orders/service.js";
6
+ import { assertPermission } from "../../../auth/permissions.js";
6
7
 
7
8
  export function orderRoutes(kernel: Kernel) {
8
9
  const router = new OpenAPIHono<AppEnv>();
@@ -62,6 +63,11 @@ export function orderRoutes(kernel: Kernel) {
62
63
 
63
64
  // @ts-expect-error -- openapi handler union return type
64
65
  router.openapi(createOrderRoute, async (c) => {
66
+ try {
67
+ assertPermission(c.get("actor"), "orders:manage");
68
+ } catch (error) {
69
+ return c.json(mapErrorToResponse(error), mapErrorToStatus(error));
70
+ }
65
71
  const body = c.req.valid("json") as CreateOrderInput;
66
72
  const result = await kernel.services.orders.create(body, c.get("actor"));
67
73
  if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
@@ -170,6 +176,11 @@ export function orderRoutes(kernel: Kernel) {
170
176
 
171
177
  // @ts-expect-error -- openapi handler union return type
172
178
  router.openapi(addOrderLineItemRoute, async (c) => {
179
+ try {
180
+ assertPermission(c.get("actor"), "orders:manage");
181
+ } catch (error) {
182
+ return c.json(mapErrorToResponse(error), mapErrorToStatus(error));
183
+ }
173
184
  const body = c.req.valid("json");
174
185
  const result = await kernel.services.orders.addLineItem(
175
186
  c.req.param("id"),
@@ -184,7 +184,7 @@ export const createOrderRoute = createRoute({
184
184
  path: "/",
185
185
  tags: ["Orders"],
186
186
  summary: "Create a draft / manual order",
187
- description: "Operator-created order (phone / POS / manual) with line items and totals, optionally without immediate payment.",
187
+ description: "Requires `orders:manage`. Creates an operator-entered order (phone / POS / manual); supplied line prices are stored as audited manual overrides.",
188
188
  request: {
189
189
  body: {
190
190
  content: { "application/json": { schema: CreateOrderBodySchema } },
@@ -272,7 +272,7 @@ export const addOrderLineItemRoute = createRoute({
272
272
  path: "/{id}/line-items",
273
273
  tags: ["Orders"],
274
274
  summary: "Add a line item to a placed order",
275
- description: "Adds a line item to a non-terminal order and recalculates subtotal/tax/grand totals. Records an audit entry.",
275
+ description: "Requires `orders:manage`. Adds a line item to a non-terminal order, records its supplied price as a manual override, and recalculates totals.",
276
276
  request: {
277
277
  params: OrderIdParam,
278
278
  body: {
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Wraps a Drizzle PgDatabase instance so that INSERT operations on
5
5
  * org-scoped tables automatically include the actor's organizationId,
6
- * and SELECT ... FROM org-scoped tables constrain rows to that organization.
6
+ * and SELECT / UPDATE / DELETE against org-scoped tables constrain rows to
7
+ * that organization — including when the caller supplies no WHERE clause.
7
8
  *
8
9
  * Plugin route handlers receive this scoped db via PluginContext.database.db
9
10
  * (organization resolved per operation from AsyncLocalStorage when used from
@@ -30,6 +31,45 @@ function tableHasOrganizationId(table: unknown): boolean {
30
31
  }
31
32
  }
32
33
 
34
+ /**
35
+ * Wrap a WHERE-able Drizzle builder so every `.where()` call AND-s `orgEq` in
36
+ * AND re-wraps its result. Re-wrapping is what makes a chained
37
+ * `.where(a).where(b)` safe: Drizzle's second `.where()` REPLACES the first, so
38
+ * without re-wrapping the chained call would reach the raw builder and execute
39
+ * with only the caller's condition — dropping the org predicate and leaking
40
+ * across tenants. Every link in the chain re-injects `orgEq`.
41
+ */
42
+ function wrapWhereable(builder: unknown, orgEq: SQL): unknown {
43
+ return new Proxy(builder as Record<string, unknown>, {
44
+ get(st, sp, sr) {
45
+ const sv = Reflect.get(st, sp, sr);
46
+ if (sp === "where" && typeof sv === "function") {
47
+ return (condition?: unknown, ...wRest: unknown[]) => {
48
+ const c = condition as SQL | undefined;
49
+ const merged = c ? and(orgEq, c) : orgEq;
50
+ const next = (sv as (...a: unknown[]) => unknown).call(st, merged, ...wRest);
51
+ return wrapWhereable(next, orgEq);
52
+ };
53
+ }
54
+ return typeof sv === "function" ? sv.bind(st) : sv;
55
+ },
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Pre-apply the org predicate to a WHERE-able builder (UPDATE/DELETE) and wrap
61
+ * its `.where()` so a caller-supplied condition is AND-ed with the org filter
62
+ * rather than replacing it. Pre-applying `orgEq` immediately means a builder
63
+ * executed with NO caller `.where()` (e.g. `update(t).set(...)`) is still
64
+ * constrained to the org — it can never touch another tenant's rows.
65
+ */
66
+ function scopeWhereBuilder(
67
+ builder: { where: (c: SQL | undefined) => unknown },
68
+ orgEq: SQL,
69
+ ): unknown {
70
+ return wrapWhereable(builder.where(orgEq), orgEq);
71
+ }
72
+
33
73
  export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId): TDb {
34
74
  if (!rawDb || typeof rawDb !== "object") return rawDb;
35
75
 
@@ -84,20 +124,7 @@ export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId)
84
124
 
85
125
  const orgEq = eq(orgCol, resolveOrganizationId(orgSource));
86
126
  const inner = chain as { where: (c: SQL | undefined) => unknown };
87
- const scoped = inner.where(orgEq);
88
- return new Proxy(scoped as Record<string, unknown>, {
89
- get(st, sp, sr) {
90
- const sv = Reflect.get(st, sp, sr);
91
- if (sp === "where" && typeof sv === "function") {
92
- return (condition?: unknown, ...wRest: unknown[]) => {
93
- const c = condition as SQL | undefined;
94
- const merged = c ? and(orgEq, c) : orgEq;
95
- return sv.call(st, merged, ...wRest);
96
- };
97
- }
98
- return typeof sv === "function" ? sv.bind(st) : sv;
99
- },
100
- });
127
+ return wrapWhereable(inner.where(orgEq), orgEq);
101
128
  };
102
129
  }
103
130
  return typeof sbVal === "function" ? sbVal.bind(sbTarget) : sbVal;
@@ -106,6 +133,47 @@ export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId)
106
133
  };
107
134
  }
108
135
 
136
+ if (prop === "update" && typeof value === "function") {
137
+ return (table: PgTable) => {
138
+ const builder = value.call(target, table);
139
+ if (!tableHasOrganizationId(table)) return builder;
140
+ const orgCol = getTableColumns(table).organizationId;
141
+ if (!orgCol) return builder;
142
+ const orgEq = eq(orgCol, resolveOrganizationId(orgSource));
143
+ // The org filter lives after `.set()`, so wrap `.set()` and scope the
144
+ // resulting WHERE-able builder.
145
+ return new Proxy(builder as Record<string, unknown>, {
146
+ get(t, p, r) {
147
+ const v = Reflect.get(t, p, r);
148
+ if (p === "set" && typeof v === "function") {
149
+ return (data: unknown) => {
150
+ const setBuilder = (v as (...a: unknown[]) => unknown).call(t, data);
151
+ return scopeWhereBuilder(
152
+ setBuilder as { where: (c: SQL | undefined) => unknown },
153
+ orgEq,
154
+ );
155
+ };
156
+ }
157
+ return typeof v === "function" ? v.bind(t) : v;
158
+ },
159
+ });
160
+ };
161
+ }
162
+
163
+ if (prop === "delete" && typeof value === "function") {
164
+ return (table: PgTable) => {
165
+ const builder = value.call(target, table);
166
+ if (!tableHasOrganizationId(table)) return builder;
167
+ const orgCol = getTableColumns(table).organizationId;
168
+ if (!orgCol) return builder;
169
+ const orgEq = eq(orgCol, resolveOrganizationId(orgSource));
170
+ return scopeWhereBuilder(
171
+ builder as { where: (c: SQL | undefined) => unknown },
172
+ orgEq,
173
+ );
174
+ };
175
+ }
176
+
109
177
  return typeof value === "function" ? value.bind(target) : value;
110
178
  },
111
179
  }) as TDb;
@@ -32,6 +32,26 @@ export class CommerceValidationError extends Error implements CommerceError {
32
32
  }
33
33
  }
34
34
 
35
+ /** Canonical message for a missing inventory record — the single source used by
36
+ * the inventory repository and mapped to the typed error below. */
37
+ export const INVENTORY_RECORD_NOT_FOUND_MESSAGE =
38
+ "No inventory record found for this entity.";
39
+
40
+ /** Raised when an inventory reserve/release targets an entity with no inventory
41
+ * record. Consumers (e.g. order status transitions that tolerate a missing
42
+ * record) discriminate on `code === "INVENTORY_RECORD_NOT_FOUND"`, never the
43
+ * message string. */
44
+ export class CommerceInventoryRecordNotFoundError extends Error implements CommerceError {
45
+ code = "INVENTORY_RECORD_NOT_FOUND" as const;
46
+ constructor(
47
+ message: string = INVENTORY_RECORD_NOT_FOUND_MESSAGE,
48
+ public details?: unknown,
49
+ ) {
50
+ super(message);
51
+ this.name = "CommerceInventoryRecordNotFoundError";
52
+ }
53
+ }
54
+
35
55
  export class CommerceForbiddenError extends Error implements CommerceError {
36
56
  code = "FORBIDDEN" as const;
37
57
  constructor(
@@ -1,9 +1,10 @@
1
- /**
2
- * Minimal interface for enqueueing background jobs.
3
- * The full DrizzleJobsAdapter implements this; hooks receive
4
- * it on HookContext.jobs so they can defer work without caring
5
- * about the underlying storage.
6
- */
1
+ import type {
2
+ JobProcessingOrder,
3
+ TaskContext,
4
+ TaskDefinition,
5
+ } from "./types.js";
6
+
7
+ /** Enqueue-only surface exposed to hooks and services. */
7
8
  export interface JobsAdapter {
8
9
  enqueue(
9
10
  taskSlug: string,
@@ -12,6 +13,37 @@ export interface JobsAdapter {
12
13
  ): Promise<string>;
13
14
  }
14
15
 
16
+ export interface RunJobsOptions {
17
+ queue?: string;
18
+ limit?: number;
19
+ }
20
+
21
+ export interface RunJobsResult {
22
+ processed: number;
23
+ failed: number;
24
+ }
25
+
26
+ export interface ExecutionEngineSetup {
27
+ tasks: ReadonlyMap<string, TaskDefinition>;
28
+ context: TaskContext;
29
+ processingOrder?: JobProcessingOrder;
30
+ }
31
+
32
+ export type ExecutionDriver =
33
+ | {
34
+ mode: "pull";
35
+ run(options?: RunJobsOptions): Promise<RunJobsResult>;
36
+ }
37
+ | {
38
+ mode: "push";
39
+ };
40
+
41
+ /** Full job-engine contract selected through `config.jobs.adapter`. */
42
+ export interface ExecutionEngine extends JobsAdapter {
43
+ readonly execution: ExecutionDriver;
44
+ register(setup: ExecutionEngineSetup): void;
45
+ }
46
+
15
47
  export interface EnqueueOptions {
16
48
  organizationId: string;
17
49
  queue?: string;
@@ -22,8 +54,7 @@ export interface EnqueueOptions {
22
54
  }
23
55
 
24
56
  /**
25
- * No-op adapter used when no jobs backend is configured.
26
- * All enqueue calls silently succeed and return a placeholder ID.
57
+ * No-op enqueue surface used by isolated hook contexts.
27
58
  */
28
59
  export class NullJobsAdapter implements JobsAdapter {
29
60
  async enqueue(
@@ -1,21 +1,79 @@
1
1
  import { eq, and } from "drizzle-orm";
2
2
  import type { DrizzleDatabase } from "../database/drizzle-db.js";
3
3
  import type { TaskDefinition } from "./types.js";
4
- import type { JobsAdapter, EnqueueOptions } from "./adapter.js";
4
+ import type {
5
+ EnqueueOptions,
6
+ ExecutionEngine,
7
+ ExecutionEngineSetup,
8
+ RunJobsOptions,
9
+ } from "./adapter.js";
5
10
  import { OrgResolutionError } from "../errors.js";
6
11
  import { commerceJobs } from "./schema.js";
12
+ import { runPendingJobs } from "./runner.js";
13
+ import {
14
+ getJobReapThresholdMs,
15
+ getJobsReaperIntervalMs,
16
+ runStaleJobReaper,
17
+ } from "./reaper.js";
7
18
 
8
19
  /**
9
20
  * PostgreSQL-backed job queue adapter using the application's own database.
10
21
  * Stores jobs in the `commerce_jobs` table. Supports concurrency keys
11
22
  * and supersede semantics for deduplication.
12
23
  */
13
- export class DrizzleJobsAdapter implements JobsAdapter {
24
+ export class DrizzleJobsAdapter implements ExecutionEngine {
25
+ private setup: ExecutionEngineSetup | undefined;
26
+ private lastStaleJobReaperAt = 0;
27
+
28
+ readonly execution = {
29
+ mode: "pull" as const,
30
+ run: async (options: RunJobsOptions = {}) => {
31
+ if (!this.setup) {
32
+ throw new Error(
33
+ "DrizzleJobsAdapter must be registered before running jobs.",
34
+ );
35
+ }
36
+
37
+ const now = Date.now();
38
+ if (now - this.lastStaleJobReaperAt >= getJobsReaperIntervalMs()) {
39
+ this.lastStaleJobReaperAt = now;
40
+ try {
41
+ await runStaleJobReaper(
42
+ this.db,
43
+ getJobReapThresholdMs(),
44
+ this.setup.context.logger,
45
+ );
46
+ } catch (error) {
47
+ this.setup.context.logger.error("Stale job reaper failed", {
48
+ error: error instanceof Error ? error.message : String(error),
49
+ });
50
+ }
51
+ }
52
+
53
+ return runPendingJobs({
54
+ db: this.db,
55
+ tasks: new Map(this.setup.tasks),
56
+ logger: this.setup.context.logger,
57
+ services: this.setup.context.services,
58
+ ...(options.queue !== undefined ? { queue: options.queue } : {}),
59
+ ...(options.limit !== undefined ? { limit: options.limit } : {}),
60
+ ...(this.setup.processingOrder !== undefined
61
+ ? { processingOrder: this.setup.processingOrder }
62
+ : {}),
63
+ });
64
+ },
65
+ };
66
+
14
67
  constructor(
15
68
  private db: DrizzleDatabase,
16
- private tasks: Map<string, TaskDefinition>,
69
+ private tasks: Map<string, TaskDefinition> = new Map(),
17
70
  ) {}
18
71
 
72
+ register(setup: ExecutionEngineSetup): void {
73
+ this.setup = setup;
74
+ this.tasks = new Map(setup.tasks);
75
+ }
76
+
19
77
  async enqueue(
20
78
  taskSlug: string,
21
79
  input: Record<string, unknown>,
@@ -28,22 +86,27 @@ export class DrizzleJobsAdapter implements JobsAdapter {
28
86
  );
29
87
  }
30
88
 
89
+ const task = this.tasks.get(taskSlug);
90
+ const concurrencyKey =
91
+ options.concurrencyKey ?? task?.concurrency?.key(input);
92
+ const supersedes = options.supersedes ?? task?.concurrency?.supersedes;
93
+
31
94
  // If supersedes is set, delete existing pending jobs with the same concurrency key
32
- if (options.concurrencyKey && options.supersedes) {
95
+ if (concurrencyKey && supersedes) {
33
96
  await this.db
34
97
  .delete(commerceJobs)
35
98
  .where(
36
99
  and(
37
- eq(commerceJobs.concurrencyKey, options.concurrencyKey),
100
+ eq(commerceJobs.organizationId, organizationId),
101
+ eq(commerceJobs.taskSlug, taskSlug),
102
+ eq(commerceJobs.concurrencyKey, concurrencyKey),
38
103
  eq(commerceJobs.status, "pending"),
39
104
  ),
40
105
  );
41
106
  }
42
107
 
43
108
  // Look up task definition for default retry config
44
- const task = this.tasks.get(taskSlug);
45
- const maxAttempts =
46
- options.maxAttempts ?? task?.retries?.attempts ?? 1;
109
+ const maxAttempts = options.maxAttempts ?? task?.retries?.attempts ?? 1;
47
110
 
48
111
  const rows = await this.db
49
112
  .insert(commerceJobs)
@@ -56,7 +119,7 @@ export class DrizzleJobsAdapter implements JobsAdapter {
56
119
  waitUntil: options.delayMs
57
120
  ? new Date(Date.now() + options.delayMs)
58
121
  : null,
59
- concurrencyKey: options.concurrencyKey ?? null,
122
+ concurrencyKey: concurrencyKey ?? null,
60
123
  })
61
124
  .returning({ id: commerceJobs.id });
62
125