@porulle/core 0.22.0 → 0.24.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 (41) hide show
  1. package/dist/auth/identity-free-routes.d.ts +51 -0
  2. package/dist/auth/identity-free-routes.d.ts.map +1 -0
  3. package/dist/auth/identity-free-routes.js +49 -0
  4. package/dist/auth/middleware.d.ts.map +1 -1
  5. package/dist/auth/middleware.js +6 -0
  6. package/dist/auth/permissions.d.ts +7 -0
  7. package/dist/auth/permissions.d.ts.map +1 -1
  8. package/dist/auth/permissions.js +23 -3
  9. package/dist/config/types.d.ts +15 -0
  10. package/dist/config/types.d.ts.map +1 -1
  11. package/dist/index.d.ts +4 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -2
  14. package/dist/interfaces/rest/customer-portal.d.ts.map +1 -1
  15. package/dist/interfaces/rest/customer-portal.js +14 -2
  16. package/dist/interfaces/rest/router.d.ts +10 -1
  17. package/dist/interfaces/rest/router.d.ts.map +1 -1
  18. package/dist/interfaces/rest/router.js +8 -3
  19. package/dist/interfaces/rest/utils.d.ts +3 -8
  20. package/dist/interfaces/rest/utils.d.ts.map +1 -1
  21. package/dist/interfaces/rest/utils.js +8 -7
  22. package/dist/kernel/error-mapper.d.ts.map +1 -1
  23. package/dist/kernel/error-mapper.js +1 -0
  24. package/dist/kernel/errors.d.ts +5 -0
  25. package/dist/kernel/errors.d.ts.map +1 -1
  26. package/dist/kernel/errors.js +9 -0
  27. package/dist/modules/cart/service.d.ts +7 -0
  28. package/dist/modules/cart/service.d.ts.map +1 -1
  29. package/dist/modules/cart/service.js +46 -6
  30. package/package.json +3 -3
  31. package/src/auth/identity-free-routes.ts +86 -0
  32. package/src/auth/middleware.ts +7 -0
  33. package/src/auth/permissions.ts +30 -2
  34. package/src/config/types.ts +15 -0
  35. package/src/index.ts +10 -1
  36. package/src/interfaces/rest/customer-portal.ts +23 -3
  37. package/src/interfaces/rest/router.ts +22 -4
  38. package/src/interfaces/rest/utils.ts +16 -11
  39. package/src/kernel/error-mapper.ts +1 -0
  40. package/src/kernel/errors.ts +8 -0
  41. package/src/modules/cart/service.ts +87 -7
@@ -1,6 +1,6 @@
1
1
  import { resolveOrgIdForCommerce } from "../../auth/org.js";
2
- import { assertPermission } from "../../auth/permissions.js";
3
- import { CommerceForbiddenError, CommerceNotFoundError, CommerceValidationError, toCommerceError, } from "../../kernel/errors.js";
2
+ import { AUTHENTICATION_REQUIRED_MESSAGE, assertPermission, isUnauthenticatedActor, } from "../../auth/permissions.js";
3
+ import { CommerceForbiddenError, CommerceNotFoundError, CommerceUnauthorizedError, CommerceValidationError, toCommerceError, } from "../../kernel/errors.js";
4
4
  import { runAfterHooks, runBeforeHooks } from "../../kernel/hooks/executor.js";
5
5
  import { createHookContext } from "../../kernel/hooks/create-context.js";
6
6
  import { Err, Ok } from "../../kernel/result.js";
@@ -191,12 +191,35 @@ export class CartService {
191
191
  item = updated;
192
192
  }
