@porulle/core 0.8.0 → 0.9.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 (85) 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 +2 -0
  7. package/dist/config/types.d.ts +9 -0
  8. package/dist/config/types.d.ts.map +1 -1
  9. package/dist/hooks/checkout-completion.d.ts.map +1 -1
  10. package/dist/hooks/checkout-completion.js +2 -2
  11. package/dist/hooks/checkout.d.ts.map +1 -1
  12. package/dist/hooks/checkout.js +1 -1
  13. package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
  14. package/dist/interfaces/rest/routes/admin/staff.js +37 -3
  15. package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
  16. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  17. package/dist/interfaces/rest/routes/checkout.js +81 -22
  18. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  19. package/dist/interfaces/rest/routes/inventory.js +13 -0
  20. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  21. package/dist/interfaces/rest/routes/orders.js +13 -0
  22. package/dist/interfaces/rest/schemas/orders.js +2 -2
  23. package/dist/interfaces/rest/schemas/responses.d.ts +34 -0
  24. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  25. package/dist/kernel/database/scoped-db.d.ts +2 -1
  26. package/dist/kernel/database/scoped-db.d.ts.map +1 -1
  27. package/dist/kernel/database/scoped-db.js +62 -1
  28. package/dist/kernel/plugin/manifest.d.ts +3 -0
  29. package/dist/kernel/plugin/manifest.d.ts.map +1 -1
  30. package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
  31. package/dist/modules/analytics/drizzle-adapter.js +16 -3
  32. package/dist/modules/cart/service.d.ts.map +1 -1
  33. package/dist/modules/cart/service.js +3 -2
  34. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  35. package/dist/modules/catalog/entity-service.js +34 -2
  36. package/dist/modules/customers/service.d.ts.map +1 -1
  37. package/dist/modules/customers/service.js +6 -0
  38. package/dist/modules/orders/schema.d.ts +17 -0
  39. package/dist/modules/orders/schema.d.ts.map +1 -1
  40. package/dist/modules/orders/schema.js +4 -0
  41. package/dist/modules/orders/service.d.ts +16 -2
  42. package/dist/modules/orders/service.d.ts.map +1 -1
  43. package/dist/modules/orders/service.js +223 -26
  44. package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
  45. package/dist/modules/orders/stale-order-cleanup.js +13 -1
  46. package/dist/modules/pricing/service.d.ts.map +1 -1
  47. package/dist/modules/pricing/service.js +5 -5
  48. package/dist/test-utils/create-test-config.d.ts.map +1 -1
  49. package/dist/test-utils/create-test-config.js +2 -0
  50. package/dist/test-utils/order-test-helpers.d.ts +12 -0
  51. package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
  52. package/dist/test-utils/order-test-helpers.js +18 -0
  53. package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
  54. package/dist/test-utils/rest-api-test-utils.js +1 -0
  55. package/dist/test-utils/test-actors.js +1 -1
  56. package/dist/testing.d.ts +1 -0
  57. package/dist/testing.d.ts.map +1 -1
  58. package/dist/testing.js +1 -0
  59. package/package.json +1 -1
  60. package/src/auth/middleware.ts +26 -7
  61. package/src/auth/setup.ts +10 -4
  62. package/src/config/defaults.ts +2 -0
  63. package/src/config/types.ts +9 -0
  64. package/src/hooks/checkout-completion.ts +22 -16
  65. package/src/hooks/checkout.ts +11 -8
  66. package/src/interfaces/rest/routes/admin/staff.ts +45 -3
  67. package/src/interfaces/rest/routes/checkout.ts +93 -28
  68. package/src/interfaces/rest/routes/inventory.ts +14 -0
  69. package/src/interfaces/rest/routes/orders.ts +11 -0
  70. package/src/interfaces/rest/schemas/orders.ts +2 -2
  71. package/src/kernel/database/scoped-db.ts +70 -1
  72. package/src/kernel/plugin/manifest.ts +3 -0
  73. package/src/modules/analytics/drizzle-adapter.ts +19 -3
  74. package/src/modules/cart/service.ts +3 -2
  75. package/src/modules/catalog/entity-service.ts +28 -1
  76. package/src/modules/customers/service.ts +6 -0
  77. package/src/modules/orders/schema.ts +4 -0
  78. package/src/modules/orders/service.ts +408 -64
  79. package/src/modules/orders/stale-order-cleanup.ts +18 -4
  80. package/src/modules/pricing/service.ts +5 -6
  81. package/src/test-utils/create-test-config.ts +2 -0
  82. package/src/test-utils/order-test-helpers.ts +26 -0
  83. package/src/test-utils/rest-api-test-utils.ts +1 -0
  84. package/src/test-utils/test-actors.ts +1 -1
  85. package/src/testing.ts +1 -0
