@porulle/core 0.40.0 → 0.41.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/middleware.d.ts +21 -0
  2. package/dist/auth/middleware.d.ts.map +1 -1
  3. package/dist/auth/middleware.js +59 -1
  4. package/dist/auth/permissions.d.ts.map +1 -1
  5. package/dist/auth/permissions.js +10 -8
  6. package/dist/hooks/checkout.d.ts.map +1 -1
  7. package/dist/hooks/checkout.js +0 -1
  8. package/dist/interfaces/rest/customer-portal.js +1 -1
  9. package/dist/interfaces/rest/schemas/catalog.d.ts +83 -5
  10. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  11. package/dist/interfaces/rest/schemas/customer-portal.d.ts +8 -5
  12. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  13. package/dist/interfaces/rest/schemas/orders.d.ts +8 -5
  14. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  15. package/dist/interfaces/rest/schemas/responses.d.ts +33 -15
  16. package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
  17. package/dist/interfaces/rest/schemas/responses.js +10 -4
  18. package/dist/interfaces/rest/schemas/search.d.ts +3 -0
  19. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  20. package/dist/modules/cart/service.d.ts.map +1 -1
  21. package/dist/modules/pricing/service.d.ts +0 -1
  22. package/dist/modules/pricing/service.d.ts.map +1 -1
  23. package/dist/runtime/kernel-register-hooks.d.ts.map +1 -1
  24. package/dist/runtime/kernel-register-hooks.js +0 -2
  25. package/dist/runtime/server.d.ts.map +1 -1
  26. package/dist/runtime/server.js +7 -2
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +1 -1
  29. package/src/auth/middleware.ts +60 -1
  30. package/src/auth/permissions.ts +10 -8
  31. package/src/hooks/checkout.ts +2 -9
  32. package/src/interfaces/rest/customer-portal.ts +1 -1
  33. package/src/interfaces/rest/schemas/responses.ts +10 -4
  34. package/src/modules/cart/service.ts +0 -1
  35. package/src/modules/pricing/service.ts +0 -1
  36. package/src/runtime/kernel-register-hooks.ts +0 -2
  37. package/src/runtime/server.ts +7 -2
  38. package/dist/hooks/order-emails.d.ts +0 -16
  39. package/dist/hooks/order-emails.d.ts.map +0 -1
  40. package/dist/hooks/order-emails.js +0 -44
  41. package/src/hooks/order-emails.ts +0 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,6 +17,33 @@ function emptyToNull(value: string | null | undefined): string | null {
17
17
  // `catalog:read` here would 401 every public storefront read.
18
18
  export { DEFAULT_CUSTOMER_PERMISSIONS } from "./actor.js";
19
19
 
20
+ /**
21
+ * The challenge RFC 9110 §15.5.2 requires on a 401: it "MUST include a WWW-Authenticate header
22
+ * field containing at least one challenge applicable to the target resource". Without it a 401
23
+ * names no scheme, so it is advice a generic HTTP client cannot act on.
24
+ *
25
+ * `Bearer` and nothing else. Both credentials this API accepts — the session cookie the mobile
26
+ * client holds and the `x-api-key` a machine holds — are presented as bearer-style tokens, and a
27
+ * `Basic` challenge would make a browser render a native credential prompt over a JSON API.
28
+ *
29
+ * The realm is a constant. It must never carry the organization or vendor, which would disclose
30
+ * tenancy to a caller that has not authenticated.
31
+ */
32
+ export const AUTHENTICATE_CHALLENGE = 'Bearer realm="api"';
33
+
34
+ /**
35
+ * Attach the challenge to a 401, and to nothing else.
36
+ *
37
+ * NOT unconditional: §15.5.4 asks no challenge of a 403, and offering one there tells a caller who
38
+ * IS authenticated to try authenticating again. An existing header is left alone so a plugin or a
39
+ * route can answer with a more specific challenge than this default.
40
+ */
41
+ export function applyAuthenticateChallenge(response: Response): void {
42
+ if (response.status !== 401) return;
43
+ if (response.headers.has("www-authenticate")) return;
44
+ response.headers.set("www-authenticate", AUTHENTICATE_CHALLENGE);
45
+ }
46
+
20
47
  const LEGACY_STORE_RESOLVER_WARN_COOLDOWN_MS = 60_000;
21
48
  let lastLegacyStoreResolverWarnAt = 0;
22
49
 
@@ -24,7 +51,15 @@ export function authMiddleware(
24
51
  auth: AuthInstance,
25
52
  config: CommerceConfig,
26
53
  ): MiddlewareHandler {
27
- return async (c, next) => {
54
+ /**
55
+ * The resolution body. It has FIVE `next()` call sites and four of them `return` immediately
56
+ * after — a signed-in caller leaves at the `if (actor)` branch, an API-key caller at its own,
57
+ * and only an anonymous caller reaches the last one. Anything that must run for EVERY request
58
+ * therefore cannot live at the bottom of this function: it would fire for one caller class and
59
+ * silently skip the rest. Measured, not assumed — a header set at the bottom reached an
60
+ * anonymous 401 and never reached a signed-in 403.
61
+ */
62
+ const resolve: MiddlewareHandler = async (c, next) => {
28
63
  if (isIdentityFreeRoute(c.req.method, c.req.path, config)) {
29
64
  c.set("actor", null);
30
65
  await next();
@@ -238,4 +273,28 @@ export function authMiddleware(
238
273
  }
239
274
  await next();
240
275
  };
276
+
277
+ // ONE boundary around all five of the body's exits. A 401 leaves this server by five routes —
278
+ // `requirePerm` and `requireAnyPerm` in interfaces/rest/utils.ts, the plugin router's inline
279
+ // refusal, the customer portal's own, and a thrown `CommerceUnauthorizedError` shaped by
280
+ // `mapErrorToResponse` — and every one of them unwinds through here whichever way the body
281
+ // returned. So one line covers all five, and any sixth refusal added later, which five copies of
282
+ // the rule could not.
283
+ //
284
+ // The only 401 that does NOT reach this point is one produced by `app.onError`: by then every
285
+ // `await next()` in the chain has already rejected. runtime/server.ts calls the same helper there.
286
+ return async (c, next) => {
287
+ // The body RETURNS a Response on one path — the strict-org-resolution 503 — and Hono assigns
288
+ // that return value over `c.res` after this handler finishes. Dropping it on the floor turned
289
+ // two `ORG_RESOLUTION_FAILED` rows red, and challenging `c.res` instead of the returned object
290
+ // would have mutated a response that was about to be replaced. Both cases are handled by
291
+ // challenging whichever object is actually going to be sent.
292
+ const returned = await resolve(c, next);
293
+ if (returned instanceof Response) {
294
+ applyAuthenticateChallenge(returned);
295
+ return returned;
296
+ }
297
+ applyAuthenticateChallenge(c.res);
298
+ return;
299
+ };
241
300
  }
@@ -41,15 +41,17 @@ export function assertPermission(actor: Actor | null, required: string): void {
41
41
  );
42
42
  }
43
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.
50
44
  export function assertOwnership(actor: Actor | null, resourceOwnerId: string | null): void {
51
- if (!actor) {
52
- throw new CommerceForbiddenError("Authentication required.");
45
+ // Written exactly as `assertPermission` above, and for the same two reasons: `actor === null`
46
+ // is redundant at runtime because the predicate already covers it, and load-bearing at compile
47
+ // time because a boolean predicate narrows nothing and the lines below read off `actor`.
48
+ //
49
+ // Hoisting the predicate is what makes the rest of this function readable: an `api_key` actor
50
+ // presented a credential and a `userId: ""` actor is a credential with a blank identity, so
51
+ // neither satisfies the predicate and both fall through to the 403 below — telling either to
52
+ // authenticate would be advice it cannot act on.
53
+ if (actor === null || isUnauthenticatedActor(actor)) {
54
+ throw new CommerceUnauthorizedError(AUTHENTICATION_REQUIRED_MESSAGE);
53
55
  }
54
56
  if (actor.permissions.includes("*:*")) return;
55
57
  if (!actor.userId || !resourceOwnerId) {
@@ -4,6 +4,7 @@ import type { CompensationFailuresRepository } from "../kernel/compensation/repo
4
4
  import type { AfterHook, BeforeHook } from "../kernel/hooks/types.js";
5
5
  import type { ShippingAddress } from "../modules/shipping/calculator.js";
6
6
  import type { AppliedPromotion } from "../modules/promotions/service.js";
7
+ import type { PriceResolutionContext } from "../modules/pricing/service.js";
7
8
  import { runCompensationChain } from "../kernel/compensation/executor.js";
8
9
  import type { CompensationContext } from "../kernel/compensation/types.js";
9
10
  import type { TxContext } from "../kernel/database/tx-context.js";
@@ -209,14 +210,7 @@ export const resolveCurrentPrices: BeforeHook<CheckoutData> = async ({
209
210
  context,
210
211
  }) => {
211
212
  const pricing = context.services.pricing as {
212
- resolve(params: {
213
- entityId: string;
214
- variantId?: string;
215
- currency: string;
216
- quantity: number;
217
- customerId?: string;
218
- customerGroupIds?: string[];
219
- }, actor?: unknown): Promise<
213
+ resolve(params: PriceResolutionContext, actor?: unknown): Promise<
220
214
  | {
221
215
  ok: true;
222
216
  value: {
@@ -239,7 +233,6 @@ export const resolveCurrentPrices: BeforeHook<CheckoutData> = async ({
239
233
  currency: data.currency,
240
234
  quantity: item.quantity,
241
235
  ...(item.variantId !== undefined ? { variantId: item.variantId } : {}),
242
- ...(data.customerId !== undefined ? { customerId: data.customerId } : {}),
243
236
  ...(data.customerGroupIds !== undefined
244
237
  ? { customerGroupIds: data.customerGroupIds }
245
238
  : {}),
@@ -125,7 +125,7 @@ export function createCustomerPortalRoutes(kernel: Kernel) {
125
125
  const status = c.req.query("status");
126
126
  // Resolve customer profile UUID from Better Auth userId
127
127
  const customerResult = await kernel.services.customers.getByUserId(actor.userId, actor);
128
- if (!customerResult.ok) return c.json({ data: [], meta: { total: 0, page: 1, limit: 20, totalPages: 0 } });
128
+ if (!customerResult.ok) return c.json({ data: [], meta: { pagination: { page: 1, limit: 20, total: 0, totalPages: 0 } } });
129
129
  const result = await kernel.services.orders.listByCustomer(
130
130
  customerResult.value.id,
131
131
  {
@@ -56,6 +56,9 @@ export const CustomerAddressSchema = createSelectSchema(customerAddresses).opena
56
56
  export const CatalogEntitySchema = createSelectSchema(sellableEntities, {
57
57
  // Override jsonb → narrow to object (drizzle-zod maps jsonb to a wide union)
58
58
  metadata: z.record(z.string(), z.unknown()).openapi({ example: { weight: 200, material: "cotton" } }),
59
+ createdAt: z.string().datetime(),
60
+ updatedAt: z.string().datetime(),
61
+ publishedAt: z.string().datetime().nullable(),
59
62
  }).openapi("CatalogEntity");
60
63
 
61
64
  // ─── Jobs ────────────────────────────────────────────────────────────────────
@@ -77,10 +80,13 @@ export function paginatedResponse<T extends z.ZodType>(schema: T, name: string)
77
80
  return z.object({
78
81
  data: z.array(schema),
79
82
  meta: z.object({
80
- page: z.number(),
81
- limit: z.number(),
82
- total: z.number().optional(),
83
- }).optional(),
83
+ pagination: z.object({
84
+ page: z.number(),
85
+ limit: z.number(),
86
+ total: z.number(),
87
+ totalPages: z.number(),
88
+ }),
89
+ }),
84
90
  }).openapi(name);
85
91
  }
86
92
 
@@ -1,7 +1,6 @@
1
1
  import { resolveOrgIdForCommerce } from "../../auth/org.js";
2
2
  import {
3
3
  AUTHENTICATION_REQUIRED_MESSAGE,
4
- assertOwnership,
5
4
  assertPermission,
6
5
  isUnauthenticatedActor,
7
6
  } from "../../auth/permissions.js";
@@ -62,7 +62,6 @@ export interface PriceResolutionContext {
62
62
  variantId?: string;
63
63
  currency: string;
64
64
  quantity: number;
65
- customerId?: string;
66
65
  customerGroupIds?: string[];
67
66
  timestamp?: Date;
68
67
  }
@@ -3,7 +3,6 @@ import { HookRegistry, type HookHandler } from "../kernel/hooks/registry.js";
3
3
  import { deliverWebhooks } from "../modules/webhooks/hook.js";
4
4
  import { syncToSearchIndex } from "../modules/search/hooks.js";
5
5
  import { auditHooks } from "../modules/audit/hooks.js";
6
- import { sendOrderStatusEmail } from "../hooks/order-emails.js";
7
6
 
8
7
  export function registerConfiguredKernelHooks(
9
8
  config: CommerceConfig,
@@ -40,7 +39,6 @@ export function registerConfiguredKernelHooks(
40
39
 
41
40
  hooks.append("orders.afterCreate", deliverWebhooks);
42
41
  hooks.append("orders.afterStatusChange", deliverWebhooks);
43
- hooks.append("orders.afterStatusChange", sendOrderStatusEmail as (...args: unknown[]) => unknown);
44
42
  hooks.append("catalog.afterCreate", deliverWebhooks);
45
43
  hooks.append("catalog.afterUpdate", deliverWebhooks);
46
44
  hooks.append("catalog.afterDelete", deliverWebhooks);
@@ -10,7 +10,7 @@ import { createClientIpResolver } from "./client-ip.js";
10
10
  import type { Actor } from "../auth/types.js";
11
11
  import type { AuthInstance } from "../auth/setup.js";
12
12
  import type { CommerceConfig } from "../config/types.js";
13
- import { authMiddleware } from "../auth/middleware.js";
13
+ import { applyAuthenticateChallenge, authMiddleware } from "../auth/middleware.js";
14
14
  import { resolveOrgIdForCommerce } from "../auth/org.js";
15
15
  import { organizationGuard } from "../auth/organization-guard.js";
16
16
  import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
@@ -371,7 +371,12 @@ export async function createServer(config: CommerceConfig) {
371
371
  const isProd = process.env.NODE_ENV === "production";
372
372
  app.onError((err, c) => {
373
373
  const { body, status } = mapErrorToResponse(err, isProd, logger);
374
- return c.json(body, status);
374
+ const response = c.json(body, status);
375
+ // The one 401 the auth middleware cannot reach: by the time onError runs, every `await next()`
376
+ // in the chain has already rejected, so its post-next step never executes. Same helper, so the
377
+ // rule lives in one place even though it is applied in two.
378
+ applyAuthenticateChallenge(response);
379
+ return response;
375
380
  });
376
381
 
377
382
  // ─── Routes ──────────────────────────────────────────────────────────
@@ -1,16 +0,0 @@
1
- /**
2
- * Order lifecycle email notifications.
3
- *
4
- * Sends emails on status changes: confirmed, fulfilled, cancelled, refunded.
5
- * Registered as an orders.afterStatusChange hook.
6
- */
7
- import type { AfterHook } from "../kernel/hooks/types.js";
8
- interface StatusChangeResult {
9
- orderId: string;
10
- customerId?: string | null;
11
- newStatus: string;
12
- previousStatus: string;
13
- }
14
- export declare const sendOrderStatusEmail: AfterHook<StatusChangeResult>;
15
- export {};
16
- //# sourceMappingURL=order-emails.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"order-emails.d.ts","sourceRoot":"","sources":["../../src/hooks/order-emails.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D,UAAU,kBAAkB;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,eAAO,MAAM,oBAAoB,EAAE,SAAS,CAAC,kBAAkB,CA6C9D,CAAC"}
@@ -1,44 +0,0 @@
1
- /**
2
- * Order lifecycle email notifications.
3
- *
4
- * Sends emails on status changes: confirmed, fulfilled, cancelled, refunded.
5
- * Registered as an orders.afterStatusChange hook.
6
- */
7
- export const sendOrderStatusEmail = async ({ result, context, }) => {
8
- const email = context.services.email;
9
- if (!email?.send)
10
- return;
11
- // Only send for customer-facing status changes
12
- const notifiableStatuses = ["confirmed", "processing", "fulfilled", "cancelled", "refunded"];
13
- if (!notifiableStatuses.includes(result.newStatus))
14
- return;
15
- // Look up customer email
16
- const customerId = result.customerId;
17
- if (!customerId)
18
- return;
19
- const customers = context.services.customers;
20
- if (!customers)
21
- return;
22
- try {
23
- const customer = await customers.getByUserId(customerId, context.actor);
24
- if (!customer.ok || !customer.value?.email)
25
- return;
26
- await email.send({
27
- template: "order-status-change",
28
- to: customer.value.email,
29
- data: {
30
- orderId: result.orderId,
31
- newStatus: result.newStatus,
32
- previousStatus: result.previousStatus,
33
- },
34
- });
35
- }
36
- catch (err) {
37
- // Email failure must not break the order flow
38
- context.logger.warn("Order status email failed", {
39
- orderId: result.orderId,
40
- newStatus: result.newStatus,
41
- error: err instanceof Error ? err.message : String(err),
42
- });
43
- }
44
- };
@@ -1,62 +0,0 @@
1
- /**
2
- * Order lifecycle email notifications.
3
- *
4
- * Sends emails on status changes: confirmed, fulfilled, cancelled, refunded.
5
- * Registered as an orders.afterStatusChange hook.
6
- */
7
-
8
- import type { AfterHook } from "../kernel/hooks/types.js";
9
-
10
- interface StatusChangeResult {
11
- orderId: string;
12
- customerId?: string | null;
13
- newStatus: string;
14
- previousStatus: string;
15
- }
16
-
17
- export const sendOrderStatusEmail: AfterHook<StatusChangeResult> = async ({
18
- result,
19
- context,
20
- }) => {
21
- const email = context.services.email as
22
- | { send(input: { template: string; to: string; data?: Record<string, unknown> }): Promise<void> }
23
- | undefined;
24
-
25
- if (!email?.send) return;
26
-
27
- // Only send for customer-facing status changes
28
- const notifiableStatuses = ["confirmed", "processing", "fulfilled", "cancelled", "refunded"];
29
- if (!notifiableStatuses.includes(result.newStatus)) return;
30
-
31
- // Look up customer email
32
- const customerId = result.customerId;
33
- if (!customerId) return;
34
-
35
- const customers = context.services.customers as
36
- | { getByUserId(id: string, actor?: unknown): Promise<{ ok: boolean; value?: { email?: string | null } }> }
37
- | undefined;
38
-
39
- if (!customers) return;
40
-
41
- try {
42
- const customer = await customers.getByUserId(customerId, context.actor);
43
- if (!customer.ok || !customer.value?.email) return;
44
-
45
- await email.send({
46
- template: "order-status-change",
47
- to: customer.value.email,
48
- data: {
49
- orderId: result.orderId,
50
- newStatus: result.newStatus,
51
- previousStatus: result.previousStatus,
52
- },
53
- });
54
- } catch (err) {
55
- // Email failure must not break the order flow
56
- context.logger.warn("Order status email failed", {
57
- orderId: result.orderId,
58
- newStatus: result.newStatus,
59
- error: err instanceof Error ? err.message : String(err),
60
- });
61
- }
62
- };