@porulle/core 0.10.4 → 0.10.6

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 (38) hide show
  1. package/dist/auth/setup.d.ts.map +1 -1
  2. package/dist/auth/setup.js +1 -0
  3. package/dist/config/types.d.ts +8 -0
  4. package/dist/config/types.d.ts.map +1 -1
  5. package/dist/hooks/checkout.d.ts +5 -0
  6. package/dist/hooks/checkout.d.ts.map +1 -1
  7. package/dist/hooks/checkout.js +6 -0
  8. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  9. package/dist/interfaces/rest/routes/checkout.js +27 -1
  10. package/dist/interfaces/rest/schemas/checkout.d.ts +2 -0
  11. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  12. package/dist/interfaces/rest/schemas/checkout.js +8 -1
  13. package/dist/interfaces/rest/schemas/compensation-failures.d.ts +1 -1
  14. package/dist/interfaces/rest/schemas/promotions.d.ts +2 -2
  15. package/dist/kernel/hooks/executor.d.ts.map +1 -1
  16. package/dist/kernel/hooks/executor.js +18 -1
  17. package/dist/modules/cart/repository/index.d.ts +2 -0
  18. package/dist/modules/cart/repository/index.d.ts.map +1 -1
  19. package/dist/modules/cart/repository/index.js +10 -0
  20. package/dist/modules/cart/service.d.ts +5 -0
  21. package/dist/modules/cart/service.d.ts.map +1 -1
  22. package/dist/modules/cart/service.js +7 -0
  23. package/dist/modules/payments/adapter.d.ts +4 -0
  24. package/dist/modules/payments/adapter.d.ts.map +1 -1
  25. package/dist/runtime/server.d.ts.map +1 -1
  26. package/dist/runtime/server.js +9 -1
  27. package/dist/tsconfig.tsbuildinfo +1 -0
  28. package/package.json +13 -13
  29. package/src/auth/setup.ts +1 -0
  30. package/src/config/types.ts +8 -0
  31. package/src/hooks/checkout.ts +13 -0
  32. package/src/interfaces/rest/routes/checkout.ts +29 -1
  33. package/src/interfaces/rest/schemas/checkout.ts +8 -1
  34. package/src/kernel/hooks/executor.ts +19 -1
  35. package/src/modules/cart/repository/index.ts +14 -0
  36. package/src/modules/cart/service.ts +11 -0
  37. package/src/modules/payments/adapter.ts +4 -0
  38. package/src/runtime/server.ts +10 -2
package/package.json CHANGED
@@ -1,38 +1,38 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
8
+ "types": "./src/index.ts",
8
9
  "bun": "./src/index.ts",
9
10
  "import": "./dist/index.js",
10
- "require": "./dist/index.js",
11
- "types": "./src/index.ts"
11
+ "require": "./dist/index.js"
12
12
  },
13
13
  "./schema": {
14
+ "types": "./src/kernel/database/schema.ts",
14
15
  "bun": "./src/kernel/database/schema.ts",
15
16
  "import": "./dist/kernel/database/schema.js",
16
- "require": "./dist/kernel/database/schema.js",
17
- "types": "./src/kernel/database/schema.ts"
17
+ "require": "./dist/kernel/database/schema.js"
18
18
  },
19
19
  "./auth-schema": {
20
+ "types": "./src/auth/auth-schema.ts",
20
21
  "bun": "./src/auth/auth-schema.ts",
21
22
  "import": "./dist/auth/auth-schema.js",
22
- "require": "./dist/auth/auth-schema.js",
23
- "types": "./src/auth/auth-schema.ts"
23
+ "require": "./dist/auth/auth-schema.js"
24
24
  },
25
25
  "./testing": {
26
+ "types": "./src/testing.ts",
26
27
  "bun": "./src/testing.ts",
27
28
  "import": "./dist/testing.js",
28
- "require": "./dist/testing.js",
29
- "types": "./src/testing.ts"
29
+ "require": "./dist/testing.js"
30
30
  },
