@unifiedcommerce/core 0.2.0 → 0.2.2
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/package.json +2 -1
- package/src/adapters/console-email.ts +43 -0
- package/src/auth/access.ts +187 -0
- package/src/auth/auth-schema.ts +139 -0
- package/src/auth/middleware.ts +161 -0
- package/src/auth/org.ts +41 -0
- package/src/auth/permissions.ts +28 -0
- package/src/auth/setup.ts +171 -0
- package/src/auth/system-actor.ts +19 -0
- package/src/auth/types.ts +10 -0
- package/src/config/defaults.ts +82 -0
- package/src/config/define-config.ts +53 -0
- package/src/config/types.ts +301 -0
- package/src/generated/plugin-capabilities.d.ts +20 -0
- package/src/generated/plugin-manifest.ts +23 -0
- package/src/generated/plugin-repositories.d.ts +20 -0
- package/src/hooks/checkout-completion.ts +262 -0
- package/src/hooks/checkout.ts +677 -0
- package/src/hooks/order-emails.ts +62 -0
- package/src/index.ts +215 -0
- package/src/interfaces/mcp/agent-prompt.ts +174 -0
- package/src/interfaces/mcp/context-enrichment.ts +177 -0
- package/src/interfaces/mcp/server.ts +47 -0
- package/src/interfaces/mcp/tool-builder.ts +261 -0
- package/src/interfaces/mcp/tools/analytics.ts +76 -0
- package/src/interfaces/mcp/tools/cart.ts +57 -0
- package/src/interfaces/mcp/tools/catalog.ts +299 -0
- package/src/interfaces/mcp/tools/index.ts +22 -0
- package/src/interfaces/mcp/tools/inventory.ts +161 -0
- package/src/interfaces/mcp/tools/orders.ts +104 -0
- package/src/interfaces/mcp/tools/pricing.ts +94 -0
- package/src/interfaces/mcp/tools/promotions.ts +106 -0
- package/src/interfaces/mcp/tools/registry.ts +101 -0
- package/src/interfaces/mcp/tools/search.ts +42 -0
- package/src/interfaces/mcp/tools/webhooks.ts +48 -0
- package/src/interfaces/mcp/transport.ts +128 -0
- package/src/interfaces/rest/customer-portal.ts +299 -0
- package/src/interfaces/rest/index.ts +74 -0
- package/src/interfaces/rest/router.ts +333 -0
- package/src/interfaces/rest/routes/admin-jobs.ts +58 -0
- package/src/interfaces/rest/routes/audit.ts +50 -0
- package/src/interfaces/rest/routes/carts.ts +89 -0
- package/src/interfaces/rest/routes/catalog.ts +493 -0
- package/src/interfaces/rest/routes/checkout.ts +284 -0
- package/src/interfaces/rest/routes/inventory.ts +70 -0
- package/src/interfaces/rest/routes/media.ts +86 -0
- package/src/interfaces/rest/routes/orders.ts +78 -0
- package/src/interfaces/rest/routes/payments.ts +60 -0
- package/src/interfaces/rest/routes/pricing.ts +57 -0
- package/src/interfaces/rest/routes/promotions.ts +93 -0
- package/src/interfaces/rest/routes/search.ts +71 -0
- package/src/interfaces/rest/routes/webhooks.ts +46 -0
- package/src/interfaces/rest/schemas/admin-jobs.ts +40 -0
- package/src/interfaces/rest/schemas/audit.ts +46 -0
- package/src/interfaces/rest/schemas/carts.ts +125 -0
- package/src/interfaces/rest/schemas/catalog.ts +450 -0
- package/src/interfaces/rest/schemas/checkout.ts +66 -0
- package/src/interfaces/rest/schemas/customer-portal.ts +195 -0
- package/src/interfaces/rest/schemas/inventory.ts +138 -0
- package/src/interfaces/rest/schemas/media.ts +75 -0
- package/src/interfaces/rest/schemas/orders.ts +104 -0
- package/src/interfaces/rest/schemas/pricing.ts +80 -0
- package/src/interfaces/rest/schemas/promotions.ts +110 -0
- package/src/interfaces/rest/schemas/responses.ts +85 -0
- package/src/interfaces/rest/schemas/search.ts +58 -0
- package/src/interfaces/rest/schemas/shared.ts +62 -0
- package/src/interfaces/rest/schemas/webhooks.ts +68 -0
- package/src/interfaces/rest/utils.ts +104 -0
- package/src/interfaces/rest/webhook-router.ts +50 -0
- package/src/kernel/compensation/executor.ts +61 -0
- package/src/kernel/compensation/types.ts +26 -0
- package/src/kernel/database/adapter.ts +21 -0
- package/src/kernel/database/drizzle-db.ts +56 -0
- package/src/kernel/database/migrate.ts +76 -0
- package/src/kernel/database/plugin-types.ts +34 -0
- package/src/kernel/database/schema.ts +49 -0
- package/src/kernel/database/scoped-db.ts +68 -0
- package/src/kernel/database/tx-context.ts +46 -0
- package/src/kernel/error-mapper.ts +15 -0
- package/src/kernel/errors.ts +89 -0
- package/src/kernel/factory/repository-factory.ts +244 -0
- package/src/kernel/hooks/create-context.ts +43 -0
- package/src/kernel/hooks/executor.ts +88 -0
- package/src/kernel/hooks/registry.ts +74 -0
- package/src/kernel/hooks/types.ts +52 -0
- package/src/kernel/http-error.ts +44 -0
- package/src/kernel/jobs/adapter.ts +36 -0
- package/src/kernel/jobs/drizzle-adapter.ts +58 -0
- package/src/kernel/jobs/runner.ts +153 -0
- package/src/kernel/jobs/schema.ts +46 -0
- package/src/kernel/jobs/types.ts +30 -0
- package/src/kernel/local-api.ts +187 -0
- package/src/kernel/plugin/manifest.ts +271 -0
- package/src/kernel/query/executor.ts +184 -0
- package/src/kernel/query/registry.ts +46 -0
- package/src/kernel/result.ts +33 -0
- package/src/kernel/schema/extra-columns.ts +37 -0
- package/src/kernel/service-registry.ts +76 -0
- package/src/kernel/service-timing.ts +89 -0
- package/src/kernel/state-machine/machine.ts +101 -0
- package/src/modules/analytics/drizzle-adapter.ts +426 -0
- package/src/modules/analytics/hooks.ts +11 -0
- package/src/modules/analytics/models.ts +125 -0
- package/src/modules/analytics/repository/index.ts +6 -0
- package/src/modules/analytics/service.ts +245 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/audit/hooks.ts +78 -0
- package/src/modules/audit/schema.ts +33 -0
- package/src/modules/audit/service.ts +151 -0
- package/src/modules/cart/access.ts +27 -0
- package/src/modules/cart/matcher.ts +26 -0
- package/src/modules/cart/repository/index.ts +234 -0
- package/src/modules/cart/schema.ts +42 -0
- package/src/modules/cart/schemas.ts +38 -0
- package/src/modules/cart/service.ts +541 -0
- package/src/modules/catalog/repository/index.ts +772 -0
- package/src/modules/catalog/schema.ts +203 -0
- package/src/modules/catalog/schemas.ts +104 -0
- package/src/modules/catalog/service.ts +1544 -0
- package/src/modules/customers/repository/index.ts +327 -0
- package/src/modules/customers/schema.ts +64 -0
- package/src/modules/customers/service.ts +171 -0
- package/src/modules/fulfillment/repository/index.ts +426 -0
- package/src/modules/fulfillment/schema.ts +101 -0
- package/src/modules/fulfillment/service.ts +555 -0
- package/src/modules/fulfillment/types.ts +59 -0
- package/src/modules/inventory/repository/index.ts +509 -0
- package/src/modules/inventory/schema.ts +94 -0
- package/src/modules/inventory/schemas.ts +38 -0
- package/src/modules/inventory/service.ts +490 -0
- package/src/modules/media/adapter.ts +17 -0
- package/src/modules/media/repository/index.ts +274 -0
- package/src/modules/media/schema.ts +41 -0
- package/src/modules/media/service.ts +151 -0
- package/src/modules/orders/repository/index.ts +287 -0
- package/src/modules/orders/schema.ts +66 -0
- package/src/modules/orders/service.ts +619 -0
- package/src/modules/orders/stale-order-cleanup.ts +76 -0
- package/src/modules/organization/service.ts +191 -0
- package/src/modules/payments/adapter.ts +47 -0
- package/src/modules/payments/repository/index.ts +6 -0
- package/src/modules/payments/service.ts +107 -0
- package/src/modules/pricing/repository/index.ts +291 -0
- package/src/modules/pricing/schema.ts +71 -0
- package/src/modules/pricing/schemas.ts +38 -0
- package/src/modules/pricing/service.ts +494 -0
- package/src/modules/promotions/repository/index.ts +325 -0
- package/src/modules/promotions/schema.ts +62 -0
- package/src/modules/promotions/schemas.ts +38 -0
- package/src/modules/promotions/service.ts +598 -0
- package/src/modules/search/adapter.ts +57 -0
- package/src/modules/search/hooks.ts +12 -0
- package/src/modules/search/repository/index.ts +6 -0
- package/src/modules/search/service.ts +315 -0
- package/src/modules/shipping/calculator.ts +188 -0
- package/src/modules/shipping/repository/index.ts +6 -0
- package/src/modules/shipping/service.ts +51 -0
- package/src/modules/tax/adapter.ts +60 -0
- package/src/modules/tax/repository/index.ts +6 -0
- package/src/modules/tax/service.ts +53 -0
- package/src/modules/webhooks/hook.ts +34 -0
- package/src/modules/webhooks/repository/index.ts +278 -0
- package/src/modules/webhooks/schema.ts +56 -0
- package/src/modules/webhooks/service.ts +117 -0
- package/src/modules/webhooks/signing.ts +6 -0
- package/src/modules/webhooks/ssrf-guard.ts +71 -0
- package/src/modules/webhooks/tasks.ts +52 -0
- package/src/modules/webhooks/worker.ts +134 -0
- package/src/runtime/commerce.ts +145 -0
- package/src/runtime/kernel.ts +426 -0
- package/src/runtime/logger.ts +36 -0
- package/src/runtime/server.ts +355 -0
- package/src/runtime/shutdown.ts +43 -0
- package/src/test-utils/create-pglite-adapter.ts +129 -0
- package/src/test-utils/create-plugin-test-app.ts +128 -0
- package/src/test-utils/create-repository-test-harness.ts +16 -0
- package/src/test-utils/create-test-config.ts +190 -0
- package/src/test-utils/create-test-kernel.ts +7 -0
- package/src/test-utils/create-test-plugin-context.ts +75 -0
- package/src/test-utils/rest-api-test-utils.ts +265 -0
- package/src/test-utils/test-actors.ts +62 -0
- package/src/test-utils/typed-hooks.ts +54 -0
- package/src/types/commerce-types.ts +34 -0
- package/src/utils/id.ts +3 -0
- package/src/utils/logger.ts +18 -0
- package/src/utils/pagination.ts +22 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed response schemas derived from Drizzle table definitions.
|
|
3
|
+
*
|
|
4
|
+
* Uses drizzle-zod's createSelectSchema() to generate Zod schemas that
|
|
5
|
+
* match the exact database column types. These replace z.any() in route
|
|
6
|
+
* response definitions, making the OpenAPI spec show real field names
|
|
7
|
+
* and types instead of empty {}.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createSelectSchema } from "drizzle-zod";
|
|
11
|
+
import { z } from "@hono/zod-openapi";
|
|
12
|
+
import { orders, orderLineItems } from "../../../modules/orders/schema.js";
|
|
13
|
+
import { carts, cartLineItems } from "../../../modules/cart/schema.js";
|
|
14
|
+
import { customers, customerAddresses } from "../../../modules/customers/schema.js";
|
|
15
|
+
import { sellableEntities } from "../../../modules/catalog/schema.js";
|
|
16
|
+
import { commerceJobs } from "../../../kernel/jobs/schema.js";
|
|
17
|
+
|
|
18
|
+
// ─── Orders ──────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export const OrderSchema = createSelectSchema(orders).openapi("Order");
|
|
21
|
+
|
|
22
|
+
export const OrderLineItemSchema = createSelectSchema(orderLineItems).openapi("OrderLineItem");
|
|
23
|
+
|
|
24
|
+
export const OrderWithItemsSchema = z.object({
|
|
25
|
+
...OrderSchema.shape,
|
|
26
|
+
lineItems: z.array(OrderLineItemSchema).optional(),
|
|
27
|
+
}).openapi("OrderWithItems");
|
|
28
|
+
|
|
29
|
+
// ─── Carts ───────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export const CartSchema = createSelectSchema(carts).openapi("Cart");
|
|
32
|
+
|
|
33
|
+
export const CartLineItemSchema = createSelectSchema(cartLineItems).openapi("CartLineItem");
|
|
34
|
+
|
|
35
|
+
export const CartWithItemsSchema = z.object({
|
|
36
|
+
...CartSchema.shape,
|
|
37
|
+
lineItems: z.array(CartLineItemSchema).optional(),
|
|
38
|
+
}).openapi("CartWithItems");
|
|
39
|
+
|
|
40
|
+
// ─── Customers ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export const CustomerSchema = createSelectSchema(customers).openapi("Customer");
|
|
43
|
+
|
|
44
|
+
export const CustomerAddressSchema = createSelectSchema(customerAddresses).openapi("CustomerAddress");
|
|
45
|
+
|
|
46
|
+
// ─── Catalog ─────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export const CatalogEntitySchema = createSelectSchema(sellableEntities).openapi("CatalogEntity");
|
|
49
|
+
|
|
50
|
+
// ─── Jobs ────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export const JobSchema = createSelectSchema(commerceJobs).openapi("Job");
|
|
53
|
+
|
|
54
|
+
// ─── Wrapped Response Helpers ────────────────────────────────────────────────
|
|
55
|
+
// These wrap a schema in { data: T } for consistent API response format.
|
|
56
|
+
|
|
57
|
+
export function dataResponse<T extends z.ZodType>(schema: T, name: string) {
|
|
58
|
+
return z.object({ data: schema }).openapi(name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function dataArrayResponse<T extends z.ZodType>(schema: T, name: string) {
|
|
62
|
+
return z.object({ data: z.array(schema) }).openapi(name);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function paginatedResponse<T extends z.ZodType>(schema: T, name: string) {
|
|
66
|
+
return z.object({
|
|
67
|
+
data: z.array(schema),
|
|
68
|
+
meta: z.object({
|
|
69
|
+
page: z.number(),
|
|
70
|
+
limit: z.number(),
|
|
71
|
+
total: z.number().optional(),
|
|
72
|
+
}).optional(),
|
|
73
|
+
}).openapi(name);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Pre-built Response Schemas ──────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export const OrderResponse = dataResponse(OrderSchema, "OrderResponse");
|
|
79
|
+
export const OrderListResponse = paginatedResponse(OrderSchema, "OrderListResponse");
|
|
80
|
+
export const CartResponse = dataResponse(CartWithItemsSchema, "CartResponse");
|
|
81
|
+
export const CustomerResponse = dataResponse(CustomerSchema, "CustomerResponse");
|
|
82
|
+
export const CustomerAddressListResponse = dataArrayResponse(CustomerAddressSchema, "CustomerAddressListResponse");
|
|
83
|
+
export const CatalogEntityResponse = dataResponse(CatalogEntitySchema, "CatalogEntityResponse");
|
|
84
|
+
export const CatalogEntityListResponse = paginatedResponse(CatalogEntitySchema, "CatalogEntityListResponse");
|
|
85
|
+
export const JobListResponse = dataArrayResponse(JobSchema, "JobListResponse");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { z, createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { CatalogEntitySchema } from "./responses.js";
|
|
3
|
+
|
|
4
|
+
// ─── Route Definitions ──────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export const searchRoute = createRoute({
|
|
7
|
+
method: "get",
|
|
8
|
+
path: "/",
|
|
9
|
+
tags: ["Search"],
|
|
10
|
+
summary: "Search catalog entities",
|
|
11
|
+
request: {
|
|
12
|
+
query: z.object({
|
|
13
|
+
q: z.string().optional(),
|
|
14
|
+
type: z.string().optional(),
|
|
15
|
+
category: z.string().optional(),
|
|
16
|
+
brand: z.string().optional(),
|
|
17
|
+
status: z.string().optional(),
|
|
18
|
+
page: z.string().optional(),
|
|
19
|
+
limit: z.string().optional(),
|
|
20
|
+
facets: z.string().optional(),
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
responses: {
|
|
24
|
+
200: {
|
|
25
|
+
content: { "application/json": { schema: z.object({
|
|
26
|
+
data: z.array(CatalogEntitySchema),
|
|
27
|
+
meta: z.object({
|
|
28
|
+
pagination: z.object({
|
|
29
|
+
page: z.number(),
|
|
30
|
+
limit: z.number(),
|
|
31
|
+
total: z.number().optional(),
|
|
32
|
+
}),
|
|
33
|
+
}).optional(),
|
|
34
|
+
}) } },
|
|
35
|
+
description: "Search results",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const suggestRoute = createRoute({
|
|
41
|
+
method: "get",
|
|
42
|
+
path: "/suggest",
|
|
43
|
+
tags: ["Search"],
|
|
44
|
+
summary: "Get search suggestions",
|
|
45
|
+
request: {
|
|
46
|
+
query: z.object({
|
|
47
|
+
prefix: z.string().optional(),
|
|
48
|
+
type: z.string().optional(),
|
|
49
|
+
limit: z.string().optional(),
|
|
50
|
+
}),
|
|
51
|
+
},
|
|
52
|
+
responses: {
|
|
53
|
+
200: {
|
|
54
|
+
content: { "application/json": { schema: z.object({ data: z.array(z.object({ id: z.string(), slug: z.string(), title: z.string().optional() })) }) } },
|
|
55
|
+
description: "Suggestions",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "@hono/zod-openapi";
|
|
2
|
+
|
|
3
|
+
// ─── Error Response ──────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export const ErrorSchema = z.object({
|
|
6
|
+
error: z.object({
|
|
7
|
+
code: z.string().openapi({ example: "VALIDATION_FAILED" }),
|
|
8
|
+
message: z.string().openapi({ example: "cartId: Invalid uuid" }),
|
|
9
|
+
}),
|
|
10
|
+
}).openapi("Error");
|
|
11
|
+
|
|
12
|
+
// ─── Pagination ──────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const PaginationQuerySchema = z.object({
|
|
15
|
+
page: z.string().optional().openapi({ example: "1" }),
|
|
16
|
+
limit: z.string().optional().openapi({ example: "20" }),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const PaginationMetaSchema = z.object({
|
|
20
|
+
page: z.number(),
|
|
21
|
+
limit: z.number(),
|
|
22
|
+
total: z.number().optional(),
|
|
23
|
+
}).openapi("PaginationMeta");
|
|
24
|
+
|
|
25
|
+
// ─── Common Params ───────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export const UuidParamSchema = z.object({
|
|
28
|
+
id: z.uuid().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const IdOrSlugParamSchema = z.object({
|
|
32
|
+
idOrSlug: z.string().min(1).openapi({ example: "my-product" }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ─── Common Responses ────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export const DeletedResponseSchema = z.object({
|
|
38
|
+
data: z.object({ deleted: z.literal(true) }),
|
|
39
|
+
}).openapi("DeletedResponse");
|
|
40
|
+
|
|
41
|
+
export const errorResponses = {
|
|
42
|
+
400: {
|
|
43
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
44
|
+
description: "Business logic error.",
|
|
45
|
+
},
|
|
46
|
+
401: {
|
|
47
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
48
|
+
description: "Authentication required.",
|
|
49
|
+
},
|
|
50
|
+
403: {
|
|
51
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
52
|
+
description: "Insufficient permissions.",
|
|
53
|
+
},
|
|
54
|
+
404: {
|
|
55
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
56
|
+
description: "Resource not found.",
|
|
57
|
+
},
|
|
58
|
+
422: {
|
|
59
|
+
content: { "application/json": { schema: ErrorSchema } },
|
|
60
|
+
description: "Validation error.",
|
|
61
|
+
},
|
|
62
|
+
} as const;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z, createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { ErrorSchema, errorResponses } from "./shared.js";
|
|
3
|
+
|
|
4
|
+
// ─── Request Schema ──────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export const CreateWebhookEndpointBodySchema = z.object({
|
|
7
|
+
url: z.string().url().openapi({ example: "https://example.com/webhooks" }),
|
|
8
|
+
events: z.array(z.string()).openapi({ example: ["order.created", "order.fulfilled"] }),
|
|
9
|
+
secret: z.string().optional().openapi({ example: "whsec_abc123" }),
|
|
10
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
11
|
+
}).openapi("CreateWebhookEndpointRequest");
|
|
12
|
+
|
|
13
|
+
// ─── Route Definitions ──────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const listWebhookEndpointsRoute = createRoute({
|
|
16
|
+
method: "get",
|
|
17
|
+
path: "/",
|
|
18
|
+
tags: ["Webhooks"],
|
|
19
|
+
summary: "List webhook endpoints",
|
|
20
|
+
responses: {
|
|
21
|
+
200: {
|
|
22
|
+
content: { "application/json": { schema: z.object({ data: z.array(z.record(z.string(), z.unknown())) }) } },
|
|
23
|
+
description: "Webhook endpoints",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const deleteWebhookEndpointRoute = createRoute({
|
|
29
|
+
method: "delete",
|
|
30
|
+
path: "/{id}",
|
|
31
|
+
tags: ["Webhooks"],
|
|
32
|
+
summary: "Delete a webhook endpoint",
|
|
33
|
+
request: {
|
|
34
|
+
params: z.object({
|
|
35
|
+
id: z.uuid().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
responses: {
|
|
39
|
+
200: {
|
|
40
|
+
content: { "application/json": { schema: z.object({ data: z.object({ deleted: z.literal(true) }) }) } },
|
|
41
|
+
description: "Webhook endpoint deleted.",
|
|
42
|
+
},
|
|
43
|
+
...errorResponses,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const createWebhookEndpointRoute = createRoute({
|
|
48
|
+
method: "post",
|
|
49
|
+
path: "/",
|
|
50
|
+
tags: ["Webhooks"],
|
|
51
|
+
summary: "Create a webhook endpoint",
|
|
52
|
+
description: "Registers a new webhook endpoint that will receive event notifications.",
|
|
53
|
+
request: {
|
|
54
|
+
body: {
|
|
55
|
+
content: {
|
|
56
|
+
"application/json": { schema: CreateWebhookEndpointBodySchema },
|
|
57
|
+
},
|
|
58
|
+
required: true,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
responses: {
|
|
62
|
+
201: {
|
|
63
|
+
content: { "application/json": { schema: z.object({ data: z.record(z.string(), z.unknown()) }) } },
|
|
64
|
+
description: "Webhook endpoint created.",
|
|
65
|
+
},
|
|
66
|
+
...errorResponses,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { CommerceError } from "../../kernel/errors.js";
|
|
2
|
+
import { mapErrorToStatus } from "../../kernel/error-mapper.js";
|
|
3
|
+
import { toCommerceError } from "../../kernel/errors.js";
|
|
4
|
+
import type { Actor } from "../../auth/types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared Hono environment type for all sub-routers.
|
|
8
|
+
* Matches the Variables set by middleware in the top-level server app.
|
|
9
|
+
*/
|
|
10
|
+
export type AppEnv = {
|
|
11
|
+
Variables: {
|
|
12
|
+
actor: Actor | null;
|
|
13
|
+
requestId: string;
|
|
14
|
+
logger: unknown;
|
|
15
|
+
kernel: unknown;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const MAX_PAGE_LIMIT = 100;
|
|
20
|
+
|
|
21
|
+
export function parsePagination(query: Record<string, string | undefined>): {
|
|
22
|
+
page: number;
|
|
23
|
+
limit: number;
|
|
24
|
+
} {
|
|
25
|
+
const page = Number.parseInt(query.page ?? "1", 10);
|
|
26
|
+
const limit = Number.parseInt(query.limit ?? "20", 10);
|
|
27
|
+
return {
|
|
28
|
+
page: Number.isFinite(page) && page > 0 ? page : 1,
|
|
29
|
+
limit: Math.min(MAX_PAGE_LIMIT, Number.isFinite(limit) && limit > 0 ? limit : 20),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseInclude(value?: string): Set<string> {
|
|
34
|
+
if (!value) return new Set();
|
|
35
|
+
return new Set(
|
|
36
|
+
value
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((item) => item.trim())
|
|
39
|
+
.filter(Boolean),
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Map an error to a safe client response. Internal errors are sanitized
|
|
45
|
+
* to prevent leaking SQL, schema, or stack trace details.
|
|
46
|
+
*/
|
|
47
|
+
export function mapErrorToResponse(error: unknown): { error: { code: string; message: string } } {
|
|
48
|
+
const ce = toCommerceError(error);
|
|
49
|
+
if (ce.code === "INTERNAL_ERROR") {
|
|
50
|
+
// Sanitize internal errors -- do not expose raw messages to clients
|
|
51
|
+
return { error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred." } };
|
|
52
|
+
}
|
|
53
|
+
return { error: { code: ce.code, message: ce.message } };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { mapErrorToStatus };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hono middleware that requires a specific permission on the actor.
|
|
60
|
+
* Returns 401 if no actor, 403 if permission denied.
|
|
61
|
+
* Usage: router.post("/", requirePerm("webhooks:manage"), handler);
|
|
62
|
+
*/
|
|
63
|
+
export function requirePerm(permission: string) {
|
|
64
|
+
return async (c: { get(key: string): unknown; json(data: unknown, status: number): unknown }, next: () => Promise<void>) => {
|
|
65
|
+
const actor = c.get("actor") as { permissions?: string[] } | null;
|
|
66
|
+
if (!actor) {
|
|
67
|
+
return c.json({ error: { code: "UNAUTHORIZED", message: "Authentication required." } }, 401);
|
|
68
|
+
}
|
|
69
|
+
const perms = actor.permissions ?? [];
|
|
70
|
+
if (perms.includes(permission) || perms.includes("*:*")) {
|
|
71
|
+
await next();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// Check resource-level wildcard (e.g., "catalog:*" matches "catalog:create")
|
|
75
|
+
const [resource] = permission.split(":");
|
|
76
|
+
if (resource && perms.includes(`${resource}:*`)) {
|
|
77
|
+
await next();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
return c.json({ error: { code: "FORBIDDEN", message: `Permission '${permission}' is required.` } }, 403);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseSort(
|
|
85
|
+
value?: string,
|
|
86
|
+
):
|
|
87
|
+
| {
|
|
88
|
+
field: "createdAt" | "updatedAt" | "slug";
|
|
89
|
+
direction: "asc" | "desc";
|
|
90
|
+
}
|
|
91
|
+
| undefined {
|
|
92
|
+
if (!value) return undefined;
|
|
93
|
+
const [fieldRaw, directionRaw] = value.split(":");
|
|
94
|
+
const selectedField = fieldRaw ?? "createdAt";
|
|
95
|
+
const field = ["createdAt", "updatedAt", "slug"].includes(selectedField)
|
|
96
|
+
? (selectedField as "createdAt" | "updatedAt" | "slug")
|
|
97
|
+
: "createdAt";
|
|
98
|
+
const direction = directionRaw === "asc" ? "asc" : "desc";
|
|
99
|
+
return { field, direction };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isUUID(value: string): boolean {
|
|
103
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
104
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* webhookRouter() — route builder for external webhook receivers.
|
|
3
|
+
*
|
|
4
|
+
* Unlike router() which uses UC authentication (.auth(), .permission()),
|
|
5
|
+
* webhookRouter() uses HMAC signature verification. This is for routes
|
|
6
|
+
* that receive callbacks from external services (Shopify, WooCommerce,
|
|
7
|
+
* Stripe, BNPL providers) that authenticate via provider-specific signatures.
|
|
8
|
+
*
|
|
9
|
+
* Provides typed access to kernel.services, kernel.database.db, and
|
|
10
|
+
* kernel.logger without casting through `unknown`.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
*
|
|
14
|
+
* import { webhookRouter } from "@unifiedcommerce/core";
|
|
15
|
+
*
|
|
16
|
+
* export function createMyWebhookRoutes(kernel: Kernel) {
|
|
17
|
+
* const { app, services, db, logger } = webhookRouter(kernel);
|
|
18
|
+
*
|
|
19
|
+
* app.post("/product-updated", async (c) => {
|
|
20
|
+
* const body = await c.req.json();
|
|
21
|
+
* await services.catalog.update(body.id, { ... }, systemActor);
|
|
22
|
+
* return c.json({ status: "ok" });
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* return app;
|
|
26
|
+
* }
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Hono } from "hono";
|
|
30
|
+
import type { Kernel } from "../../runtime/kernel.js";
|
|
31
|
+
|
|
32
|
+
export interface WebhookRouterResult {
|
|
33
|
+
/** Raw Hono app — mount routes on this. No UC auth middleware attached. */
|
|
34
|
+
app: Hono;
|
|
35
|
+
/** Kernel services (catalog, inventory, orders, etc.). Typed properly. */
|
|
36
|
+
services: Kernel["services"];
|
|
37
|
+
/** Drizzle database instance for direct queries. */
|
|
38
|
+
db: Kernel["database"]["db"];
|
|
39
|
+
/** Structured Pino logger. */
|
|
40
|
+
logger: Kernel["logger"];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function webhookRouter(kernel: Kernel): WebhookRouterResult {
|
|
44
|
+
return {
|
|
45
|
+
app: new Hono(),
|
|
46
|
+
services: kernel.services,
|
|
47
|
+
db: kernel.database.db,
|
|
48
|
+
logger: kernel.logger,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { CompensationContext, Step } from "./types.js";
|
|
2
|
+
import type { Result } from "../result.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AnyStep erases the output type parameter so that heterogeneous step
|
|
6
|
+
* arrays can be passed to runCompensationChain without variance issues
|
|
7
|
+
* under exactOptionalPropertyTypes.
|
|
8
|
+
*/
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- erases output type for heterogeneous step arrays; unknown breaks contravariance on compensate()
|
|
10
|
+
type AnyStep<TInput> = Step<TInput, any>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Runs a list of steps in order. If any step fails, compensates all
|
|
14
|
+
* previously completed steps in reverse. Steps share the same input
|
|
15
|
+
* object (they may mutate it to enrich downstream steps, following
|
|
16
|
+
* the same pattern established by BeforeHooks).
|
|
17
|
+
*
|
|
18
|
+
* Compensation failures are logged but do not override the original error.
|
|
19
|
+
* A failed compensation is a separate operational concern that requires
|
|
20
|
+
* manual review — it should never mask the root cause returned to the caller.
|
|
21
|
+
*/
|
|
22
|
+
export async function runCompensationChain<TInput>(
|
|
23
|
+
steps: ReadonlyArray<AnyStep<TInput>>,
|
|
24
|
+
input: TInput,
|
|
25
|
+
ctx: CompensationContext,
|
|
26
|
+
): Promise<Result<TInput>> {
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches AnyStep output erasure
|
|
28
|
+
const completed: Array<{ step: AnyStep<TInput>; output: any }> = [];
|
|
29
|
+
|
|
30
|
+
for (const step of steps) {
|
|
31
|
+
const result = await step.run(input, ctx);
|
|
32
|
+
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
ctx.hook.logger.error(
|
|
35
|
+
`Compensation chain failed at step "${step.id}". ` +
|
|
36
|
+
`Running ${completed.length} compensation(s).`,
|
|
37
|
+
{ error: result.error },
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Compensate in reverse order — most recently completed step first
|
|
41
|
+
for (const done of [...completed].reverse()) {
|
|
42
|
+
if (!done.step.compensate) continue;
|
|
43
|
+
try {
|
|
44
|
+
await done.step.compensate(done.output, ctx);
|
|
45
|
+
ctx.hook.logger.info(`Compensated step "${done.step.id}"`);
|
|
46
|
+
} catch (compensateError) {
|
|
47
|
+
ctx.hook.logger.error(
|
|
48
|
+
`Compensation for step "${done.step.id}" failed. Manual review required.`,
|
|
49
|
+
{ compensateError },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
completed.push({ step, output: result.value });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { ok: true, value: input };
|
|
61
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TxContext } from "../database/tx-context.js";
|
|
2
|
+
import type { HookContext } from "../hooks/types.js";
|
|
3
|
+
import type { Result } from "../result.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CompensationContext carries the transaction and hook context into
|
|
7
|
+
* both the run and compensate functions. Steps have access to services,
|
|
8
|
+
* the actor, and the logger through ctx.hook.
|
|
9
|
+
*/
|
|
10
|
+
export interface CompensationContext {
|
|
11
|
+
tx: TxContext | null;
|
|
12
|
+
hook: HookContext;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A Step is one unit of work in a compensation chain.
|
|
17
|
+
*
|
|
18
|
+
* TInput is the data the step receives (typically the shared checkout data object).
|
|
19
|
+
* TOutput is what the step produces. This same value is passed to compensate()
|
|
20
|
+
* so the compensate function has everything it needs to reverse the work.
|
|
21
|
+
*/
|
|
22
|
+
export interface Step<TInput, TOutput> {
|
|
23
|
+
id: string;
|
|
24
|
+
run: (input: TInput, ctx: CompensationContext) => Promise<Result<TOutput>>;
|
|
25
|
+
compensate?: (output: TOutput, ctx: CompensationContext) => Promise<void>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database adapter interface for the commerce engine.
|
|
3
|
+
*
|
|
4
|
+
* Generic defaults to `unknown` so that any driver-specific adapter
|
|
5
|
+
* (postgres-js, PGlite, etc.) can implement it without type conflicts.
|
|
6
|
+
* Internal code narrows to `PluginDb` at the consumption site — see
|
|
7
|
+
* PluginContext in manifest.ts and createHookContext.
|
|
8
|
+
*/
|
|
9
|
+
export interface DatabaseAdapter<TDatabase = unknown, TTransaction = unknown> {
|
|
10
|
+
provider: string;
|
|
11
|
+
db: TDatabase;
|
|
12
|
+
transaction<T>(fn: (tx: TTransaction) => Promise<T>): Promise<T>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DatabaseConnectionFactoryInput {
|
|
16
|
+
adapter: DatabaseAdapter;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createDatabaseConnection(input: DatabaseConnectionFactoryInput): DatabaseAdapter {
|
|
20
|
+
return input.adapter;
|
|
21
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle Database Type Definitions
|
|
3
|
+
*
|
|
4
|
+
* This module provides type-safe, driver-agnostic database access for
|
|
5
|
+
* PostgreSQL repositories.
|
|
6
|
+
*
|
|
7
|
+
* We use `PgDatabase` from `drizzle-orm/pg-core` — the base class that all
|
|
8
|
+
* PostgreSQL drivers extend (postgres-js, pglite, node-postgres, bun-sql).
|
|
9
|
+
* This means:
|
|
10
|
+
*
|
|
11
|
+
* - Repositories accept any PG driver without casts
|
|
12
|
+
* - PGlite in tests and postgres-js in production use the same type
|
|
13
|
+
* - Row types are fully inferred from pgTable schema definitions
|
|
14
|
+
* - No coupling to any specific driver package
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
|
|
18
|
+
import * as schema from "./schema.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Combined schema type for type inference
|
|
22
|
+
*/
|
|
23
|
+
export type Schema = typeof schema;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Driver-agnostic PostgreSQL database instance with full schema type
|
|
27
|
+
* information.
|
|
28
|
+
*
|
|
29
|
+
* Both `PostgresJsDatabase<Schema>` and `PgliteDatabase<Schema>` are
|
|
30
|
+
* assignable to this type, so repositories work identically in production
|
|
31
|
+
* and tests without any casts.
|
|
32
|
+
*/
|
|
33
|
+
export type DrizzleDatabase = PgDatabase<PgQueryResultHKT, Schema>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Transaction type extracted from the database type.
|
|
37
|
+
* Used when operating within a transaction context.
|
|
38
|
+
*
|
|
39
|
+
* This type is derived from the transaction callback parameter:
|
|
40
|
+
* db.transaction(async (tx) => { ... })
|
|
41
|
+
* ^^-- This is DrizzleTx
|
|
42
|
+
*/
|
|
43
|
+
export type DrizzleTx = Parameters<
|
|
44
|
+
Parameters<DrizzleDatabase["transaction"]>[0]
|
|
45
|
+
>[0];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Union type for database or transaction - repositories can accept either.
|
|
49
|
+
* Both have the same query builder interface.
|
|
50
|
+
*/
|
|
51
|
+
export type DbOrTx = DrizzleDatabase | DrizzleTx;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Re-export schema for convenience
|
|
55
|
+
*/
|
|
56
|
+
export { schema };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic database schema management.
|
|
3
|
+
*
|
|
4
|
+
* For npm consumers who don't have access to the raw .ts schema files,
|
|
5
|
+
* this module provides:
|
|
6
|
+
*
|
|
7
|
+
* 1. `getSchemaFiles()` — returns schema module paths for use in drizzle.config.ts
|
|
8
|
+
* 2. `getSchema()` — returns the combined Drizzle schema object
|
|
9
|
+
* 3. `pushSchema()` — programmatic push (creates tables if not exist)
|
|
10
|
+
*
|
|
11
|
+
* Usage in consumer's drizzle.config.ts:
|
|
12
|
+
*
|
|
13
|
+
* import { getSchema } from "@unifiedcommerce/core";
|
|
14
|
+
* import { defineConfig } from "drizzle-kit";
|
|
15
|
+
*
|
|
16
|
+
* export default defineConfig({
|
|
17
|
+
* dialect: "postgresql",
|
|
18
|
+
* schema: getSchema(),
|
|
19
|
+
* dbCredentials: { url: process.env.DATABASE_URL! },
|
|
20
|
+
* });
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { CommerceConfig } from "../../config/types.js";
|
|
24
|
+
import * as schema from "./schema.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns the combined Drizzle schema object with all table definitions.
|
|
28
|
+
* Use this in your own drizzle.config.ts or for programmatic schema inspection.
|
|
29
|
+
*/
|
|
30
|
+
export function getSchema() {
|
|
31
|
+
return schema;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns core schema merged with all plugin schemas from `config.customSchemas[]`.
|
|
36
|
+
* Throws if a plugin table name collides with a core table name.
|
|
37
|
+
*/
|
|
38
|
+
export function buildSchema(config?: CommerceConfig): Record<string, unknown> {
|
|
39
|
+
const merged: Record<string, unknown> = { ...schema };
|
|
40
|
+
|
|
41
|
+
if (!config?.customSchemas?.length) return merged;
|
|
42
|
+
|
|
43
|
+
const coreKeys = new Set(Object.keys(schema));
|
|
44
|
+
|
|
45
|
+
for (const pluginSchema of config.customSchemas) {
|
|
46
|
+
for (const [key, value] of Object.entries(pluginSchema)) {
|
|
47
|
+
if (coreKeys.has(key)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Plugin schema name collision: "${key}" already exists in core schema`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (key in merged) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Plugin schema name collision: "${key}" is defined by multiple plugins`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
merged[key] = value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return merged;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns a list of all schema table names defined by the commerce engine.
|
|
66
|
+
*/
|
|
67
|
+
export function getTableNames(): string[] {
|
|
68
|
+
return Object.entries(schema)
|
|
69
|
+
.filter(
|
|
70
|
+
([_, value]) =>
|
|
71
|
+
value != null &&
|
|
72
|
+
typeof value === "object" &&
|
|
73
|
+
"getSQL" in (value as object),
|
|
74
|
+
)
|
|
75
|
+
.map(([key]) => key);
|
|
76
|
+
}
|