package/src/auth/setup.ts CHANGED
@@ -7,13 +7,13 @@ import type { CommerceConfig } from "../config/types.js";
7
7
  import type { DatabaseAdapter } from "../kernel/database/adapter.js";
8
8
  import * as authSchema from "./auth-schema.js";
9
9
 
10
- type BetterAuthDbProvider = "pg" | "mysql" | "sqlite";
10
+ type BetterAuthDbProvider = "pg";
11
11
 
12
12
  function resolveAuthDbProvider(provider: string): BetterAuthDbProvider {
13
13
  if (provider === "postgres" || provider === "postgresql" || provider === "pg") return "pg";
14
- if (provider === "mysql") return "mysql";
15
- if (provider === "sqlite") return "sqlite";
16
- throw new Error(`Unsupported auth database provider "${provider}".`);
14
+ throw new Error(
15
+ `Unsupported auth database provider "${provider}". Only PostgreSQL is supported.`,
16
+ );
17
17
  }
18
18
 
19
19
  interface AuthEmailPayload {
@@ -91,6 +91,12 @@ export function createAuth(
91
91
  }
92
92
  : {}),
93
93
  ...(scope.keyExpiration ? { keyExpiration: scope.keyExpiration } : {}),
94
+ ...("references" in scope && scope.references
95
+ ? { references: scope.references as "user" | "organization" }
96
+ : {}),
97
+ ...(scope.enableMetadata || scope.references === "organization"
98
+ ? { enableMetadata: true }
99
+ : {}),
94
100
  }));
95
101
  plugins.push(apiKey(apiKeyConfigs));
96
102
  } else if (config.auth?.apiKeys?.enabled) {
@@ -27,7 +27,9 @@ export const defaultConfig: Partial<CommerceConfig> = {
27
27
  "inventory:read",
28
28
  "inventory:adjust",
29
29
  "orders:read",
30
+ "orders:create",
30
31
  "orders:update",
32
+ "orders:manage",
31
33
  "cart:create",
32
34
  "cart:update",
33
35
  "cart:manage",
@@ -113,6 +113,10 @@ export interface ApiKeyScopeDefinition {
113
113
  minExpiresIn?: number;
114
114
  maxExpiresIn?: number;
115
115
  };
116
+ /** Better Auth ownership model — POS shift keys bind to an organization. */
117
+ references?: "user" | "organization";
118
+ /** Allow metadata on keys minted under this scope (e.g. operatorId on POS shift keys). */
119
+ enableMetadata?: boolean;
116
120
  }
117
121
 
118
122
  export interface AuthConfig {
@@ -214,6 +218,11 @@ export interface AuthConfig {
214
218
  * Appended to the core plugin list (organization, bearer, jwt, etc.).
215
219
  */
216
220
  extraAuthPlugins?: unknown[];
221
+ /**
222
+ * When true (and NODE_ENV === "test"), the `x-test-actor` header may inject
223
+ * a trusted actor for integration tests. Default false.
224
+ */
225
+ allowTestActor?: boolean;
217
226
  }
218
227
 
219
228
  export interface CartConfig {
@@ -13,14 +13,14 @@ interface InventoryServiceLike {
13
13
  quantity: number;
14
14
  orderId: string;
15
15
  performedBy: string;
16
- }): Promise<{ ok: boolean; error?: { message: string } }>;
16
+ }, actor?: unknown): Promise<{ ok: boolean; error?: { message: string } }>;
17
17
  release(input: {
18
18
  entityId: string;
19
19
  variantId?: string;
20
20
  quantity: number;
21
21
  orderId: string;
22
22
  performedBy: string;
23
- }): Promise<unknown>;
23
+ }, actor?: unknown): Promise<unknown>;
24
24
  }
25
25
 
