@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.
- package/dist/auth/identity-free-routes.d.ts +51 -0
- package/dist/auth/identity-free-routes.d.ts.map +1 -0
- package/dist/auth/identity-free-routes.js +49 -0
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +6 -0
- package/dist/auth/permissions.d.ts +7 -0
- package/dist/auth/permissions.d.ts.map +1 -1
- package/dist/auth/permissions.js +23 -3
- package/dist/config/types.d.ts +15 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/interfaces/rest/customer-portal.d.ts.map +1 -1
- package/dist/interfaces/rest/customer-portal.js +14 -2
- package/dist/interfaces/rest/router.d.ts +10 -1
- package/dist/interfaces/rest/router.d.ts.map +1 -1
- package/dist/interfaces/rest/router.js +8 -3
- package/dist/interfaces/rest/utils.d.ts +3 -8
- package/dist/interfaces/rest/utils.d.ts.map +1 -1
- package/dist/interfaces/rest/utils.js +8 -7
- 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/cart/service.d.ts +7 -0
- package/dist/modules/cart/service.d.ts.map +1 -1
- package/dist/modules/cart/service.js +46 -6
- package/package.json +3 -3
- package/src/auth/identity-free-routes.ts +86 -0
- package/src/auth/middleware.ts +7 -0
- package/src/auth/permissions.ts +30 -2
- package/src/config/types.ts +15 -0
- package/src/index.ts +10 -1
- package/src/interfaces/rest/customer-portal.ts +23 -3
- package/src/interfaces/rest/router.ts +22 -4
- package/src/interfaces/rest/utils.ts +16 -11
- package/src/kernel/error-mapper.ts +1 -0
- package/src/kernel/errors.ts +8 -0
- 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
|
|
199
|
-
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 (
|
|
517
|
-
throw new
|
|
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.
|
|
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/
|
|
66
|
-
"@porulle/
|
|
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
|
+
}
|
package/src/auth/middleware.ts
CHANGED
|
@@ -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
|
|
package/src/auth/permissions.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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.");
|
package/src/config/types.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 (
|
|
37
|
+
if (isUnauthenticatedActor(actor)) {
|
|
29
38
|
return c.json(
|
|
30
|
-
{ error: { code: "
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
222
|
-
|
|
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:
|
|
88
|
-
const actor = c.get("actor")
|
|
89
|
-
|
|
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:
|
|
110
|
-
const actor = c.get("actor")
|
|
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);
|
package/src/kernel/errors.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
327
|
-
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 (
|
|
824
|
-
throw new
|
|
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) {
|