@porulle/core 0.5.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  3. package/dist/interfaces/rest/routes/orders.js +25 -1
  4. package/dist/interfaces/rest/schemas/catalog.d.ts +12 -1
  5. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  6. package/dist/interfaces/rest/schemas/orders.d.ts +1393 -0
  7. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  8. package/dist/interfaces/rest/schemas/orders.js +97 -0
  9. package/dist/kernel/error-mapper.d.ts.map +1 -1
  10. package/dist/kernel/error-mapper.js +1 -0
  11. package/dist/kernel/errors.d.ts +5 -0
  12. package/dist/kernel/errors.d.ts.map +1 -1
  13. package/dist/kernel/errors.js +9 -0
  14. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  15. package/dist/modules/catalog/entity-service.js +19 -3
  16. package/dist/modules/catalog/schemas.d.ts +12 -1
  17. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  18. package/dist/modules/catalog/schemas.js +14 -1
  19. package/dist/modules/catalog/service.d.ts +7 -1
  20. package/dist/modules/catalog/service.d.ts.map +1 -1
  21. package/dist/modules/media/service.d.ts +18 -0
  22. package/dist/modules/media/service.d.ts.map +1 -1
  23. package/dist/modules/media/service.js +26 -0
  24. package/dist/modules/orders/service.d.ts +16 -1
  25. package/dist/modules/orders/service.d.ts.map +1 -1
  26. package/dist/modules/orders/service.js +47 -3
  27. package/dist/modules/pricing/service.d.ts.map +1 -1
  28. package/dist/modules/pricing/service.js +19 -1
  29. package/dist/runtime/server.d.ts.map +1 -1
  30. package/dist/runtime/server.js +34 -2
  31. package/package.json +14 -14
  32. package/src/interfaces/rest/routes/orders.ts +36 -1
  33. package/src/interfaces/rest/schemas/orders.ts +104 -0
  34. package/src/kernel/error-mapper.ts +1 -0
  35. package/src/kernel/errors.ts +11 -0
  36. package/src/modules/catalog/entity-service.ts +27 -3
  37. package/src/modules/catalog/schemas.ts +15 -1
  38. package/src/modules/catalog/service.ts +2 -2
  39. package/src/modules/media/service.ts +40 -0
  40. package/src/modules/orders/service.ts +85 -2
  41. package/src/modules/pricing/service.ts +29 -1
  42. package/src/runtime/server.ts +35 -2
@@ -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
- app.use("/api/*", csrf({
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -35,14 +35,6 @@
35
35
  "types": "./src/drizzle.ts"
36
36
  }
37
37
  },
38
- "scripts": {
39
- "generate:plugins": "node ./scripts/generate-plugin-types.mjs",
40
- "check:store-guard": "node ./scripts/check-store-guard.mjs",
41
- "build": "rm -rf dist tsconfig.build.tsbuildinfo && npm run generate:plugins && npm run check:store-guard && tsc -p tsconfig.build.json",
42
- "check-types": "npm run generate:plugins && npm run check:store-guard && tsc --noEmit",
43
- "lint": "eslint . --max-warnings 1000",
44
- "test": "vitest run"
45
- },
46
38
  "dependencies": {
47
39
  "@better-auth/api-key": "^1.3.8",
48
40
  "@better-auth/drizzle-adapter": "^1.3.8",
@@ -58,14 +50,14 @@
58
50
  },
59
51
  "devDependencies": {
60
52
  "@electric-sql/pglite": "^0.3.15",
61
- "@porulle/eslint-config": "*",
62
- "@porulle/typescript-config": "*",
63
53
  "@types/node": "^25.5.0",
64
54
  "@vitest/coverage-v8": "^3.2.4",
65
55
  "drizzle-kit": "^0.31.9",
66
56
  "eslint": "^9.39.1",
67
57
  "typescript": "5.9.2",
68
- "vitest": "^3.2.4"
58
+ "vitest": "^3.2.4",
59
+ "@porulle/eslint-config": "0.1.0",
60
+ "@porulle/typescript-config": "0.1.0"
69
61
  },
70
62
  "publishConfig": {
71
63
  "access": "public"
@@ -85,5 +77,13 @@
85
77
  "url": "git+https://github.com/asyncdotengineering/porulle.git",
86
78
  "directory": "packages/core"
87
79
  },
88
- "author": "Porulle contributors"
89
- }
80
+ "author": "Porulle contributors",
81
+ "scripts": {
82
+ "generate:plugins": "node ./scripts/generate-plugin-types.mjs",
83
+ "check:store-guard": "node ./scripts/check-store-guard.mjs",
84
+ "build": "rm -rf dist tsconfig.build.tsbuildinfo && npm run generate:plugins && npm run check:store-guard && tsc -p tsconfig.build.json",
85
+ "check-types": "npm run generate:plugins && npm run check:store-guard && tsc --noEmit",
86
+ "lint": "eslint . --max-warnings 1000",
87
+ "test": "vitest run"
88
+ }
89
+ }
@@ -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",
@@ -7,6 +7,7 @@ const statusByCode: Record<string, ContentfulStatusCode> = {
7
7
  NOT_FOUND: 404,
8
8
  VALIDATION_FAILED: 422,
9
9
  FORBIDDEN: 403,
10
+ CSRF_ORIGIN_REJECTED: 403,
10
11
  CONFLICT: 409,
11
12
  INVALID_TRANSITION: 422,
12
13
  ORG_RESOLUTION_FAILED: 503,
@@ -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) hydrated.media = [];
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
- export const GenerateVariantsBodySchema = z.object({}).passthrough().openapi("GenerateVariantsBody");
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?: string }>;
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 refundAmount = Math.min(
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
- { orderId, newStatus: "refunded", reason },
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
- const record = await this.repo.createPrice(priceData, ctx);
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",
@@ -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
- app.use("/api/*", csrf({
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