26
26
  interface PaymentsServiceLike {
@@ -80,13 +80,16 @@ export const reserveInventoryStep: Step<
80
80
  const performedBy = ctx.hook.actor?.userId ?? "system";
81
81
 
82
82
  for (const item of data.lineItems) {
83
- const result = await inventory.reserve({
84
- entityId: item.entityId,
85
- ...(item.variantId != null ? { variantId: item.variantId } : {}),
86
- quantity: item.quantity,
87
- orderId: data.checkoutId,
88
- performedBy,
89
- });
83
+ const result = await inventory.reserve(
84
+ {
85
+ entityId: item.entityId,
86
+ ...(item.variantId != null ? { variantId: item.variantId } : {}),
87
+ quantity: item.quantity,
88
+ orderId: data.checkoutId,
89
+ performedBy,
90
+ },
91
+ ctx.hook.actor,
92
+ );
90
93
 
91
94
  if (!result.ok) {
92
95
  return Err(
@@ -112,13 +115,16 @@ export const reserveInventoryStep: Step<
112
115
  const performedBy = ctx.hook.actor?.userId ?? "system";
113
116
 
114
117
  for (const r of reservations) {
115
- await inventory.release({
116
- entityId: r.entityId,
117
- ...(r.variantId != null ? { variantId: r.variantId } : {}),
118
- quantity: r.quantity,
119
- orderId: r.orderId,
120
- performedBy,
121
- });
118
+ await inventory.release(
119
+ {
120
+ entityId: r.entityId,
121
+ ...(r.variantId != null ? { variantId: r.variantId } : {}),
122
+ quantity: r.quantity,
123
+ orderId: r.orderId,
124
+ performedBy,
125
+ },
126
+ ctx.hook.actor,
127
+ );
122
128
  }
123
129
  },
124
130
  };
@@ -566,16 +566,19 @@ export const reserveInventory: AfterHook<OrderResult> = async ({ result, context
566
566
  quantity: number;
567
567
  orderId: string;
568
568
  performedBy: string;
569
- }): Promise<unknown>;
569
+ }, actor?: unknown): Promise<unknown>;
570
570
  };
571
571
  for (const lineItem of result.lineItems ?? []) {
572
- await inventory.reserve({
573
- entityId: lineItem.entityId,
574
- ...(lineItem.variantId != null ? { variantId: lineItem.variantId } : {}),
575
- quantity: lineItem.quantity,
576
- orderId: result.id,
577
- performedBy: context.actor?.userId ?? "system",
578
- });
572
+ await inventory.reserve(
573
+ {
574
+ entityId: lineItem.entityId,
575
+ ...(lineItem.variantId != null ? { variantId: lineItem.variantId } : {}),
576
+ quantity: lineItem.quantity,
577
+ orderId: result.id,
578
+ performedBy: context.actor?.userId ?? "system",
579
+ },
580
+ context.actor,
581
+ );
579
582
  }
580
583
  };
581
584
 
@@ -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(
@@ -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,33 @@ function tableHasOrganizationId(table: unknown): boolean {
30
31
  }
31
32
  }
32
33
 
34
+ /**
35
+ * Pre-apply the org predicate to a WHERE-able builder (UPDATE/DELETE) and wrap
36
+ * its `.where()` so a caller-supplied condition is AND-ed with the org filter
37
+ * rather than replacing it. Pre-applying `orgEq` immediately means a builder
38
+ * executed with NO caller `.where()` (e.g. `update(t).set(...)`) is still
39
+ * constrained to the org — it can never touch another tenant's rows.
40
+ */
41
+ function scopeWhereBuilder(
42
+ builder: { where: (c: SQL | undefined) => unknown },
43
+ orgEq: SQL,
44
+ ): unknown {
45
+ const scoped = builder.where(orgEq);
46
+ return new Proxy(scoped as Record<string, unknown>, {
47
+ get(st, sp, sr) {
48
+ const sv = Reflect.get(st, sp, sr);
49
+ if (sp === "where" && typeof sv === "function") {
50
+ return (condition?: unknown, ...wRest: unknown[]) => {
51
+ const c = condition as SQL | undefined;
52
+ const merged = c ? and(orgEq, c) : orgEq;
53
+ return (sv as (...a: unknown[]) => unknown).call(st, merged, ...wRest);
54
+ };
55
+ }
56
+ return typeof sv === "function" ? sv.bind(st) : sv;
57
+ },
58
+ });
59
+ }
60
+
33
61
  export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId): TDb {
34
62
  if (!rawDb || typeof rawDb !== "object") return rawDb;
35
63
 
@@ -106,6 +134,47 @@ export function createScopedDb<TDb>(rawDb: TDb, orgSource: ScopedOrganizationId)
106
134
  };
107
135
  }
108
136
 
137
+ if (prop === "update" && typeof value === "function") {
138
+ return (table: PgTable) => {
139
+ const builder = value.call(target, table);
140
+ if (!tableHasOrganizationId(table)) return builder;
141
+ const orgCol = getTableColumns(table).organizationId;
142
+ if (!orgCol) return builder;
143
+ const orgEq = eq(orgCol, resolveOrganizationId(orgSource));
144
+ // The org filter lives after `.set()`, so wrap `.set()` and scope the
145
+ // resulting WHERE-able builder.
146
+ return new Proxy(builder as Record<string, unknown>, {
147
+ get(t, p, r) {
148
+ const v = Reflect.get(t, p, r);
149
+ if (p === "set" && typeof v === "function") {
150
+ return (data: unknown) => {
151
+ const setBuilder = (v as (...a: unknown[]) => unknown).call(t, data);
152
+ return scopeWhereBuilder(
153
+ setBuilder as { where: (c: SQL | undefined) => unknown },
154
+ orgEq,
155
+ );
156
+ };
157
+ }
158
+ return typeof v === "function" ? v.bind(t) : v;
159
+ },
160
+ });
161
+ };
162
+ }
163
+
164
+ if (prop === "delete" && typeof value === "function") {
165
+ return (table: PgTable) => {
166
+ const builder = value.call(target, table);
167
+ if (!tableHasOrganizationId(table)) return builder;
168
+ const orgCol = getTableColumns(table).organizationId;
169
+ if (!orgCol) return builder;
170
+ const orgEq = eq(orgCol, resolveOrganizationId(orgSource));
171
+ return scopeWhereBuilder(
172
+ builder as { where: (c: SQL | undefined) => unknown },
173
+ orgEq,
174
+ );
175
+ };
176
+ }
177
+
109
178
  return typeof value === "function" ? value.bind(target) : value;