193
193
  else {
194
+ // A new line needs a price, and a price the system cannot determine is refused rather than
195
+ // invented. This used to read `processed.unitPriceSnapshot ?? 1000`: a silent default in a
196
+ // money path, which nothing in the response, the logs or the schema disclosed. An integrator
197
+ // found it by comparing a deployed cart reading 1000 against a catalog priced 14500-22800.
198
+ //
199
+ // A hook still wins when it supplies one, so bespoke pricing keeps its seam; otherwise the
200
+ // pricing step answers — the SAME step `resolveCurrentPrices` uses at checkout, so a cart and
201
+ // its order agree by construction rather than by an integrator remembering to install a hook.
202
+ const currency = processed.currency ?? cart.currency;
203
+ let unitPriceSnapshot = processed.unitPriceSnapshot;
204
+ if (unitPriceSnapshot === undefined) {
205
+ const resolved = await this.resolveUnitPrice({
206
+ entityId: processed.entityId,
207
+ currency,
208
+ quantity,
209
+ ...(processed.variantId != null
210
+ ? { variantId: processed.variantId }
211
+ : {}),
212
+ }, actor ?? null, ctx);
213
+ if (!resolved.ok)
214
+ return resolved;
215
+ unitPriceSnapshot = resolved.value;
216
+ }
194
217
  item = await this.repo.createLineItem({
195
218
  cartId: input.cartId,
196
219
  entityId: processed.entityId,
197
220
  quantity,
198
- unitPriceSnapshot: processed.unitPriceSnapshot ?? 1000,
199
- currency: processed.currency ?? cart.currency,
221
+ unitPriceSnapshot,
222
+ currency,
200
223
  metadata: processed.metadata ?? {},
201
224
  ...(processed.variantId !== undefined
202
225
  ? { variantId: processed.variantId }
@@ -206,6 +229,23 @@ export class CartService {
206
229
  await runAfterHooks(afterHooks, null, item, "addItem", context);
207
230
  return Ok(item);
208
231
  }
232
+ /**
233
+ * The unit price for a new cart line, from the pricing step. Refuses — naming the entity and the
234
+ * currency — when no price is configured, when the pricing service is absent, or when resolution
235
+ * fails for any other reason: every one of those is a value the system cannot determine, and the
236
+ * defect this replaces was substituting a literal for exactly that.
237
+ */
238
+ async resolveUnitPrice(input, actor, ctx) {
239
+ const pricing = this.deps.services.pricing;
240
+ if (typeof pricing?.resolve !== "function") {
241
+ return Err(new CommerceValidationError(`Cannot resolve a unit price for ${input.entityId}: no pricing service is configured.`));
242
+ }
243
+ const resolved = await pricing.resolve(input, actor, ctx);
244
+ if (!resolved.ok) {
245
+ return Err(new CommerceValidationError(`Cannot resolve a unit price for ${input.entityId} (${input.currency}). Configure a price for it, or supply unitPriceSnapshot from a cart.beforeAddItem hook.`));
246
+ }
247
+ return Ok(resolved.value.finalAmount);
248
+ }
209
249
  async removeItem(cartId, itemId, actor, ctx, presentedSecret) {
210
250
  try {
211
251
  assertPermission(actor ?? null, "cart:update");
@@ -513,8 +553,8 @@ export class CartService {
513
553
  if (actor && actor.permissions.includes("*:*"))
514
554
  return;
515
555
  if (cart.customerId != null) {
516
- if (!actor) {
517
- throw new CommerceForbiddenError("Authentication required to read customer cart.");
556
+ if (isUnauthenticatedActor(actor)) {
557
+ throw new CommerceUnauthorizedError(AUTHENTICATION_REQUIRED_MESSAGE);
518
558
  }
519
559
  const profileId = await this.resolveActorCustomerId(actor, ctx);
520
560
  if (profileId !== cart.customerId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -62,8 +62,8 @@
62
62
  "eslint": "^9.39.1",
63
63
  "typescript": "5.9.2",
64
64
  "vitest": "^3.2.4",
65
- "@porulle/eslint-config": "0.1.0",
66
- "@porulle/typescript-config": "0.1.0"
65
+ "@porulle/typescript-config": "0.1.0",
66
+ "@porulle/eslint-config": "0.1.0"
67
67
  },
68
68
  "publishConfig": {
69
69
  "access": "public"
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Routes that read no actor, so `authMiddleware` need not resolve one.
3
+ *
4
+ * THE DEFAULT IS TO RESOLVE. A route absent from this list keeps its actor,
5
+ * because the failure mode of the opposite default is silent: a route that
6
+ * stops receiving an actor does not throw, it 401s, or takes an anonymous
7
+ * branch and answers.
8
+ *
9
+ * Matching is EXACT on method and path — no patterns, no prefixes. A pattern is
10
+ * how one entry silently widens to cover a route that does need an identity,
11
+ * and this list is small enough that the cost of exactness is a line per route.
12
+ * A route with a path parameter therefore cannot be listed at all, which is the
13
+ * intended limit rather than an oversight.
14
+ *
15
+ * Entries are the routes whose handlers were read and shown to consult neither
16
+ * `c.get("actor")` nor anything derived from it. `/api/carts` and the rest of
17
+ * `PUBLIC_ROUTES` in `interfaces/rest/route-coverage.ts` are NOT here: they are
18
+ * public in the sense of needing no permission, and they still need the
19
+ * organization the anonymous actor carries. Public and identity-free are
20
+ * different properties and this list is the second one.
21
+ */
22
+ export type IdentityFreeRoute = {
23
+ method: string;
24
+ path: string;
25
+ justification: string;
26
+ };
27
+
28
+ export const IDENTITY_FREE_ROUTES: readonly IdentityFreeRoute[] = [
29
+ {
30
+ method: "GET",
31
+ path: "/api/health",
32
+ justification:
33
+ "A liveness probe for load balancers; the handler issues its own SELECT 1 and reads no actor.",
34
+ },
35
+ {
36
+ method: "GET",
37
+ path: "/api/doc",
38
+ justification:
39
+ "The generated OpenAPI document is built from config and the route table, never from the caller.",
40
+ },
41
+ {
42
+ method: "GET",
43
+ path: "/api/doc-ext",
44
+ justification:
45
+ "The enriched OpenAPI document, same handler shape as /api/doc.",
46
+ },
47
+ ];
48
+
49
+ const DEFAULT_KEYS = new Set(
50
+ IDENTITY_FREE_ROUTES.map((route) => `${route.method} ${route.path}`),
51
+ );
52
+
53
+ function normalize(entry: string): string {
54
+ const [method = "", ...rest] = entry.trim().split(/\s+/);
55
+ return `${method.toUpperCase()} ${rest.join(" ")}`;
56
+ }
57
+
58
+ /**
59
+ * An app declares its own with `auth.identityFreeRoutes: ["POST /api/payments/notify"]`.
60
+ *
61
+ * Additive only: a config entry can never remove one of the defaults above, so
62
+ * the narrowest thing an app can do to this set is widen it, and the widening
63
+ * is visible in its own configuration file.
64
+ *
65
+ * The routes that want this are app-local by nature — a signed payment notify,
66
+ * a tracked click-out — and they have no session, taking their organization
67
+ * from the payload they verified rather than from a resolver, which is what
68
+ * core's own `POST /api/payments/webhook` already does.
69
+ *
70
+ * WHAT AN ENTRY COSTS: `authMiddleware` is followed by the wrapper that opens
71
+ * the plugin database scope FROM THE RESOLVED ACTOR. A route listed here gets
72
+ * no actor, so it gets no scope, and every plugin read and write on that
73
+ * request runs with no organization predicate while the handler still answers
74
+ * normally. Such a route must open its own scope.
75
+ */
76
+ export function isIdentityFreeRoute(
77
+ method: string,
78
+ path: string,
79
+ config?: { auth?: { identityFreeRoutes?: readonly string[] } },
80
+ ): boolean {
81
+ const key = `${method.toUpperCase()} ${path}`;
82
+ if (DEFAULT_KEYS.has(key)) return true;
83
+ const declared = config?.auth?.identityFreeRoutes;
84
+ if (!declared) return false;
85
+ return declared.some((entry) => normalize(entry) === key);
86
+ }
@@ -6,6 +6,7 @@ import { getCustomerPermissions, resolveActor } from "./actor.js";
6
6
  import { DEFAULT_ORG_ID } from "./org.js";
7
7
  import { isCredentialRejection } from "./auth-failure.js";
8
8
  import { isStrictOrgResolution } from "./strict-org-resolution.js";
9
+ import { isIdentityFreeRoute } from "./identity-free-routes.js";
9
10
 
10
11
  function emptyToNull(value: string | null | undefined): string | null {
11
12
  return value == null || value === "" ? null : value;
@@ -24,6 +25,12 @@ export function authMiddleware(
24
25
  config: CommerceConfig,
25
26
  ): MiddlewareHandler {
26
27
  return async (c, next) => {
28
+ if (isIdentityFreeRoute(c.req.method, c.req.path, config)) {
29
+ c.set("actor", null);
30
+ await next();
31
+ return;
32
+ }
33
+
27
34
  // Resolve the default org from config, falling back to deprecated constant
28
35
  const defaultOrgId = config.auth?.defaultOrganizationId ?? DEFAULT_ORG_ID;
29
36
 
@@ -1,6 +1,22 @@
1
- import { CommerceForbiddenError } from "../kernel/errors.js";
1
+ import {
2
+ CommerceForbiddenError,
3
+ CommerceUnauthorizedError,
4
+ } from "../kernel/errors.js";
2
5
  import type { Actor } from "./types.js";
3
6
 
7
+ export const AUTHENTICATION_REQUIRED_MESSAGE = "Authentication required.";
8
+
9
+ /**
10
+ * True when the request carried no credential at all. An API key actor
11
+ * (`type: "api_key"`) DID present one, whatever its referenceId, so it keeps
12
+ * 403 — telling it to authenticate would be a lie it cannot act on.
13
+ */
14
+ export function isUnauthenticatedActor(
15
+ actor: Pick<Actor, "type" | "userId"> | null,
16
+ ): boolean {
17
+ return actor === null || (actor.type === "user" && actor.userId === null);
18
+ }
19
+
4
20
  export function hasPermission(actor: Actor | null, required: string): boolean {
5
21
  if (!actor) return false;
6
22
  if (actor.permissions.includes("*:*")) return true;
@@ -12,13 +28,25 @@ export function hasPermission(actor: Actor | null, required: string): boolean {
12
28
 
13
29
  export function assertPermission(actor: Actor | null, required: string): void {
14
30
  if (hasPermission(actor, required)) return;
15
- if (!actor) throw new CommerceForbiddenError("Authentication required.");
31
+ // `actor === null` is redundant at runtime — the predicate already covers it —
32
+ // and load-bearing at compile time: a boolean predicate narrows nothing, and
33
+ // the message below reads `actor.role`. Written this way rather than as a cast
34
+ // so it cannot start lying if the predicate changes.
35
+ if (actor === null || isUnauthenticatedActor(actor)) {
36
+ throw new CommerceUnauthorizedError(AUTHENTICATION_REQUIRED_MESSAGE);
37
+ }
16
38
 
17
39
  throw new CommerceForbiddenError(
18
40
  `Permission "${required}" is required. Your role "${actor.role}" does not include this permission.`,
19
41
  );
20
42
  }
21
43
 
44
+ // NOT given the unauthenticated predicate, and the omission is deliberate.
45
+ // `auth-permissions.test.ts` pins this refusal as 403 for a STAFF actor with a
46
+ // null userId — a shape `resolveActor` cannot produce — so whether that means
47
+ // "anonymous" or "a synthetic actor whose identity is broken" has to be decided
48
+ // before the status can be. It is carded; no route reaches here with an
49
+ // anonymous actor today.
22
50
  export function assertOwnership(actor: Actor | null, resourceOwnerId: string | null): void {
23
51
  if (!actor) {
24
52
  throw new CommerceForbiddenError("Authentication required.");
@@ -199,6 +199,21 @@ export interface AuthConfig {
199
199
  * ```
200
200
  */
201
201
  storeResolver?: (request: Request) => string | null | Promise<string | null>;
202
+ /**
203
+ * Routes this app serves that read no actor, as `"<METHOD> <exact path>"` —
204
+ * e.g. `["POST /api/payments/notify", "GET /api/out/:token"]`. Matching is
205
+ * exact; there are no globs, because a glob is how one entry silently widens
206
+ * to cover a route that does need an identity.
207
+ *
208
+ * Additive to core's own list, and it can never remove one. Core's defaults
209
+ * are in `auth/identity-free-routes.ts` with the evidence for each.
210
+ *
211
+ * The route then resolves NO actor, which also means it gets no plugin
212
+ * database scope — that scope is derived from the actor. Such a route must
213
+ * open its own, the way a signed webhook builds its actor from the payload it
214
+ * verified. Listing a route that reads `c.get("actor")` will not fail loudly.
215
+ */
216
+ identityFreeRoutes?: readonly string[];
202
217
  /**
203
218
  * Governs two layers, and its default differs between them.
204
219
  *
package/src/index.ts CHANGED
@@ -42,7 +42,15 @@ export { createSystemActor } from "./auth/system-actor.js";
42
42
  export { OrganizationService } from "./modules/organization/service.js";
43
43
  export { createScopedDb } from "./kernel/database/scoped-db.js";
44
44
  export type { ScopedOrganizationId } from "./kernel/database/scoped-db.js";
45
- export { assertOwnership, assertPermission, requireUserId } from "./auth/permissions.js";
45
+ export { IDENTITY_FREE_ROUTES, isIdentityFreeRoute } from "./auth/identity-free-routes.js";
46
+ export type { IdentityFreeRoute } from "./auth/identity-free-routes.js";
47
+ export {
48
+ AUTHENTICATION_REQUIRED_MESSAGE,
49
+ assertOwnership,
50
+ assertPermission,
51
+ isUnauthenticatedActor,
52
+ requireUserId,
53
+ } from "./auth/permissions.js";
46
54
  export type { AccessResult, AccessContext, AccessFn, WhereClause } from "./auth/access.js";
47
55
  export {
48
56
  accessOR,
@@ -127,6 +135,7 @@ export {
127
135
  CommerceNotFoundError,
128
136
  CommerceValidationError,
129
137
  CommerceForbiddenError,
138
+ CommerceUnauthorizedError,
130
139
  CommerceReauthRequiredError,
131
140
  CommerceConflictError,
132
141
  CommerceInvalidTransitionError,
@@ -1,6 +1,11 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { Kernel } from "../../runtime/kernel.js";
3
- import { assertPermission, requireUserId } from "../../auth/permissions.js";
3
+ import {
4
+ AUTHENTICATION_REQUIRED_MESSAGE,
5
+ assertPermission,
6
+ isUnauthenticatedActor,
7
+ requireUserId,
8
+ } from "../../auth/permissions.js";
4
9
  import type { Actor } from "../../auth/types.js";
5
10
  import type { AppEnv } from "./utils.js";
6
11
  import {
@@ -23,14 +28,29 @@ type AuthenticatedActor = Actor & { userId: string };
23
28
  export function createCustomerPortalRoutes(kernel: Kernel) {
24
29
  const router = new OpenAPIHono<AppEnv>();
25
30
 
31
+ // Two refusals, because two different callers are being turned away and one
32
+ // answer cannot serve both. The second clause is also what makes the
33
+ // `AuthenticatedActor` cast below true: every route under this guard reads
34
+ // `actor.userId` as a string.
26
35
  router.use("*", markRoutePermissionGuard(async (c, next) => {
27
36
  const actor = c.get("actor") as Actor | null;
28
- if (!actor?.userId) {
37
+ if (isUnauthenticatedActor(actor)) {
29
38
  return c.json(
30
- { error: { code: "FORBIDDEN", message: "Authentication required." } },
39
+ { error: { code: "UNAUTHORIZED", message: AUTHENTICATION_REQUIRED_MESSAGE } },
31
40
  401,
32
41
  );
33
42
  }
43
+ if (!actor?.userId) {
44
+ return c.json(
45
+ {
46
+ error: {
47
+ code: "FORBIDDEN",
48
+ message: "The customer portal requires a signed-in user, not an API key.",
49
+ },
50
+ },
51
+ 403,
52
+ );
53
+ }
34
54
  await next();
35
55
  }));
36
56
 
@@ -33,7 +33,11 @@ import type { CommerceConfig } from "../../config/types.js";
33
33
  import type { PluginRouteRegistration } from "../../kernel/plugin/manifest.js";
34
34
  import { createScopedDb } from "../../kernel/database/scoped-db.js";
35
35
  import { resolveOrgIdForCommerce } from "../../auth/org.js";
36
- import { hasPermission } from "../../auth/permissions.js";
36
+ import {
37
+ AUTHENTICATION_REQUIRED_MESSAGE,
38
+ hasPermission,
39
+ isUnauthenticatedActor,
40
+ } from "../../auth/permissions.js";
37
41
  import type { Actor } from "../../auth/types.js";
38
42
 
39
43
  // ─── Shared OpenAPI Error Responses ──────────────────────────────────────────
@@ -63,7 +67,16 @@ export interface RouteHandlerContext {
63
67
  query: Record<string, unknown>;
64
68
  /** Path parameters, auto-extracted from {id} segments. */
65
69
  params: Record<string, string>;
66
- /** Authenticated actor. Guaranteed non-null if .auth() or .permission() was called. */
70
+ /**
71
+ * Authenticated actor. Guaranteed non-null if .auth() or .permission() was called.
72
+ *
73
+ * Structural rather than `Actor` ON PURPOSE. This is the published plugin
74
+ * API: plugins outside this repository cast it to their own shapes, and
75
+ * three call sites in `plugin-layaway` alone read it as
76
+ * `{ userId: string } & Record<string, unknown>` — a cast TypeScript refuses
77
+ * against a closed `Actor`. Narrowing it is a breaking change to a published
78
+ * contract, not a tidy-up, and it belongs to a card that migrates the callers.
79
+ */
67
80
  actor: { userId: string | null; role: string; permissions: string[]; vendorId?: string | null; [key: string]: unknown } | null;
68
81
  /** Resolved organization ID. Derived from actor.organizationId or the deployment config. */
69
82
  orgId: string;
@@ -218,8 +231,13 @@ class RouteChain {
218
231
  // Order: auth first (401), then permission (403).
219
232
  const actor = ctx.get("actor") as RouteHandlerContext["actor"];
220
233
 
221
- if ((requireAuth || requiredPermission) && !actor) {
222
- return ctx.json({ error: { code: "UNAUTHORIZED", message: "Authentication required." } }, 401);
234
+ // `RouteHandlerContext["actor"]` is deliberately structural — see its
235
+ // declaration — but the middleware only ever sets a real `Actor`. Read
236
+ // the two identity fields through that type here rather than narrowing
237
+ // the published one, which breaks every plugin that casts it.
238
+ const identity = actor as Pick<Actor, "type" | "userId"> | null;
239
+ if ((requireAuth || requiredPermission) && isUnauthenticatedActor(identity)) {
240
+ return ctx.json({ error: { code: "UNAUTHORIZED", message: AUTHENTICATION_REQUIRED_MESSAGE } }, 401);
223
241
  }
224
242
 
225
243
  if (requiredPermission) {
@@ -2,6 +2,10 @@ import type { CommerceError } from "../../kernel/errors.js";
2
2
  import { mapErrorToStatus } from "../../kernel/error-mapper.js";
3
3
  import { toCommerceError } from "../../kernel/errors.js";
4
4
  import type { Actor } from "../../auth/types.js";
5
+ import {
6
+ AUTHENTICATION_REQUIRED_MESSAGE,
7
+ isUnauthenticatedActor,
8
+ } from "../../auth/permissions.js";
5
9
 
6
10
  export const ROUTE_PERMISSION_GUARD = Symbol("porulle.routePermissionGuard");
7
11
 
@@ -21,6 +25,7 @@ export function markPublicRoute<T>(handler: T, methods?: readonly string[]): T {
21
25
  }
22
26
 
23
27
  type PermissionContext = {
28
+ get(key: "actor"): Pick<Actor, "type" | "userId" | "permissions"> | null;
24
29
  get(key: string): unknown;
25
30
  json(data: unknown, status: number): unknown;
26
31
  req: { method: string };
@@ -84,12 +89,9 @@ export { mapErrorToStatus };
84
89
  * Usage: router.post("/", requirePerm("webhooks:manage"), handler);
85
90
  */
86
91
  export function requirePerm(permission: string) {
87
- const middleware = async (c: { get(key: string): unknown; json(data: unknown, status: number): unknown }, next: () => Promise<void>) => {
88
- const actor = c.get("actor") as { permissions?: string[] } | null;
89
- if (!actor) {
90
- return c.json({ error: { code: "UNAUTHORIZED", message: "Authentication required." } }, 401);
91
- }
92
- const perms = actor.permissions ?? [];
92
+ const middleware = async (c: PermissionContext, next: () => Promise<void>) => {
93
+ const actor = c.get("actor");
94
+ const perms = actor?.permissions ?? [];
93
95
  if (perms.includes(permission) || perms.includes("*:*")) {
94
96
  await next();
95
97
  return;
@@ -100,27 +102,30 @@ export function requirePerm(permission: string) {
100
102
  await next();
101
103
  return;
102
104
  }
105
+ if (isUnauthenticatedActor(actor)) {
106
+ return c.json({ error: { code: "UNAUTHORIZED", message: AUTHENTICATION_REQUIRED_MESSAGE } }, 401);
107
+ }
103
108
  return c.json({ error: { code: "FORBIDDEN", message: `Permission '${permission}' is required.` } }, 403);
104
109
  };
105
110
  return markRoutePermissionGuard(middleware);
106
111
  }
107
112
 
108
113
  export function requireAnyPerm(permissions: readonly string[]) {
109
- const middleware = async (c: { get(key: string): unknown; json(data: unknown, status: number): unknown }, next: () => Promise<void>) => {
110
- const actor = c.get("actor") as { permissions?: string[] } | null;
114
+ const middleware = async (c: PermissionContext, next: () => Promise<void>) => {
115
+ const actor = c.get("actor");
111
116
  const granted = actor?.permissions ?? [];
112
117
  const allowed = permissions.some((permission) =>
113
118
  granted.includes(permission) ||
114
119
  granted.includes("*:*") ||
115
120
  granted.includes(`${permission.split(":")[0]}:*`),
116
121
  );
117
- if (!actor) {
118
- return c.json({ error: { code: "UNAUTHORIZED", message: "Authentication required." } }, 401);
119
- }
120
122
  if (allowed) {
121
123
  await next();
122
124
  return;
123
125
  }
126
+ if (isUnauthenticatedActor(actor)) {
127
+ return c.json({ error: { code: "UNAUTHORIZED", message: AUTHENTICATION_REQUIRED_MESSAGE } }, 401);
128
+ }
124
129
  return c.json({ error: { code: "FORBIDDEN", message: `One of these permissions is required: ${permissions.join(", ")}.` } }, 403);
125
130
  };
126
131
  return markRoutePermissionGuard(middleware);
@@ -7,6 +7,7 @@ const statusByCode: Record<string, ContentfulStatusCode> = {
7
7
  NOT_FOUND: 404,
8
8
  VALIDATION_FAILED: 422,
9
9
  FORBIDDEN: 403,
10
+ UNAUTHORIZED: 401,
10
11
  REAUTH_REQUIRED: 401,
11
12
  CSRF_ORIGIN_REJECTED: 403,
12
13
  CONFLICT: 409,
@@ -81,6 +81,14 @@ export class CommerceForbiddenError extends Error implements CommerceError {
81
81
  }
82
82
  }
83
83
 
84
+ export class CommerceUnauthorizedError extends Error implements CommerceError {
85
+ code = "UNAUTHORIZED" as const;
86
+ constructor(message: string, public details?: unknown) {
87
+ super(message);
88
+ this.name = "CommerceUnauthorizedError";
89
+ }
90
+ }
91
+
84
92
  export class CommerceCsrfError extends Error implements CommerceError {
85
93
  code = "CSRF_ORIGIN_REJECTED" as const;
86
94
  constructor(
@@ -1,10 +1,16 @@
1
1
  import { resolveOrgIdForCommerce } from "../../auth/org.js";
2
- import { assertOwnership, assertPermission } from "../../auth/permissions.js";
2
+ import {
3
+ AUTHENTICATION_REQUIRED_MESSAGE,
4
+ assertOwnership,
5
+ assertPermission,
6
+ isUnauthenticatedActor,
7
+ } from "../../auth/permissions.js";
3
8
  import type { Actor } from "../../auth/types.js";
4
9
  import type { CommerceConfig } from "../../config/types.js";
5
10
  import {
6
11
  CommerceForbiddenError,
7
12
  CommerceNotFoundError,
13
+ CommerceUnauthorizedError,
8
14
  CommerceValidationError,
9
15
  toCommerceError,
10
16
  } from "../../kernel/errors.js";
@@ -318,13 +324,39 @@ export class CartService {
318
324
  );
319
325
  item = updated!;
320
326
  } else {
327
+ // A new line needs a price, and a price the system cannot determine is refused rather than
328
+ // invented. This used to read `processed.unitPriceSnapshot ?? 1000`: a silent default in a
329
+ // money path, which nothing in the response, the logs or the schema disclosed. An integrator
330
+ // found it by comparing a deployed cart reading 1000 against a catalog priced 14500-22800.
331
+ //
332
+ // A hook still wins when it supplies one, so bespoke pricing keeps its seam; otherwise the
333
+ // pricing step answers — the SAME step `resolveCurrentPrices` uses at checkout, so a cart and
334
+ // its order agree by construction rather than by an integrator remembering to install a hook.
335
+ const currency = processed.currency ?? cart.currency;
336
+ let unitPriceSnapshot = processed.unitPriceSnapshot;
337
+ if (unitPriceSnapshot === undefined) {
338
+ const resolved = await this.resolveUnitPrice(
339
+ {
340
+ entityId: processed.entityId,
341
+ currency,
342
+ quantity,
343
+ ...(processed.variantId != null
344
+ ? { variantId: processed.variantId }
345
+ : {}),
346
+ },
347
+ actor ?? null,
348
+ ctx,
349
+ );
350
+ if (!resolved.ok) return resolved;
351
+ unitPriceSnapshot = resolved.value;
352
+ }
321
353
  item = await this.repo.createLineItem(
322
354
  {
323
355
  cartId: input.cartId,
324
356
  entityId: processed.entityId,
325
357
  quantity,
326
- unitPriceSnapshot: processed.unitPriceSnapshot ?? 1000,
327
- currency: processed.currency ?? cart.currency,
358
+ unitPriceSnapshot,
359
+ currency,
328
360
  metadata: processed.metadata ?? {},
329
361
  ...(processed.variantId !== undefined
330
362
  ? { variantId: processed.variantId }
@@ -339,6 +371,56 @@ export class CartService {
339
371
  return Ok(item);
340
372
  }
341
373
 
374
+ /**
375
+ * The unit price for a new cart line, from the pricing step. Refuses — naming the entity and the
376
+ * currency — when no price is configured, when the pricing service is absent, or when resolution
377
+ * fails for any other reason: every one of those is a value the system cannot determine, and the
378
+ * defect this replaces was substituting a literal for exactly that.
379
+ */
380
+ private async resolveUnitPrice(
381
+ input: {
382
+ entityId: string;
383
+ currency: string;
384
+ quantity: number;
385
+ variantId?: string;
386
+ },
387
+ actor: Actor | null,
388
+ ctx?: TxContext,
389
+ ): Promise<Result<number>> {
390
+ const pricing = this.deps.services.pricing as
391
+ | {
392
+ resolve(
393
+ params: {
394
+ entityId: string;
395
+ currency: string;
396
+ quantity: number;
397
+ variantId?: string;
398
+ },
399
+ actor?: Actor | null,
400
+ ctx?: TxContext,
401
+ ): Promise<Result<{ finalAmount: number }>>;
402
+ }
403
+ | undefined;
404
+
405
+ if (typeof pricing?.resolve !== "function") {
406
+ return Err(
407
+ new CommerceValidationError(
408
+ `Cannot resolve a unit price for ${input.entityId}: no pricing service is configured.`,
409
+ ),
410
+ );
411
+ }
412
+
413
+ const resolved = await pricing.resolve(input, actor, ctx);
414
+ if (!resolved.ok) {
415
+ return Err(
416
+ new CommerceValidationError(
417
+ `Cannot resolve a unit price for ${input.entityId} (${input.currency}). Configure a price for it, or supply unitPriceSnapshot from a cart.beforeAddItem hook.`,
418
+ ),
419
+ );
420
+ }
421
+ return Ok(resolved.value.finalAmount);
422
+ }
423
+
342
424
  async removeItem(
343
425
  cartId: string,
344
426
  itemId: string,
@@ -820,10 +902,8 @@ export class CartService {
820
902
  if (actor && actor.permissions.includes("*:*")) return;
821
903
 
822
904
  if (cart.customerId != null) {
823
- if (!actor) {
824
- throw new CommerceForbiddenError(
825
- "Authentication required to read customer cart.",
826
- );
905
+ if (isUnauthenticatedActor(actor)) {
906
+ throw new CommerceUnauthorizedError(AUTHENTICATION_REQUIRED_MESSAGE);
827
907
  }
828
908
  const profileId = await this.resolveActorCustomerId(actor, ctx);
829
909
  if (profileId !== cart.customerId) {