@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.
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +26 -7
- package/dist/auth/setup.d.ts.map +1 -1
- package/dist/auth/setup.js +7 -5
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/types.d.ts +9 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/hooks/checkout-completion.d.ts.map +1 -1
- package/dist/hooks/checkout-completion.js +2 -2
- package/dist/hooks/checkout.d.ts.map +1 -1
- package/dist/hooks/checkout.js +1 -1
- package/dist/interfaces/rest/routes/admin/staff.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/admin/staff.js +37 -3
- package/dist/interfaces/rest/routes/checkout.d.ts +9 -0
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +81 -22
- package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/inventory.js +13 -0
- package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +13 -0
- package/dist/interfaces/rest/schemas/orders.js +2 -2
- package/dist/interfaces/rest/schemas/responses.d.ts +34 -0
- package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.d.ts +2 -1
- package/dist/kernel/database/scoped-db.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.js +62 -1
- package/dist/kernel/plugin/manifest.d.ts +3 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/modules/analytics/drizzle-adapter.d.ts.map +1 -1
- package/dist/modules/analytics/drizzle-adapter.js +16 -3
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +3 -2
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +34 -2
- package/dist/modules/customers/service.d.ts.map +1 -1
- package/dist/modules/customers/service.js +6 -0
- package/dist/modules/orders/schema.d.ts +17 -0
- package/dist/modules/orders/schema.d.ts.map +1 -1
- package/dist/modules/orders/schema.js +4 -0
- package/dist/modules/orders/service.d.ts +16 -2
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +223 -26
- package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
- package/dist/modules/orders/stale-order-cleanup.js +13 -1
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +5 -5
- package/dist/test-utils/create-test-config.d.ts.map +1 -1
- package/dist/test-utils/create-test-config.js +2 -0
- package/dist/test-utils/order-test-helpers.d.ts +12 -0
- package/dist/test-utils/order-test-helpers.d.ts.map +1 -0
- package/dist/test-utils/order-test-helpers.js +18 -0
- package/dist/test-utils/rest-api-test-utils.d.ts.map +1 -1
- package/dist/test-utils/rest-api-test-utils.js +1 -0
- package/dist/test-utils/test-actors.js +1 -1
- package/dist/testing.d.ts +1 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +1 -0
- package/package.json +1 -1
- package/src/auth/middleware.ts +26 -7
- package/src/auth/setup.ts +10 -4
- package/src/config/defaults.ts +2 -0
- package/src/config/types.ts +9 -0
- package/src/hooks/checkout-completion.ts +22 -16
- package/src/hooks/checkout.ts +11 -8
- package/src/interfaces/rest/routes/admin/staff.ts +45 -3
- package/src/interfaces/rest/routes/checkout.ts +93 -28
- package/src/interfaces/rest/routes/inventory.ts +14 -0
- package/src/interfaces/rest/routes/orders.ts +11 -0
- package/src/interfaces/rest/schemas/orders.ts +2 -2
- package/src/kernel/database/scoped-db.ts +70 -1
- package/src/kernel/plugin/manifest.ts +3 -0
- package/src/modules/analytics/drizzle-adapter.ts +19 -3
- package/src/modules/cart/service.ts +3 -2
- package/src/modules/catalog/entity-service.ts +28 -1
- package/src/modules/customers/service.ts +6 -0
- package/src/modules/orders/schema.ts +4 -0
- package/src/modules/orders/service.ts +408 -64
- package/src/modules/orders/stale-order-cleanup.ts +18 -4
- package/src/modules/pricing/service.ts +5 -6
- package/src/test-utils/create-test-config.ts +2 -0
- package/src/test-utils/order-test-helpers.ts +26 -0
- package/src/test-utils/rest-api-test-utils.ts +1 -0
- package/src/test-utils/test-actors.ts +1 -1
- 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"
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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) {
|
package/src/config/defaults.ts
CHANGED
package/src/config/types.ts
CHANGED
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
};
|
package/src/hooks/checkout.ts
CHANGED
|
@@ -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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|