110
179
  },
111
180
  }) as TDb;
@@ -158,6 +158,9 @@ export interface CommercePluginManifest {
158
158
  permissions?: Record<string, string[]>;
159
159
  /** Expiry bounds in DAYS (fractions allowed — 1/24 = one hour). */
160
160
  keyExpiration?: { minExpiresIn?: number; maxExpiresIn?: number };
161
+ /** Better Auth ownership model for keys minted under this scope. */
162
+ references?: "user" | "organization";
163
+ enableMetadata?: boolean;
161
164
  }>;
162
165
  }
163
166
 
@@ -216,6 +216,17 @@ export class DrizzleAnalyticsAdapter implements AnalyticsAdapter {
216
216
  }
217
217
  }
218
218
 
219
+ // Validate that requested time dimensions exist in the cube
220
+ for (const td of params.timeDimensions ?? []) {
221
+ const shortName = td.dimension.split(".")[1];
222
+ if (shortName && !cube.dimensions[shortName]) {
223
+ const available = Object.keys(cube.dimensions).map((d) => `${cubeName}.${d}`).join(", ");
224
+ return Err(new CommerceValidationError(
225
+ `Unknown time dimension: "${td.dimension}". Available dimensions for ${cubeName}: ${available}`,
226
+ ));
227
+ }
228
+ }
229
+
219
230
  try {
220
231
  const rows = await this.executeQuery(cube, params, scope);
221
232
  return Ok({
@@ -330,14 +341,17 @@ export class DrizzleAnalyticsAdapter implements AnalyticsAdapter {
330
341
  const shortName = dim.split(".")[1]!;
331
342
  const dimDef = cube.dimensions[shortName];
332
343
  if (!dimDef) continue;
333
- selectParts.push(sql.raw(`${dimDef.sql} AS "${dim}"`));
344
+ const alias = `${cube.name}.${shortName}`;
345
+ selectParts.push(sql.raw(`${dimDef.sql} AS "${alias}"`));
334
346
  groupByParts.push(sql.raw(dimDef.sql));
335
347
  }
336
348
 
337
349
  // Time dimensions → SELECT + GROUP BY + WHERE (dateRange)
338
350
  for (const td of params.timeDimensions ?? []) {
351
+ const shortName = td.dimension.split(".")[1]!;
339
352
  const selectExpr = compileTimeDimensionSelect(cube, td);
340
- selectParts.push(sql`${selectExpr} AS ${sql.raw(`"${td.dimension}"`)}`);
353
+ const alias = `${cube.name}.${shortName}`;
354
+ selectParts.push(sql`${selectExpr} AS ${sql.raw(`"${alias}"`)}`);
341
355
  groupByParts.push(selectExpr);
342
356
 
343
357
  // Date range filter
@@ -357,7 +371,9 @@ export class DrizzleAnalyticsAdapter implements AnalyticsAdapter {
357
371
 
358
372
  // Measures → SELECT
359
373
  for (const measure of params.measures) {
360
- selectParts.push(sql`${compileMeasure(cube, measure)} AS ${sql.raw(`"${measure}"`)}`);
374
+ const shortName = measure.split(".")[1]!;
375
+ const alias = `${cube.name}.${shortName}`;
376
+ selectParts.push(sql`${compileMeasure(cube, measure)} AS ${sql.raw(`"${alias}"`)}`);
361
377
  }
362
378
 
363
379
  // If no select parts (shouldn't happen with validation), bail
@@ -227,8 +227,9 @@ export class CartService {
227
227
  );
228
228
  }
229
229
 
230
- // Validate entity exists
231
- const entity = await this.catalogRepo.findEntityById(input.entityId, ctx);
230
+ // Validate entity exists AND belongs to the actor's org (org-scoped lookup
231
+ // so another tenant's entity id cannot be added to this cart).
232
+ const entity = await this.catalogRepo.findEntityById(input.entityId, ctx, orgId);
232
233
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
233
234
 
234
235
  // Check if entity has variants