31
31
  "./drizzle": {
32
+ "types": "./src/drizzle.ts",
32
33
  "bun": "./src/drizzle.ts",
33
34
  "import": "./dist/drizzle.js",
34
- "require": "./dist/drizzle.js",
35
- "types": "./src/drizzle.ts"
35
+ "require": "./dist/drizzle.js"
36
36
  }
37
37
  },
38
38
  "dependencies": {
@@ -56,8 +56,8 @@
56
56
  "eslint": "^9.39.1",
57
57
  "typescript": "5.9.2",
58
58
  "vitest": "^3.2.4",
59
- "@porulle/typescript-config": "0.1.0",
60
- "@porulle/eslint-config": "0.1.0"
59
+ "@porulle/eslint-config": "0.1.0",
60
+ "@porulle/typescript-config": "0.1.0"
61
61
  },
62
62
  "publishConfig": {
63
63
  "access": "public"
package/src/auth/setup.ts CHANGED
@@ -122,6 +122,7 @@ export function createAuth(
122
122
 
123
123
  try {
124
124
  const auth = betterAuth({
125
+ ...(config.auth?.baseURL ? { baseURL: config.auth.baseURL } : {}),
125
126
  database: drizzleAdapter(db.db as unknown as Record<string, unknown>, {
126
127
  provider: resolveAuthDbProvider(db.provider),
127
128
  schema: authSchema,
@@ -123,6 +123,14 @@ export interface ApiKeyScopeDefinition {
123
123
  }
124
124
 
125
125
  export interface AuthConfig {
126
+ /**
127
+ * Canonical public origin used by Better Auth when constructing callback
128
+ * URLs and validating requests. Set this explicitly in deployed runtimes
129
+ * instead of relying on request-header inference.
130
+ *
131
+ * @example "https://commerce.example.com"
132
+ */
133
+ baseURL?: string;
126
134
  /**
127
135
  * The default organization ID for single-store deployments.
128
136
  * Created by the seed script via `auth.api.createOrganization()`.
@@ -61,7 +61,12 @@ export interface CheckoutData {
61
61
  customerId?: string;
62
62
  customerGroupIds?: string[];
63
63
  currency: string;
64
+ /** Porulle payment adapter/provider id, e.g. `stripe`. */
64
65
  paymentMethodId: string;
66
+ /** Provider-issued token for the actual payment instrument. */
67
+ paymentMethodToken?: string;
68
+ /** Stable retry key shared by order creation and payment authorization. */
69
+ idempotencyKey?: string;
65
70
  lineItems: CheckoutLineItem[];
66
71
  subtotal: number;
67
72
  discountTotal: number;
@@ -517,6 +522,8 @@ export const authorizePayment: BeforeHook<CheckoutData> = async ({
517
522
  amount: number;
518
523
  currency: string;
519
524
  paymentMethodId: string;
525
+ paymentMethodToken?: string;
526
+ idempotencyKey?: string;
520
527
  customerId?: string;
521
528
  metadata: Record<string, unknown>;
522
529
  }): Promise<{
@@ -529,6 +536,12 @@ export const authorizePayment: BeforeHook<CheckoutData> = async ({
529
536
  amount: data.total,
530
537
  currency: data.currency,
531
538
  paymentMethodId: data.paymentMethodId,
539
+ ...(data.paymentMethodToken !== undefined
540
+ ? { paymentMethodToken: data.paymentMethodToken }
541
+ : {}),
542
+ ...(data.idempotencyKey !== undefined
543
+ ? { idempotencyKey: data.idempotencyKey }
544
+ : {}),
532
545
  metadata: {
533
546
  checkoutId: data.checkoutId,
534
547
  cartId: data.cartId,
@@ -131,6 +131,12 @@ export function checkoutRoutes(kernel: Kernel) {
131
131
  cartId: body.cartId,
132
132
  currency: body.currency ?? "USD",
133
133
  paymentMethodId: body.paymentMethodId,
134
+ ...(body.paymentMethodToken !== undefined
135
+ ? { paymentMethodToken: body.paymentMethodToken }
136
+ : {}),
137
+ ...(body.idempotencyKey !== undefined
138
+ ? { idempotencyKey: body.idempotencyKey }
139
+ : {}),
134
140
  lineItems: [],
135
141
  subtotal: 0,
136
142
  discountTotal: 0,
@@ -194,6 +200,7 @@ export function checkoutRoutes(kernel: Kernel) {
194
200
  database: { db: kernel.database.db as PluginDb },
195
201
  });
196
202
 
203
+ let pipelineStage = "validate-and-calculate";
197
204
  try {
198
205
  // Phase 1: DB transaction for validation — releases connection immediately after
199
206
  const validated = await kernel.database.transaction(async (_tx) => {
@@ -208,6 +215,7 @@ export function checkoutRoutes(kernel: Kernel) {
208
215
 
209
216
  // Phase 2: Payment authorization — NO DB connection held while calling Stripe/etc.
210
217
  // If Stripe takes 5s, the DB connection pool is not affected.
218
+ pipelineStage = "authorize-payment";
211
219
  context.tx = null;
212
220
  const processed = await runBeforeHooks(
213
221
  paymentHooks,
@@ -219,6 +227,7 @@ export function checkoutRoutes(kernel: Kernel) {
219
227
  // SEC-07: resolve the order's customer server-side. A self-service actor
220
228
  // can only attribute the order to its own profile; a client-supplied
221
229
  // foreign customerId is ignored unless the actor is staff.
230
+ pipelineStage = "resolve-customer";
222
231
  const customerUuid = await resolveCheckoutCustomerUuid(
223
232
  kernel.services.customers,
224
233
  actor,
@@ -275,6 +284,7 @@ export function checkoutRoutes(kernel: Kernel) {
275
284
  // Checkout is a trusted, already-server-priced pipeline (resolveCurrentPrices
276
285
  // + promotions/tax) and reserves stock in its own after-hooks — so it hands
277
286
  // the order primitive precomputed totals rather than re-deriving them.
287
+ pipelineStage = "create-order";
278
288
  const order = await kernel.services.orders.create(orderPayload, actor, undefined, {
279
289
  trustedPricing: true,
280
290
  });
@@ -287,6 +297,7 @@ export function checkoutRoutes(kernel: Kernel) {
287
297
  }
288
298
 
289
299
  if (order.ok && (processed.appliedPromotions?.length ?? 0) > 0) {
300
+ pipelineStage = "record-promotion-usage";
290
301
  await kernel.services.promotions.recordUsage({
291
302
  promotions: processed.appliedPromotions ?? [],
292
303
  organizationId: order.value.organizationId,
@@ -298,6 +309,7 @@ export function checkoutRoutes(kernel: Kernel) {
298
309
  }
299
310
 
300
311
  if (order.ok) {
312
+ pipelineStage = "report-tax-transaction";
301
313
  await kernel.services.tax.reportTransaction({
302
314
  transactionId: order.value.id,
303
315
  transactionDate: new Date(),
@@ -330,6 +342,7 @@ export function checkoutRoutes(kernel: Kernel) {
330
342
  // Stash paymentMethodId for completeCheckout compensation chain
331
343
  context.context.paymentMethodId = processed.paymentMethodId;
332
344
 
345
+ pipelineStage = "complete-checkout";
333
346
  const afterReport = await runAfterHooks(
334
347
  afterHooks,
335
348
  null,
@@ -338,6 +351,7 @@ export function checkoutRoutes(kernel: Kernel) {
338
351
  context,
339
352
  );
340
353
 
354
+ pipelineStage = "mark-cart-checked-out";
341
355
  await kernel.services.cart.markAsCheckedOut(body.cartId, actor);
342
356
 
343
357
  return c.json(
@@ -356,8 +370,22 @@ export function checkoutRoutes(kernel: Kernel) {
356
370
  201,
357
371
  );
358
372
  } catch (error) {
373
+ // Validation, payment authorization, or compensated completion may fail
374
+ // after the cart's atomic active -> checking_out claim. Release only
375
+ // that transient state so the shopper can correct the problem and retry.
376
+ await kernel.services.cart.releaseCheckoutClaim(body.cartId).catch(
377
+ (releaseError: unknown) => {
378
+ console.error("[checkout] Failed to release cart claim:", {
379
+ cartId: body.cartId,
380
+ error: releaseError instanceof Error ? releaseError.message : String(releaseError),
381
+ });
382
+ },
383
+ );
359
384
  // Always log the real error — hidden errors in checkout are unacceptable
360
- const realMessage = error instanceof Error ? error.message : String(error);
385
+ const rawMessage = error instanceof Error ? error.message : String(error);
386
+ const realMessage = rawMessage === "[object ErrorEvent]"
387
+ ? `Checkout stage "${pipelineStage}" failed: ${rawMessage}`
388
+ : rawMessage;
361
389
  const realStack = error instanceof Error ? error.stack : undefined;
362
390
  console.error("[checkout] Pipeline failed:", { message: realMessage, stack: realStack, code: (error as Record<string, unknown>)?.code });
363
391
 
@@ -5,7 +5,14 @@ import { ErrorSchema, errorResponses } from "./shared.js";
5
5
 
6
6
  export const CheckoutBodySchema = z.object({
7
7
  cartId: z.uuid().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
8
- paymentMethodId: z.string().openapi({ example: "pm_card_visa" }),
8
+ paymentMethodId: z.string().openapi({
9
+ example: "stripe",
10
+ description: "Registered Porulle payment adapter/provider id.",
11
+ }),
12
+ paymentMethodToken: z.string().min(3).max(255).optional().openapi({
13
+ example: "pm_card_visa",
14
+ description: "Provider-issued token for the shopper's payment instrument. This is not the Porulle adapter id.",
15
+ }),
9
16
  idempotencyKey: z.string().min(8).max(255).optional().openapi({
10
17
  example: "checkout-8f14e45f-1738312200",
11
18
  description: "Client-supplied retry key. A re-submitted checkout with the same key returns the already-created order without re-authorizing payment (safe offline-queue replay).",
@@ -34,6 +34,24 @@ function withTimeout<T>(promiseOrValue: Promise<T> | T, timeoutMs: number, hookN
34
34
  });
35
35
  }
36
36
 
37
+ function actionableHookError(error: unknown, hookName: string): Error {
38
+ if (error instanceof Error && error.message && error.message !== "[object ErrorEvent]") {
39
+ return error;
40
+ }
41
+ const candidate = error as { error?: unknown; cause?: unknown; message?: unknown } | null;
42
+ const nested = candidate?.error ?? candidate?.cause;
43
+ const nestedMessage = nested instanceof Error
44
+ ? nested.message
45
+ : nested && typeof nested === "object" && typeof (nested as { message?: unknown }).message === "string"
46
+ ? String((nested as { message: string }).message)
47
+ : undefined;
48
+ const directMessage = typeof candidate?.message === "string" && candidate.message !== "[object ErrorEvent]"
49
+ ? candidate.message
50
+ : undefined;
51
+ const message = nestedMessage || directMessage || String(error);
52
+ return new Error(`Before-hook "${hookName}" failed: ${message}`, { cause: error });
53
+ }
54
+
37
55
  export async function runBeforeHooks<T>(
38
56
  hooks: BeforeHook<T>[],
39
57
  data: T,
@@ -54,7 +72,7 @@ export async function runBeforeHooks<T>(
54
72
  error: error instanceof Error ? error.message : String(error),
55
73
  requestId: context.requestId,
56
74
  });
57
- throw error; // Re-throw beforeHooks MUST succeed
75
+ throw actionableHookError(error, hookName); // Before hooks MUST succeed with actionable context.
58
76
  }
59
77
  }
60
78
  return current;
@@ -148,6 +148,20 @@ export class CartRepository {
148
148
  return rows[0];
149
149
  }
150
150
 
151
+ /** Release only an in-progress checkout claim; terminal cart states are untouched. */
152
+ async releaseCheckoutClaim(
153
+ id: string,
154
+ ctx?: TxContext,
155
+ ): Promise<Cart | undefined> {
156
+ const db = this.getDb(ctx);
157
+ const rows = await db
158
+ .update(carts)
159
+ .set({ status: "active", updatedAt: new Date() })
160
+ .where(and(eq(carts.id, id), eq(carts.status, "checking_out")))
161
+ .returning();
162
+ return rows[0];
163
+ }
164
+
151
165
  async delete(id: string, ctx?: TxContext): Promise<boolean> {
152
166
  const db = this.getDb(ctx);
153
167
  const result = await db.delete(carts).where(eq(carts.id, id)).returning();
@@ -599,6 +599,17 @@ export class CartService {
599
599
  return Ok(claimed);
600
600
  }
601
601
 
602
+ /**
603
+ * Atomically returns a failed checkout claim to `active`. A checked-out,
604
+ * merged, abandoned, or already-active cart is left untouched.
605
+ */
606
+ async releaseCheckoutClaim(
607
+ cartId: string,
608
+ ctx?: TxContext,
609
+ ): Promise<Result<Cart | null>> {
610
+ return Ok((await this.repo.releaseCheckoutClaim(cartId, ctx)) ?? null);
611
+ }
612
+
602
613
  /**
603
614
  * Creates an anonymous guest cart with a secret token for access control.
604
615
  * The secret must be stored client-side (cookie/local storage) and sent
@@ -32,6 +32,10 @@ export interface CreatePaymentIntentParams {
32
32
  orderId: string;
33
33
  customerId?: string;
34
34
  metadata?: Record<string, string>;
35
+ /** Provider token for the shopper-selected payment instrument. */
36
+ paymentMethodToken?: string;
37
+ /** Stable key forwarded to the provider so retries cannot create a second intent. */
38
+ idempotencyKey?: string;
35
39
  terminalId?: string;
36
40
  }
37
41
 
@@ -39,8 +39,16 @@ type ServerEnv = {
39
39
  * Exposed for direct unit testing — see test/server-edge-runtime.test.ts.
40
40
  */
41
41
  export function isNodeRuntime(): boolean {
42
+ const workerUserAgent = typeof navigator !== "undefined"
43
+ ? navigator.userAgent
44
+ : undefined;
45
+ const hasWorkersSocketPair = "WebSocketPair" in globalThis;
46
+ if (workerUserAgent === "Cloudflare-Workers" || hasWorkersSocketPair) {
47
+ return false;
48
+ }
42
49
  return (
43
50
  typeof process !== "undefined" &&
51
+ typeof process.versions?.node === "string" &&
44
52
  typeof process.on === "function" &&
45
53
  typeof process.exit === "function"
46
54
  );
@@ -150,8 +158,8 @@ export async function createServer(config: CommerceConfig) {
150
158
  const ct = c.res.headers.get("content-type");
151
159
  if (ct?.includes("json")) {
152
160
  try {
153
- const body = await c.res.clone().json();
154
- if (body?.error?.name === "ZodError") {
161
+ const body = await c.res.clone().json() as { error?: { name?: string } };
162
+ if (body.error?.name === "ZodError") {
155
163
  c.res = new Response(
156
164
  JSON.stringify({ error: { code: "VALIDATION_FAILED", message: "Invalid input." } }),
157
165
  { status: 422, headers: { "content-type": "application/json" } },