@porulle/core 0.6.0 → 0.7.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/interfaces/rest/routes/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/orders.js +25 -1
- package/dist/interfaces/rest/schemas/catalog.d.ts +12 -1
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.d.ts +1393 -0
- package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/orders.js +97 -0
- package/dist/kernel/error-mapper.d.ts.map +1 -1
- package/dist/kernel/error-mapper.js +1 -0
- package/dist/kernel/errors.d.ts +5 -0
- package/dist/kernel/errors.d.ts.map +1 -1
- package/dist/kernel/errors.js +9 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +19 -3
- package/dist/modules/catalog/schemas.d.ts +12 -1
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/catalog/schemas.js +14 -1
- package/dist/modules/catalog/service.d.ts +7 -1
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/media/service.d.ts +18 -0
- package/dist/modules/media/service.d.ts.map +1 -1
- package/dist/modules/media/service.js +26 -0
- package/dist/modules/orders/service.d.ts +16 -1
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +47 -3
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +19 -1
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +34 -2
- package/package.json +1 -1
- package/src/interfaces/rest/routes/orders.ts +36 -1
- package/src/interfaces/rest/schemas/orders.ts +104 -0
- package/src/kernel/error-mapper.ts +1 -0
- package/src/kernel/errors.ts +11 -0
- package/src/modules/catalog/entity-service.ts +27 -3
- package/src/modules/catalog/schemas.ts +15 -1
- package/src/modules/catalog/service.ts +2 -2
- package/src/modules/media/service.ts +40 -0
- package/src/modules/orders/service.ts +85 -2
- package/src/modules/pricing/service.ts +29 -1
- package/src/runtime/server.ts +35 -2
package/dist/runtime/server.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import { cors } from "hono/cors";
|
|
3
3
|
import { csrf } from "hono/csrf";
|
|
4
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
5
|
import { bodyLimit } from "hono/body-limit";
|
|
5
6
|
import { rateLimiter } from "hono-rate-limiter";
|
|
6
7
|
import { createHash } from "node:crypto";
|
|
7
8
|
import { createClientIpResolver } from "./client-ip.js";
|
|
8
9
|
import { authMiddleware } from "../auth/middleware.js";
|
|
10
|
+
import { CommerceCsrfError } from "../kernel/errors.js";
|
|
9
11
|
import { createRestRoutes } from "../interfaces/rest/index.js";
|
|
10
12
|
import { createCustomerPortalRoutes } from "../interfaces/rest/customer-portal.js";
|
|
11
13
|
import { createCommerce } from "./commerce.js";
|
|
@@ -138,11 +140,41 @@ export async function createServer(config) {
|
|
|
138
140
|
maxAge: 86400,
|
|
139
141
|
}));
|
|
140
142
|
// ─── CSRF Protection (F14) ──────────────────────────────────────────
|
|
141
|
-
|
|
143
|
+
// CSRF defends cookie/session auth, where the browser attaches the credential
|
|
144
|
+
// ambiently. API-key (x-api-key) and bearer-token requests carry an explicit,
|
|
145
|
+
// non-ambient credential and are not CSRF-attackable, so the guard is skipped
|
|
146
|
+
// for them — otherwise a bodyless server-to-server POST (no Origin, no JSON
|
|
147
|
+
// content-type, e.g. /publish or /archive from the SDK) trips CSRF and 403s
|
|
148
|
+
// for no security benefit.
|
|
149
|
+
const csrfGuard = csrf({
|
|
142
150
|
origin: trustedOrigins.length > 0
|
|
143
151
|
? trustedOrigins
|
|
144
152
|
: (process.env.NODE_ENV === "production" ? [] : ["http://localhost:*"]),
|
|
145
|
-
})
|
|
153
|
+
});
|
|
154
|
+
app.use("/api/*", async (c, next) => {
|
|
155
|
+
const authenticatedByKey = !!c.req.header("x-api-key") ||
|
|
156
|
+
/^Bearer\s+/i.test(c.req.header("authorization") ?? "");
|
|
157
|
+
if (authenticatedByKey)
|
|
158
|
+
return next();
|
|
159
|
+
// Run only the CSRF origin check here; invoke the real downstream afterwards
|
|
160
|
+
// so a genuine 403 from a route handler can't be misattributed to CSRF.
|
|
161
|
+
let passedCsrf = false;
|
|
162
|
+
try {
|
|
163
|
+
await csrfGuard(c, async () => {
|
|
164
|
+
passedCsrf = true;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (err instanceof HTTPException && err.status === 403) {
|
|
169
|
+
throw new CommerceCsrfError("Origin check failed: the request Origin is not in the trusted origins allowlist. " +
|
|
170
|
+
"Browser clients must send a trusted Origin; server-to-server callers should authenticate with an API key (x-api-key).");
|
|
171
|
+
}
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
if (!passedCsrf)
|
|
175
|
+
return;
|
|
176
|
+
return next();
|
|
177
|
+
});
|
|
146
178
|
// ─── Body Size Limit (F6) ──────────────────────────────────────────
|
|
147
179
|
// Media uploads (phone photos are 3–8MB) get their own larger limit and are
|
|
148
180
|
// exempt from the global 1MB limit. Everything else stays at 1MB.
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { Kernel } from "../../../runtime/kernel.js";
|
|
3
|
-
import { changeOrderStatusRoute, listOrdersRoute, orderLookupRoute, getOrderRoute, getOrderFulfillmentsRoute } from "../schemas/orders.js";
|
|
3
|
+
import { changeOrderStatusRoute, listOrdersRoute, orderLookupRoute, getOrderRoute, getOrderFulfillmentsRoute, createOrderRoute, refundOrderRoute, captureOrderRoute } from "../schemas/orders.js";
|
|
4
4
|
import { type AppEnv, isUUID, mapErrorToResponse, mapErrorToStatus, parsePagination } from "../utils.js";
|
|
5
|
+
import type { CreateOrderInput } from "../../../modules/orders/service.js";
|
|
5
6
|
|
|
6
7
|
export function orderRoutes(kernel: Kernel) {
|
|
7
8
|
const router = new OpenAPIHono<AppEnv>();
|
|
@@ -59,6 +60,40 @@ export function orderRoutes(kernel: Kernel) {
|
|
|
59
60
|
return c.json({ data: result.value });
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
// @ts-expect-error -- openapi handler union return type
|
|
64
|
+
router.openapi(createOrderRoute, async (c) => {
|
|
65
|
+
const body = c.req.valid("json") as CreateOrderInput;
|
|
66
|
+
const result = await kernel.services.orders.create(body, c.get("actor"));
|
|
67
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
68
|
+
return c.json({ data: result.value }, 201);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// @ts-expect-error -- openapi handler union return type
|
|
72
|
+
router.openapi(refundOrderRoute, async (c) => {
|
|
73
|
+
const body = c.req.valid("json") as { amount?: number; reason?: string } | undefined;
|
|
74
|
+
const result = await kernel.services.orders.refund(
|
|
75
|
+
c.req.param("id"),
|
|
76
|
+
c.get("actor"),
|
|
77
|
+
body?.reason ?? "refunded",
|
|
78
|
+
undefined,
|
|
79
|
+
body?.amount !== undefined ? { amount: body.amount } : undefined,
|
|
80
|
+
);
|
|
81
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
82
|
+
return c.json({ data: result.value });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// @ts-expect-error -- openapi handler union return type
|
|
86
|
+
router.openapi(captureOrderRoute, async (c) => {
|
|
87
|
+
const body = c.req.valid("json") as { amount?: number } | undefined;
|
|
88
|
+
const result = await kernel.services.orders.capture(
|
|
89
|
+
c.req.param("id"),
|
|
90
|
+
c.get("actor"),
|
|
91
|
+
body?.amount !== undefined ? { amount: body.amount } : undefined,
|
|
92
|
+
);
|
|
93
|
+
if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
|
|
94
|
+
return c.json({ data: result.value });
|
|
95
|
+
});
|
|
96
|
+
|
|
62
97
|
// @ts-expect-error -- openapi() enforces strict response typing but our handler
|
|
63
98
|
// returns union responses (200 | 400 | 404). The route definition documents the
|
|
64
99
|
// contract; the handler returns dynamic status.
|
|
@@ -12,6 +12,45 @@ export const ChangeOrderStatusBodySchema = z.object({
|
|
|
12
12
|
reason: z.string().optional().openapi({ example: "Payment verified" }),
|
|
13
13
|
}).openapi("ChangeOrderStatusRequest");
|
|
14
14
|
|
|
15
|
+
export const RefundOrderBodySchema = z.object({
|
|
16
|
+
// Minor units (e.g. cents). Omit to refund the full captured amount.
|
|
17
|
+
amount: z.number().int().positive().optional().openapi({ example: 1575 }),
|
|
18
|
+
reason: z.string().optional().openapi({ example: "Customer returned item" }),
|
|
19
|
+
}).openapi("RefundOrderRequest");
|
|
20
|
+
|
|
21
|
+
export const CaptureOrderBodySchema = z.object({
|
|
22
|
+
// Minor units. Omit to capture the full authorized amount.
|
|
23
|
+
amount: z.number().int().positive().optional().openapi({ example: 1575 }),
|
|
24
|
+
}).openapi("CaptureOrderRequest");
|
|
25
|
+
|
|
26
|
+
const CreateOrderLineItemSchema = z.object({
|
|
27
|
+
entityId: z.uuid().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
|
|
28
|
+
entityType: z.string().min(1).openapi({ example: "product" }),
|
|
29
|
+
variantId: z.uuid().optional(),
|
|
30
|
+
sku: z.string().optional(),
|
|
31
|
+
title: z.string().min(1).openapi({ example: "Ceylon Black Tea 250g" }),
|
|
32
|
+
quantity: z.number().int().positive().openapi({ example: 2 }),
|
|
33
|
+
unitPrice: z.number().int().nonnegative().openapi({ example: 1250 }),
|
|
34
|
+
totalPrice: z.number().int().nonnegative().openapi({ example: 2500 }),
|
|
35
|
+
taxAmount: z.number().int().nonnegative().optional(),
|
|
36
|
+
discountAmount: z.number().int().nonnegative().optional(),
|
|
37
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const CreateOrderBodySchema = z.object({
|
|
41
|
+
customerId: z.uuid().optional(),
|
|
42
|
+
currency: z.string().min(3).max(3).openapi({ example: "USD" }),
|
|
43
|
+
subtotal: z.number().int().nonnegative().openapi({ example: 2500 }),
|
|
44
|
+
taxTotal: z.number().int().nonnegative().openapi({ example: 200 }),
|
|
45
|
+
shippingTotal: z.number().int().nonnegative().openapi({ example: 500 }),
|
|
46
|
+
discountTotal: z.number().int().nonnegative().optional().openapi({ example: 0 }),
|
|
47
|
+
grandTotal: z.number().int().nonnegative().openapi({ example: 3200 }),
|
|
48
|
+
paymentIntentId: z.string().optional(),
|
|
49
|
+
paymentMethodId: z.string().optional(),
|
|
50
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
51
|
+
lineItems: z.array(CreateOrderLineItemSchema).min(1),
|
|
52
|
+
}).openapi("CreateOrderRequest");
|
|
53
|
+
|
|
15
54
|
// ─── Response Schemas ───────────────────────────────────────────────────────
|
|
16
55
|
|
|
17
56
|
export const OrderDataResponseSchema = OrderResponse;
|
|
@@ -101,6 +140,71 @@ export const getOrderFulfillmentsRoute = createRoute({
|
|
|
101
140
|
},
|
|
102
141
|
});
|
|
103
142
|
|
|
143
|
+
export const createOrderRoute = createRoute({
|
|
144
|
+
method: "post",
|
|
145
|
+
path: "/",
|
|
146
|
+
tags: ["Orders"],
|
|
147
|
+
summary: "Create a draft / manual order",
|
|
148
|
+
description: "Operator-created order (phone / POS / manual) with line items and totals, optionally without immediate payment.",
|
|
149
|
+
request: {
|
|
150
|
+
body: {
|
|
151
|
+
content: { "application/json": { schema: CreateOrderBodySchema } },
|
|
152
|
+
required: true,
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
responses: {
|
|
156
|
+
201: {
|
|
157
|
+
content: { "application/json": { schema: OrderDataResponseSchema } },
|
|
158
|
+
description: "Order created.",
|
|
159
|
+
},
|
|
160
|
+
...errorResponses,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
export const refundOrderRoute = createRoute({
|
|
165
|
+
method: "post",
|
|
166
|
+
path: "/{id}/refund",
|
|
167
|
+
tags: ["Orders"],
|
|
168
|
+
summary: "Refund an order's payment",
|
|
169
|
+
description: "Refunds the captured payment via the payment adapter and transitions the order to `refunded`. Omit `amount` for a full refund.",
|
|
170
|
+
request: {
|
|
171
|
+
params: OrderIdParam,
|
|
172
|
+
body: {
|
|
173
|
+
content: { "application/json": { schema: RefundOrderBodySchema } },
|
|
174
|
+
required: false,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
responses: {
|
|
178
|
+
200: {
|
|
179
|
+
content: { "application/json": { schema: OrderDataResponseSchema } },
|
|
180
|
+
description: "Order refunded.",
|
|
181
|
+
},
|
|
182
|
+
...errorResponses,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
export const captureOrderRoute = createRoute({
|
|
187
|
+
method: "post",
|
|
188
|
+
path: "/{id}/capture",
|
|
189
|
+
tags: ["Orders"],
|
|
190
|
+
summary: "Capture an authorized payment",
|
|
191
|
+
description: "Captures the authorized payment via the payment adapter and records `amountCaptured`. Omit `amount` for a full capture.",
|
|
192
|
+
request: {
|
|
193
|
+
params: OrderIdParam,
|
|
194
|
+
body: {
|
|
195
|
+
content: { "application/json": { schema: CaptureOrderBodySchema } },
|
|
196
|
+
required: false,
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
responses: {
|
|
200
|
+
200: {
|
|
201
|
+
content: { "application/json": { schema: OrderDataResponseSchema } },
|
|
202
|
+
description: "Payment captured.",
|
|
203
|
+
},
|
|
204
|
+
...errorResponses,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
104
208
|
export const changeOrderStatusRoute = createRoute({
|
|
105
209
|
method: "patch",
|
|
106
210
|
path: "/{id}/status",
|
package/src/kernel/errors.ts
CHANGED
|
@@ -43,6 +43,17 @@ export class CommerceForbiddenError extends Error implements CommerceError {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export class CommerceCsrfError extends Error implements CommerceError {
|
|
47
|
+
code = "CSRF_ORIGIN_REJECTED" as const;
|
|
48
|
+
constructor(
|
|
49
|
+
message: string,
|
|
50
|
+
public details?: unknown,
|
|
51
|
+
) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = "CommerceCsrfError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
export class CommerceConflictError extends Error implements CommerceError {
|
|
47
58
|
code = "CONFLICT" as const;
|
|
48
59
|
constructor(
|
|
@@ -181,13 +181,22 @@ export class EntityService {
|
|
|
181
181
|
}
|
|
182
182
|
if (options?.includeCategories) hydrated.categories = await this.repo.findEntityCategories(entity.id, ctx);
|
|
183
183
|
if (options?.includeBrands) hydrated.brands = await this.repo.findEntityBrands(entity.id, ctx);
|
|
184
|
-
if (options?.includeMedia)
|
|
184
|
+
if (options?.includeMedia) {
|
|
185
|
+
const mediaService = this.deps.services.media as {
|
|
186
|
+
listEntityMedia?: (
|
|
187
|
+
entityId: string,
|
|
188
|
+
opts?: { orgId?: string },
|
|
189
|
+
) => Promise<{ ok: boolean; value?: CatalogEntityHydrated["media"] }>;
|
|
190
|
+
} | undefined;
|
|
191
|
+
const mediaResult = await mediaService?.listEntityMedia?.(entity.id, { orgId: entity.organizationId });
|
|
192
|
+
hydrated.media = mediaResult?.ok && mediaResult.value ? mediaResult.value : [];
|
|
193
|
+
}
|
|
185
194
|
if (options?.includePricing) {
|
|
186
195
|
try {
|
|
187
|
-
const pricingService = this.deps.services.pricing as { listPrices: (filter: { entityId: string }) => Promise<{ ok: boolean; value?: { prices: Array<{ currency: string; amount: number; compareAtAmount?: number | null }> } }> };
|
|
196
|
+
const pricingService = this.deps.services.pricing as { listPrices: (filter: { entityId: string }) => Promise<{ ok: boolean; value?: { prices: Array<{ id: string; currency: string; amount: number; compareAtAmount?: number | null; createdAt: Date }> } }> };
|
|
188
197
|
const priceResult = await pricingService.listPrices({ entityId: entity.id });
|
|
189
198
|
if (priceResult.ok && priceResult.value) {
|
|
190
|
-
hydrated.pricing = priceResult.value.prices.map((p) => ({ currency: p.currency, amount: p.amount, compareAtAmount: p.compareAtAmount ?? null }));
|
|
199
|
+
hydrated.pricing = priceResult.value.prices.map((p) => ({ id: p.id, currency: p.currency, amount: p.amount, compareAtAmount: p.compareAtAmount ?? null, createdAt: p.createdAt }));
|
|
191
200
|
}
|
|
192
201
|
} catch {}
|
|
193
202
|
}
|
|
@@ -435,6 +444,21 @@ export class EntityService {
|
|
|
435
444
|
|
|
436
445
|
async generateVariants(entityId: string, strategy: VariantGenerationStrategy, actor: Actor | null, ctx?: TxContext): Promise<Result<Variant[]>> {
|
|
437
446
|
assertPermission(actor, "catalog:update");
|
|
447
|
+
// Guard the strategy: a missing/malformed body (e.g. a bodyless SDK call that
|
|
448
|
+
// skips JSON validation) must be a 422, not a 500 from dereferencing an
|
|
449
|
+
// undefined strategy.
|
|
450
|
+
const mode = (strategy as { mode?: unknown } | null | undefined)?.mode;
|
|
451
|
+
if (mode !== "all" && mode !== "manual" && mode !== "matrix") {
|
|
452
|
+
return Err(new CommerceValidationError(
|
|
453
|
+
'A variant generation strategy is required: { "mode": "all" } | { "mode": "manual", "combinations": [...] } | { "mode": "matrix", "matrix": { "include"?, "exclude"? } }.',
|
|
454
|
+
));
|
|
455
|
+
}
|
|
456
|
+
if (strategy.mode === "manual" && !Array.isArray(strategy.combinations)) {
|
|
457
|
+
return Err(new CommerceValidationError('strategy.combinations (string[][]) is required for mode "manual".'));
|
|
458
|
+
}
|
|
459
|
+
if (strategy.mode === "matrix" && (!strategy.matrix || typeof strategy.matrix !== "object")) {
|
|
460
|
+
return Err(new CommerceValidationError('strategy.matrix is required for mode "matrix".'));
|
|
461
|
+
}
|
|
438
462
|
const entity = await this.repo.findEntityById(entityId, ctx);
|
|
439
463
|
if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
|
|
440
464
|
const entityOptionTypes = await this.repo.findOptionTypesByEntityId(entityId, ctx);
|
|
@@ -71,7 +71,21 @@ export const CreateVariantBodySchema = z.object({
|
|
|
71
71
|
price: z.number().optional().openapi({ example: 34.99 }),
|
|
72
72
|
}).openapi("CreateVariantBody");
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
const VariantMatrixRuleSchema = z.object({
|
|
75
|
+
include: z.array(z.array(z.string())).optional().openapi({ example: [["red", "small"]] }),
|
|
76
|
+
exclude: z.array(z.array(z.string())).optional().openapi({ example: [["red", "large"]] }),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
export const GenerateVariantsBodySchema = z
|
|
80
|
+
.discriminatedUnion("mode", [
|
|
81
|
+
z.object({ mode: z.literal("all") }),
|
|
82
|
+
z.object({
|
|
83
|
+
mode: z.literal("manual"),
|
|
84
|
+
combinations: z.array(z.array(z.string())).openapi({ example: [["red", "small"]] }),
|
|
85
|
+
}),
|
|
86
|
+
z.object({ mode: z.literal("matrix"), matrix: VariantMatrixRuleSchema }),
|
|
87
|
+
])
|
|
88
|
+
.openapi("GenerateVariantsBody");
|
|
75
89
|
|
|
76
90
|
// ─── Derived Input Types ─────────────────────────────────────────────────────
|
|
77
91
|
|
|
@@ -105,8 +105,8 @@ export interface CatalogEntityHydrated extends SellableEntity {
|
|
|
105
105
|
optionTypes?: Array<OptionType & { values: OptionValue[] }>;
|
|
106
106
|
categories?: EntityCategory[];
|
|
107
107
|
brands?: EntityBrand[];
|
|
108
|
-
media?: Array<{ mediaAssetId: string; role: string; variantId
|
|
109
|
-
pricing?: Array<{ currency: string; amount: number; compareAtAmount?: number | null }>;
|
|
108
|
+
media?: Array<{ mediaAssetId: string; role: string; sortOrder: number; variantId: string | null; url: string; alt: string | null; contentType: string }>;
|
|
109
|
+
pricing?: Array<{ id: string; currency: string; amount: number; compareAtAmount?: number | null; createdAt: Date }>;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
export interface CatalogService {
|
|
@@ -28,6 +28,16 @@ export interface AttachMediaInput {
|
|
|
28
28
|
sortOrder?: number;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export interface AttachedMedia {
|
|
32
|
+
mediaAssetId: string;
|
|
33
|
+
role: string;
|
|
34
|
+
sortOrder: number;
|
|
35
|
+
variantId: string | null;
|
|
36
|
+
url: string;
|
|
37
|
+
alt: string | null;
|
|
38
|
+
contentType: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
interface MediaServiceDeps {
|
|
32
42
|
repository: MediaRepository;
|
|
33
43
|
catalogRepository: CatalogRepository;
|
|
@@ -234,4 +244,34 @@ export class MediaService {
|
|
|
234
244
|
|
|
235
245
|
return Ok(undefined);
|
|
236
246
|
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Media attached to an entity, resolved to public URLs and ordered by
|
|
250
|
+
* sortOrder. Powers catalog `?include=media` hydration. Returns entity-level
|
|
251
|
+
* links only unless a specific variantId is requested.
|
|
252
|
+
*/
|
|
253
|
+
async listEntityMedia(
|
|
254
|
+
entityId: string,
|
|
255
|
+
opts?: { variantId?: string; orgId?: string },
|
|
256
|
+
ctx?: TxContext,
|
|
257
|
+
): Promise<Result<AttachedMedia[]>> {
|
|
258
|
+
const links = await this.repo.findEntityMedia(entityId, opts?.variantId, ctx);
|
|
259
|
+
const out: AttachedMedia[] = [];
|
|
260
|
+
for (const link of links) {
|
|
261
|
+
const asset = await this.repo.findAssetById(link.mediaAssetId, ctx, opts?.orgId);
|
|
262
|
+
if (!asset) continue;
|
|
263
|
+
const urlResult = await this.deps.storage.getUrl(asset.storageKey);
|
|
264
|
+
out.push({
|
|
265
|
+
mediaAssetId: link.mediaAssetId,
|
|
266
|
+
role: link.role,
|
|
267
|
+
sortOrder: link.sortOrder,
|
|
268
|
+
variantId: link.variantId ?? null,
|
|
269
|
+
url: urlResult.ok ? urlResult.value : "",
|
|
270
|
+
alt: asset.alt ?? null,
|
|
271
|
+
contentType: asset.contentType,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
out.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
275
|
+
return Ok(out);
|
|
276
|
+
}
|
|
237
277
|
}
|
|
@@ -78,6 +78,11 @@ export interface ChangeStatusInput {
|
|
|
78
78
|
orderId: string;
|
|
79
79
|
newStatus: OrderState;
|
|
80
80
|
reason?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Explicit refund amount (minor units) for a `refunded` transition. Clamped
|
|
83
|
+
* to the captured amount. Omit to refund the full captured amount.
|
|
84
|
+
*/
|
|
85
|
+
refundAmount?: number;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
export interface OrderServiceDeps {
|
|
@@ -540,10 +545,14 @@ export class OrderService {
|
|
|
540
545
|
| undefined;
|
|
541
546
|
|
|
542
547
|
if (payments?.refund) {
|
|
543
|
-
const
|
|
548
|
+
const maxRefund = Math.min(
|
|
544
549
|
order.grandTotal,
|
|
545
550
|
order.amountCaptured ?? order.grandTotal,
|
|
546
551
|
);
|
|
552
|
+
const refundAmount =
|
|
553
|
+
input.refundAmount != null
|
|
554
|
+
? Math.min(input.refundAmount, maxRefund)
|
|
555
|
+
: maxRefund;
|
|
547
556
|
await payments.refund(
|
|
548
557
|
paymentIntentId,
|
|
549
558
|
refundAmount,
|
|
@@ -675,14 +684,88 @@ export class OrderService {
|
|
|
675
684
|
actor: Actor | null,
|
|
676
685
|
reason = "refunded",
|
|
677
686
|
ctx?: TxContext,
|
|
687
|
+
opts?: { amount?: number },
|
|
678
688
|
): Promise<Result<HydratedOrder>> {
|
|
679
689
|
return this.changeStatus(
|
|
680
|
-
{
|
|
690
|
+
{
|
|
691
|
+
orderId,
|
|
692
|
+
newStatus: "refunded",
|
|
693
|
+
reason,
|
|
694
|
+
...(opts?.amount != null ? { refundAmount: opts.amount } : {}),
|
|
695
|
+
},
|
|
681
696
|
actor,
|
|
682
697
|
ctx,
|
|
683
698
|
);
|
|
684
699
|
}
|
|
685
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Capture an authorized payment for an order via the payment adapter and
|
|
703
|
+
* record the captured amount. Does not transition order status — capture is a
|
|
704
|
+
* payment operation, not a fulfillment one.
|
|
705
|
+
*/
|
|
706
|
+
async capture(
|
|
707
|
+
orderId: string,
|
|
708
|
+
actor: Actor | null,
|
|
709
|
+
opts?: { amount?: number },
|
|
710
|
+
ctx?: TxContext,
|
|
711
|
+
): Promise<Result<HydratedOrder>> {
|
|
712
|
+
const orgId = resolveOrgId(actor);
|
|
713
|
+
const order = await this.repo.findById(orgId, orderId, ctx);
|
|
714
|
+
if (!order) return Err(new CommerceNotFoundError("Order not found."));
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
assertPermission(actor, "orders:update");
|
|
718
|
+
} catch (error) {
|
|
719
|
+
return Err(toCommerceError(error));
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const paymentIntentId =
|
|
723
|
+
((order as Record<string, unknown>).paymentIntentId as string | undefined) ??
|
|
724
|
+
((order.metadata as Record<string, unknown> | null)?.paymentIntentId as
|
|
725
|
+
| string
|
|
726
|
+
| undefined);
|
|
727
|
+
if (!paymentIntentId) {
|
|
728
|
+
return Err(
|
|
729
|
+
new CommerceValidationError("Order has no authorized payment to capture."),
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const payments = this.deps.services.payments as
|
|
734
|
+
| {
|
|
735
|
+
capture(
|
|
736
|
+
paymentIntentId: string,
|
|
737
|
+
amount?: number,
|
|
738
|
+
paymentMethodId?: string,
|
|
739
|
+
): Promise<{ ok: boolean; value?: { amountCaptured?: number }; error?: unknown }>;
|
|
740
|
+
}
|
|
741
|
+
| undefined;
|
|
742
|
+
if (!payments?.capture) {
|
|
743
|
+
return Err(
|
|
744
|
+
new CommerceValidationError("No payment adapter configured for capture."),
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const paymentMethodId = (order as Record<string, unknown>).paymentMethodId as
|
|
749
|
+
| string
|
|
750
|
+
| undefined;
|
|
751
|
+
const captureResult = await payments.capture(
|
|
752
|
+
paymentIntentId,
|
|
753
|
+
opts?.amount,
|
|
754
|
+
paymentMethodId,
|
|
755
|
+
);
|
|
756
|
+
if (!captureResult.ok) {
|
|
757
|
+
return Err(toCommerceError(captureResult.error));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const amountCaptured =
|
|
761
|
+
captureResult.value?.amountCaptured ?? opts?.amount ?? order.grandTotal;
|
|
762
|
+
await this.repo.update(orderId, { amountCaptured }, ctx);
|
|
763
|
+
|
|
764
|
+
const refreshed = await this.repo.findById(orgId, orderId, ctx);
|
|
765
|
+
const hydrated = await this.hydrateOrder(refreshed ?? order, ctx);
|
|
766
|
+
return Ok(hydrated);
|
|
767
|
+
}
|
|
768
|
+
|
|
686
769
|
async getStatusHistory(
|
|
687
770
|
orderId: string,
|
|
688
771
|
actor?: Actor | null,
|
|
@@ -129,6 +129,11 @@ function quantityRangeWidth(
|
|
|
129
129
|
return Number.MAX_SAFE_INTEGER;
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
function sameNaturalKeyDate(a: Date | null | undefined, b: Date | null | undefined): boolean {
|
|
133
|
+
if (a == null || b == null) return (a ?? null) === (b ?? null);
|
|
134
|
+
return a.getTime() === b.getTime();
|
|
135
|
+
}
|
|
136
|
+
|
|
132
137
|
function compareBasePriceSpecificity(
|
|
133
138
|
a: Price,
|
|
134
139
|
b: Price,
|
|
@@ -229,7 +234,30 @@ export class PricingService {
|
|
|
229
234
|
validUntil: input.validUntil ?? null,
|
|
230
235
|
};
|
|
231
236
|
|
|
232
|
-
|
|
237
|
+
// Upsert on the natural key. "Set base price" is idempotent per
|
|
238
|
+
// (org, entity, variant, currency, customerGroup, qty range, validity):
|
|
239
|
+
// a repeat call replaces the existing row's amount instead of appending a
|
|
240
|
+
// shadow row that the resolver would then tie-break on createdAt.
|
|
241
|
+
const existing = (
|
|
242
|
+
await this.repo.findPricesByEntityId(orgId, input.entityId, ctx)
|
|
243
|
+
).find(
|
|
244
|
+
(p) =>
|
|
245
|
+
p.currency === priceData.currency &&
|
|
246
|
+
p.variantId === (priceData.variantId ?? null) &&
|
|
247
|
+
p.customerGroupId === (priceData.customerGroupId ?? null) &&
|
|
248
|
+
p.minQuantity === (priceData.minQuantity ?? null) &&
|
|
249
|
+
p.maxQuantity === (priceData.maxQuantity ?? null) &&
|
|
250
|
+
sameNaturalKeyDate(p.validFrom, priceData.validFrom ?? null) &&
|
|
251
|
+
sameNaturalKeyDate(p.validUntil, priceData.validUntil ?? null),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const record = existing
|
|
255
|
+
? (await this.repo.updatePrice(
|
|
256
|
+
existing.id,
|
|
257
|
+
{ amount: priceData.amount, metadata: priceData.metadata },
|
|
258
|
+
ctx,
|
|
259
|
+
)) ?? existing
|
|
260
|
+
: await this.repo.createPrice(priceData, ctx);
|
|
233
261
|
|
|
234
262
|
const afterHooks = this.deps.hooks.resolve(
|
|
235
263
|
"pricing.afterCreate",
|
package/src/runtime/server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
3
3
|
import { cors } from "hono/cors";
|
|
4
4
|
import { csrf } from "hono/csrf";
|
|
5
|
+
import { HTTPException } from "hono/http-exception";
|
|
5
6
|
import { bodyLimit } from "hono/body-limit";
|
|
6
7
|
import { rateLimiter } from "hono-rate-limiter";
|
|
7
8
|
import { createHash } from "node:crypto";
|
|
@@ -10,6 +11,7 @@ import type { Actor } from "../auth/types.js";
|
|
|
10
11
|
import type { AuthInstance } from "../auth/setup.js";
|
|
11
12
|
import type { CommerceConfig } from "../config/types.js";
|
|
12
13
|
import { authMiddleware } from "../auth/middleware.js";
|
|
14
|
+
import { CommerceCsrfError } from "../kernel/errors.js";
|
|
13
15
|
import { createRestRoutes } from "../interfaces/rest/index.js";
|
|
14
16
|
import { createCustomerPortalRoutes } from "../interfaces/rest/customer-portal.js";
|
|
15
17
|
import { createKernel } from "./kernel.js";
|
|
@@ -178,11 +180,42 @@ export async function createServer(config: CommerceConfig) {
|
|
|
178
180
|
}));
|
|
179
181
|
|
|
180
182
|
// ─── CSRF Protection (F14) ──────────────────────────────────────────
|
|
181
|
-
|
|
183
|
+
// CSRF defends cookie/session auth, where the browser attaches the credential
|
|
184
|
+
// ambiently. API-key (x-api-key) and bearer-token requests carry an explicit,
|
|
185
|
+
// non-ambient credential and are not CSRF-attackable, so the guard is skipped
|
|
186
|
+
// for them — otherwise a bodyless server-to-server POST (no Origin, no JSON
|
|
187
|
+
// content-type, e.g. /publish or /archive from the SDK) trips CSRF and 403s
|
|
188
|
+
// for no security benefit.
|
|
189
|
+
const csrfGuard = csrf({
|
|
182
190
|
origin: trustedOrigins.length > 0
|
|
183
191
|
? trustedOrigins
|
|
184
192
|
: (process.env.NODE_ENV === "production" ? [] : ["http://localhost:*"]),
|
|
185
|
-
})
|
|
193
|
+
});
|
|
194
|
+
app.use("/api/*", async (c, next) => {
|
|
195
|
+
const authenticatedByKey =
|
|
196
|
+
!!c.req.header("x-api-key") ||
|
|
197
|
+
/^Bearer\s+/i.test(c.req.header("authorization") ?? "");
|
|
198
|
+
if (authenticatedByKey) return next();
|
|
199
|
+
|
|
200
|
+
// Run only the CSRF origin check here; invoke the real downstream afterwards
|
|
201
|
+
// so a genuine 403 from a route handler can't be misattributed to CSRF.
|
|
202
|
+
let passedCsrf = false;
|
|
203
|
+
try {
|
|
204
|
+
await csrfGuard(c, async () => {
|
|
205
|
+
passedCsrf = true;
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (err instanceof HTTPException && err.status === 403) {
|
|
209
|
+
throw new CommerceCsrfError(
|
|
210
|
+
"Origin check failed: the request Origin is not in the trusted origins allowlist. " +
|
|
211
|
+
"Browser clients must send a trusted Origin; server-to-server callers should authenticate with an API key (x-api-key).",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
if (!passedCsrf) return;
|
|
217
|
+
return next();
|
|
218
|
+
});
|
|
186
219
|
|
|
187
220
|
// ─── Body Size Limit (F6) ──────────────────────────────────────────
|
|
188
221
|
// Media uploads (phone photos are 3–8MB) get their own larger limit and are
|