@unifiedcommerce/core 0.2.0 → 0.2.1

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 (186) hide show
  1. package/package.json +2 -1
  2. package/src/adapters/console-email.ts +43 -0
  3. package/src/auth/access.ts +187 -0
  4. package/src/auth/auth-schema.ts +139 -0
  5. package/src/auth/middleware.ts +161 -0
  6. package/src/auth/org.ts +41 -0
  7. package/src/auth/permissions.ts +28 -0
  8. package/src/auth/setup.ts +171 -0
  9. package/src/auth/system-actor.ts +19 -0
  10. package/src/auth/types.ts +10 -0
  11. package/src/config/defaults.ts +82 -0
  12. package/src/config/define-config.ts +53 -0
  13. package/src/config/types.ts +301 -0
  14. package/src/generated/plugin-capabilities.d.ts +20 -0
  15. package/src/generated/plugin-manifest.ts +23 -0
  16. package/src/generated/plugin-repositories.d.ts +20 -0
  17. package/src/hooks/checkout-completion.ts +262 -0
  18. package/src/hooks/checkout.ts +677 -0
  19. package/src/hooks/order-emails.ts +62 -0
  20. package/src/index.ts +215 -0
  21. package/src/interfaces/mcp/agent-prompt.ts +174 -0
  22. package/src/interfaces/mcp/context-enrichment.ts +177 -0
  23. package/src/interfaces/mcp/server.ts +47 -0
  24. package/src/interfaces/mcp/tool-builder.ts +261 -0
  25. package/src/interfaces/mcp/tools/analytics.ts +76 -0
  26. package/src/interfaces/mcp/tools/cart.ts +57 -0
  27. package/src/interfaces/mcp/tools/catalog.ts +299 -0
  28. package/src/interfaces/mcp/tools/index.ts +22 -0
  29. package/src/interfaces/mcp/tools/inventory.ts +161 -0
  30. package/src/interfaces/mcp/tools/orders.ts +104 -0
  31. package/src/interfaces/mcp/tools/pricing.ts +94 -0
  32. package/src/interfaces/mcp/tools/promotions.ts +106 -0
  33. package/src/interfaces/mcp/tools/registry.ts +101 -0
  34. package/src/interfaces/mcp/tools/search.ts +42 -0
  35. package/src/interfaces/mcp/tools/webhooks.ts +48 -0
  36. package/src/interfaces/mcp/transport.ts +128 -0
  37. package/src/interfaces/rest/customer-portal.ts +299 -0
  38. package/src/interfaces/rest/index.ts +74 -0
  39. package/src/interfaces/rest/router.ts +333 -0
  40. package/src/interfaces/rest/routes/admin-jobs.ts +58 -0
  41. package/src/interfaces/rest/routes/audit.ts +50 -0
  42. package/src/interfaces/rest/routes/carts.ts +89 -0
  43. package/src/interfaces/rest/routes/catalog.ts +493 -0
  44. package/src/interfaces/rest/routes/checkout.ts +284 -0
  45. package/src/interfaces/rest/routes/inventory.ts +70 -0
  46. package/src/interfaces/rest/routes/media.ts +86 -0
  47. package/src/interfaces/rest/routes/orders.ts +78 -0
  48. package/src/interfaces/rest/routes/payments.ts +60 -0
  49. package/src/interfaces/rest/routes/pricing.ts +57 -0
  50. package/src/interfaces/rest/routes/promotions.ts +93 -0
  51. package/src/interfaces/rest/routes/search.ts +71 -0
  52. package/src/interfaces/rest/routes/webhooks.ts +46 -0
  53. package/src/interfaces/rest/schemas/admin-jobs.ts +40 -0
  54. package/src/interfaces/rest/schemas/audit.ts +46 -0
  55. package/src/interfaces/rest/schemas/carts.ts +125 -0
  56. package/src/interfaces/rest/schemas/catalog.ts +450 -0
  57. package/src/interfaces/rest/schemas/checkout.ts +66 -0
  58. package/src/interfaces/rest/schemas/customer-portal.ts +195 -0
  59. package/src/interfaces/rest/schemas/inventory.ts +138 -0
  60. package/src/interfaces/rest/schemas/media.ts +75 -0
  61. package/src/interfaces/rest/schemas/orders.ts +104 -0
  62. package/src/interfaces/rest/schemas/pricing.ts +80 -0
  63. package/src/interfaces/rest/schemas/promotions.ts +110 -0
  64. package/src/interfaces/rest/schemas/responses.ts +85 -0
  65. package/src/interfaces/rest/schemas/search.ts +58 -0
  66. package/src/interfaces/rest/schemas/shared.ts +62 -0
  67. package/src/interfaces/rest/schemas/webhooks.ts +68 -0
  68. package/src/interfaces/rest/utils.ts +104 -0
  69. package/src/interfaces/rest/webhook-router.ts +50 -0
  70. package/src/kernel/compensation/executor.ts +61 -0
  71. package/src/kernel/compensation/types.ts +26 -0
  72. package/src/kernel/database/adapter.ts +21 -0
  73. package/src/kernel/database/drizzle-db.ts +56 -0
  74. package/src/kernel/database/migrate.ts +76 -0
  75. package/src/kernel/database/plugin-types.ts +34 -0
  76. package/src/kernel/database/schema.ts +49 -0
  77. package/src/kernel/database/scoped-db.ts +68 -0
  78. package/src/kernel/database/tx-context.ts +46 -0
  79. package/src/kernel/error-mapper.ts +15 -0
  80. package/src/kernel/errors.ts +89 -0
  81. package/src/kernel/factory/repository-factory.ts +244 -0
  82. package/src/kernel/hooks/create-context.ts +43 -0
  83. package/src/kernel/hooks/executor.ts +88 -0
  84. package/src/kernel/hooks/registry.ts +74 -0
  85. package/src/kernel/hooks/types.ts +52 -0
  86. package/src/kernel/http-error.ts +44 -0
  87. package/src/kernel/jobs/adapter.ts +36 -0
  88. package/src/kernel/jobs/drizzle-adapter.ts +58 -0
  89. package/src/kernel/jobs/runner.ts +153 -0
  90. package/src/kernel/jobs/schema.ts +46 -0
  91. package/src/kernel/jobs/types.ts +30 -0
  92. package/src/kernel/local-api.ts +187 -0
  93. package/src/kernel/plugin/manifest.ts +271 -0
  94. package/src/kernel/query/executor.ts +184 -0
  95. package/src/kernel/query/registry.ts +46 -0
  96. package/src/kernel/result.ts +33 -0
  97. package/src/kernel/schema/extra-columns.ts +37 -0
  98. package/src/kernel/service-registry.ts +76 -0
  99. package/src/kernel/service-timing.ts +89 -0
  100. package/src/kernel/state-machine/machine.ts +101 -0
  101. package/src/modules/analytics/drizzle-adapter.ts +426 -0
  102. package/src/modules/analytics/hooks.ts +11 -0
  103. package/src/modules/analytics/models.ts +125 -0
  104. package/src/modules/analytics/repository/index.ts +6 -0
  105. package/src/modules/analytics/service.ts +245 -0
  106. package/src/modules/analytics/types.ts +180 -0
  107. package/src/modules/audit/hooks.ts +78 -0
  108. package/src/modules/audit/schema.ts +33 -0
  109. package/src/modules/audit/service.ts +151 -0
  110. package/src/modules/cart/access.ts +27 -0
  111. package/src/modules/cart/matcher.ts +26 -0
  112. package/src/modules/cart/repository/index.ts +234 -0
  113. package/src/modules/cart/schema.ts +42 -0
  114. package/src/modules/cart/schemas.ts +38 -0
  115. package/src/modules/cart/service.ts +541 -0
  116. package/src/modules/catalog/repository/index.ts +772 -0
  117. package/src/modules/catalog/schema.ts +203 -0
  118. package/src/modules/catalog/schemas.ts +104 -0
  119. package/src/modules/catalog/service.ts +1544 -0
  120. package/src/modules/customers/repository/index.ts +327 -0
  121. package/src/modules/customers/schema.ts +64 -0
  122. package/src/modules/customers/service.ts +171 -0
  123. package/src/modules/fulfillment/repository/index.ts +426 -0
  124. package/src/modules/fulfillment/schema.ts +101 -0
  125. package/src/modules/fulfillment/service.ts +555 -0
  126. package/src/modules/fulfillment/types.ts +59 -0
  127. package/src/modules/inventory/repository/index.ts +509 -0
  128. package/src/modules/inventory/schema.ts +94 -0
  129. package/src/modules/inventory/schemas.ts +38 -0
  130. package/src/modules/inventory/service.ts +490 -0
  131. package/src/modules/media/adapter.ts +17 -0
  132. package/src/modules/media/repository/index.ts +274 -0
  133. package/src/modules/media/schema.ts +41 -0
  134. package/src/modules/media/service.ts +151 -0
  135. package/src/modules/orders/repository/index.ts +287 -0
  136. package/src/modules/orders/schema.ts +66 -0
  137. package/src/modules/orders/service.ts +619 -0
  138. package/src/modules/orders/stale-order-cleanup.ts +76 -0
  139. package/src/modules/organization/service.ts +191 -0
  140. package/src/modules/payments/adapter.ts +47 -0
  141. package/src/modules/payments/repository/index.ts +6 -0
  142. package/src/modules/payments/service.ts +107 -0
  143. package/src/modules/pricing/repository/index.ts +291 -0
  144. package/src/modules/pricing/schema.ts +71 -0
  145. package/src/modules/pricing/schemas.ts +38 -0
  146. package/src/modules/pricing/service.ts +494 -0
  147. package/src/modules/promotions/repository/index.ts +325 -0
  148. package/src/modules/promotions/schema.ts +62 -0
  149. package/src/modules/promotions/schemas.ts +38 -0
  150. package/src/modules/promotions/service.ts +598 -0
  151. package/src/modules/search/adapter.ts +57 -0
  152. package/src/modules/search/hooks.ts +12 -0
  153. package/src/modules/search/repository/index.ts +6 -0
  154. package/src/modules/search/service.ts +315 -0
  155. package/src/modules/shipping/calculator.ts +188 -0
  156. package/src/modules/shipping/repository/index.ts +6 -0
  157. package/src/modules/shipping/service.ts +51 -0
  158. package/src/modules/tax/adapter.ts +60 -0
  159. package/src/modules/tax/repository/index.ts +6 -0
  160. package/src/modules/tax/service.ts +53 -0
  161. package/src/modules/webhooks/hook.ts +34 -0
  162. package/src/modules/webhooks/repository/index.ts +278 -0
  163. package/src/modules/webhooks/schema.ts +56 -0
  164. package/src/modules/webhooks/service.ts +117 -0
  165. package/src/modules/webhooks/signing.ts +6 -0
  166. package/src/modules/webhooks/ssrf-guard.ts +71 -0
  167. package/src/modules/webhooks/tasks.ts +52 -0
  168. package/src/modules/webhooks/worker.ts +134 -0
  169. package/src/runtime/commerce.ts +145 -0
  170. package/src/runtime/kernel.ts +426 -0
  171. package/src/runtime/logger.ts +36 -0
  172. package/src/runtime/server.ts +355 -0
  173. package/src/runtime/shutdown.ts +43 -0
  174. package/src/test-utils/create-pglite-adapter.ts +129 -0
  175. package/src/test-utils/create-plugin-test-app.ts +128 -0
  176. package/src/test-utils/create-repository-test-harness.ts +16 -0
  177. package/src/test-utils/create-test-config.ts +190 -0
  178. package/src/test-utils/create-test-kernel.ts +7 -0
  179. package/src/test-utils/create-test-plugin-context.ts +75 -0
  180. package/src/test-utils/rest-api-test-utils.ts +265 -0
  181. package/src/test-utils/test-actors.ts +62 -0
  182. package/src/test-utils/typed-hooks.ts +54 -0
  183. package/src/types/commerce-types.ts +34 -0
  184. package/src/utils/id.ts +3 -0
  185. package/src/utils/logger.ts +18 -0
  186. package/src/utils/pagination.ts +22 -0
@@ -0,0 +1,36 @@
1
+ import pino from "pino";
2
+ import type { CommerceConfig } from "../config/types.js";
3
+
4
+ export type Logger = pino.Logger;
5
+
6
+ /**
7
+ * Create a structured JSON logger for the commerce engine.
8
+ *
9
+ * Automatically redacts sensitive fields (authorization, password, token, etc.)
10
+ * from all log output. Uses Pino for high-performance, newline-delimited JSON.
11
+ */
12
+ export function createLogger(config: CommerceConfig): Logger {
13
+ return pino({
14
+ level: config.logLevel ?? "info",
15
+ redact: {
16
+ paths: [
17
+ "req.headers.authorization",
18
+ "req.headers.cookie",
19
+ "*.password",
20
+ "*.secret",
21
+ "*.apiKey",
22
+ "*.creditCard",
23
+ "*.token",
24
+ "*.apiToken",
25
+ ],
26
+ censor: "[REDACTED]",
27
+ },
28
+ serializers: {
29
+ req: pino.stdSerializers.req,
30
+ err: pino.stdSerializers.err,
31
+ },
32
+ formatters: {
33
+ level: (label) => ({ level: label }),
34
+ },
35
+ });
36
+ }
@@ -0,0 +1,355 @@
1
+ import { Hono } from "hono";
2
+ import { OpenAPIHono } from "@hono/zod-openapi";
3
+ import { cors } from "hono/cors";
4
+ import { csrf } from "hono/csrf";
5
+ import { bodyLimit } from "hono/body-limit";
6
+ import { rateLimiter } from "hono-rate-limiter";
7
+ import type { Actor } from "../auth/types.js";
8
+ import type { AuthInstance } from "../auth/setup.js";
9
+ import type { CommerceConfig } from "../config/types.js";
10
+ import { authMiddleware } from "../auth/middleware.js";
11
+ import { createMCPHandler } from "../interfaces/mcp/transport.js";
12
+ import { createRestRoutes } from "../interfaces/rest/index.js";
13
+ import { createCustomerPortalRoutes } from "../interfaces/rest/customer-portal.js";
14
+ import { createKernel } from "./kernel.js";
15
+ import { ensureDefaultOrg } from "../auth/org.js";
16
+ import { createLogger, type Logger } from "./logger.js";
17
+ import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
18
+ import { createCommerce, type CommerceInstance } from "./commerce.js";
19
+
20
+ type ServerEnv = {
21
+ Variables: {
22
+ auth: AuthInstance;
23
+ actor: Actor | null;
24
+ requestId: string;
25
+ logger: Logger;
26
+ };
27
+ };
28
+
29
+ /**
30
+ * Create a full HTTP server (Hono) with all REST routes, auth, and middleware.
31
+ *
32
+ * For server-side frameworks (Next.js, TanStack Start, SvelteKit), use
33
+ * `createCommerce()` instead — it gives you a local API without HTTP overhead.
34
+ */
35
+ export async function createServer(config: CommerceConfig) {
36
+ const commerce = await createCommerce(config);
37
+ const { kernel, auth, logger } = commerce;
38
+ const isProdEnv = process.env.NODE_ENV === "production";
39
+
40
+
41
+ console.log("config", config);
42
+ console.log("isProdEnv", isProdEnv);
43
+
44
+ const app = new OpenAPIHono<ServerEnv>({
45
+ defaultHook: (result, c) => {
46
+ if (!result.success) {
47
+ return c.json({
48
+ error: {
49
+ code: "VALIDATION_FAILED",
50
+ message: isProdEnv
51
+ ? "Invalid input."
52
+ : result.error.issues
53
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
54
+ .join("; "),
55
+ },
56
+ }, 422);
57
+ }
58
+ },
59
+ });
60
+
61
+ // ─── Security Guards ──────────────────────────────────────────────
62
+ if (config.auth?.enableDevKey && process.env.NODE_ENV === "production") {
63
+ throw new Error(
64
+ "FATAL: Dev key MUST NOT be enabled in production. " +
65
+ "Set auth.enableDevKey: false in production config.",
66
+ );
67
+ }
68
+ if (config.auth?.enableDevKey && process.env.NODE_ENV !== "development") {
69
+ logger.warn(
70
+ "Dev key is enabled outside NODE_ENV=development. " +
71
+ "Set auth.enableDevKey: false in production config to disable the dev backdoor.",
72
+ );
73
+ }
74
+
75
+ // ─── Process Crash Handlers (F4) ─────────────────────────────────────
76
+ process.on("unhandledRejection", (reason) => {
77
+ logger.fatal({ err: reason }, "unhandled promise rejection -- exiting");
78
+ process.exit(1);
79
+ });
80
+
81
+ process.on("uncaughtException", (err) => {
82
+ logger.fatal({ err }, "uncaught exception -- exiting");
83
+ process.exit(1);
84
+ });
85
+
86
+ // ─── Security Response Headers ──────────────────────────────────────
87
+ app.use("*", async (c, next) => {
88
+ c.header("X-Content-Type-Options", "nosniff");
89
+ c.header("X-Frame-Options", "DENY");
90
+ c.header("Referrer-Policy", "strict-origin-when-cross-origin");
91
+ c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
92
+ if (isProdEnv) {
93
+ c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
94
+ }
95
+ await next();
96
+ });
97
+
98
+ // ─── Request ID + Logging (F2, F12) ──────────────────────────────────
99
+ app.use("*", async (c, next) => {
100
+ const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
101
+ c.header("x-request-id", requestId);
102
+ c.set("requestId", requestId);
103
+ const child = logger.child({ requestId, method: c.req.method, path: c.req.path });
104
+ c.set("logger", child);
105
+
106
+ const start = performance.now();
107
+ await next();
108
+
109
+ // Sanitize ZodError responses in production.
110
+ // @hono/zod-openapi returns { success: false, error: { name: "ZodError" } }
111
+ // which leaks schema details. The library does not respect defaultHook for
112
+ // this format, so we intercept at the response level.
113
+ if (isProdEnv && c.res.status >= 400 && c.res.status < 500) {
114
+ const ct = c.res.headers.get("content-type");
115
+ if (ct?.includes("json")) {
116
+ try {
117
+ const body = await c.res.clone().json();
118
+ if (body?.error?.name === "ZodError") {
119
+ c.res = new Response(
120
+ JSON.stringify({ error: { code: "VALIDATION_FAILED", message: "Invalid input." } }),
121
+ { status: 422, headers: { "content-type": "application/json" } },
122
+ );
123
+ }
124
+ } catch { /* non-parseable, skip */ }
125
+ }
126
+ }
127
+
128
+ const duration = Math.round(performance.now() - start);
129
+
130
+ child.info({ status: c.res.status, durationMs: duration }, "request completed");
131
+ });
132
+
133
+ // ─── CORS (hardened by default) ──────────────────────────────────────
134
+ const trustedOrigins = config.auth?.trustedOrigins ?? [];
135
+ app.use("*", cors({
136
+ origin: trustedOrigins.length > 0
137
+ ? trustedOrigins
138
+ : (process.env.NODE_ENV === "production" ? [] : ["http://localhost:*"]),
139
+ credentials: true,
140
+ allowMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
141
+ allowHeaders: ["Content-Type", "Authorization", "x-api-key", "x-request-id"],
142
+ maxAge: 86400,
143
+ }));
144
+
145
+ // ─── CSRF Protection (F14) ──────────────────────────────────────────
146
+ app.use("/api/*", csrf({
147
+ origin: trustedOrigins.length > 0
148
+ ? trustedOrigins
149
+ : (process.env.NODE_ENV === "production" ? [] : ["http://localhost:*"]),
150
+ }));
151
+
152
+ // ─── Body Size Limit (F6) ──────────────────────────────────────────
153
+ app.use("*", bodyLimit({
154
+ maxSize: 1024 * 1024, // 1 MB default
155
+ onError: (c) => c.json({
156
+ error: { code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds 1MB limit." },
157
+ }, 413),
158
+ }));
159
+
160
+ // ─── Rate Limiting (F1) ──────────────────────────────────────────────
161
+ // Trust X-Forwarded-For ONLY from a known reverse proxy IP.
162
+ // Set TRUSTED_PROXY_IP env var to the proxy's IP (e.g., "127.0.0.1").
163
+ const trustedProxyIp = process.env.TRUSTED_PROXY_IP;
164
+ const getClientIp = (c: { req: { raw: unknown; header: (name: string) => string | undefined } }): string => {
165
+ const raw = c.req.raw as { socket?: { remoteAddress?: string } };
166
+ const remoteAddress = raw.socket?.remoteAddress;
167
+ // Only trust X-Forwarded-For when the direct connection is from a trusted proxy
168
+ if (trustedProxyIp && remoteAddress === trustedProxyIp) {
169
+ const xff = c.req.header("x-forwarded-for")?.split(",")[0]?.trim();
170
+ if (xff) return xff;
171
+ }
172
+ return remoteAddress ?? "unknown";
173
+ };
174
+ const keyGenerator = getClientIp;
175
+
176
+ app.use("/api/auth/*", rateLimiter({
177
+ windowMs: 60 * 1000,
178
+ limit: config.rateLimits?.auth ?? 10,
179
+ keyGenerator,
180
+ }));
181
+
182
+ app.use("/api/checkout", rateLimiter({
183
+ windowMs: 60 * 1000,
184
+ limit: config.rateLimits?.checkout ?? 5,
185
+ keyGenerator,
186
+ }));
187
+
188
+ app.use("/api/*", rateLimiter({
189
+ windowMs: 60 * 1000,
190
+ limit: config.rateLimits?.api ?? 100,
191
+ keyGenerator,
192
+ }));
193
+
194
+ // ─── Custom Middleware ──────────────────────────────────────────────
195
+ if (config.middleware) {
196
+ for (const middleware of config.middleware) {
197
+ app.use("*", middleware);
198
+ }
199
+ }
200
+
201
+ // ─── Auth ──────────────────────────────────────────────────────────
202
+ app.use("/api/auth/*", async (c, next) => {
203
+ if (c.req.path.startsWith("/api/auth/pos/")) {
204
+ await next();
205
+ return;
206
+ }
207
+ return auth.handler(c.req.raw);
208
+ });
209
+
210
+ app.use("*", authMiddleware(auth, config));
211
+ app.use("*", async (c, next) => {
212
+ c.set("auth", auth);
213
+ await next();
214
+ });
215
+
216
+ // ─── Global Error Handler ─────────────────────────────────────────
217
+ // Sanitize errors in production — no stack traces, no class names, no schema details
218
+ const isProd = process.env.NODE_ENV === "production";
219
+ app.onError((err, c) => {
220
+ // Catch ZodError from @hono/zod-openapi validation (bypasses defaultHook)
221
+ if (err.constructor?.name === "ZodError" || "issues" in err) {
222
+ if (isProd) {
223
+ return c.json(
224
+ { error: { code: "VALIDATION_FAILED", message: "Invalid input." } },
225
+ 422,
226
+ );
227
+ }
228
+ const issues = (err as { issues?: Array<{ path: string[]; message: string }> }).issues ?? [];
229
+ return c.json(
230
+ {
231
+ error: {
232
+ code: "VALIDATION_FAILED",
233
+ message: issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
234
+ },
235
+ },
236
+ 422,
237
+ );
238
+ }
239
+
240
+ if (isProd) {
241
+ logger.error({ err }, "unhandled request error");
242
+ return c.json(
243
+ { error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred." } },
244
+ 500,
245
+ );
246
+ }
247
+ return c.json(
248
+ { error: { code: "INTERNAL_ERROR", message: err.message } },
249
+ 500,
250
+ );
251
+ });
252
+
253
+ // ─── Routes ──────────────────────────────────────────────────────────
254
+ app.route("/api", createRestRoutes(kernel));
255
+ app.route("/mcp", createMCPHandler(kernel));
256
+ app.route("/api/me", createCustomerPortalRoutes(kernel));
257
+
258
+ if (config.routes) {
259
+ config.routes(app, kernel);
260
+ }
261
+
262
+ // OpenAPI spec — disabled in production unless explicitly enabled
263
+ const exposeSpec = config.exposeOpenApiSpec ?? !isProd;
264
+ if (exposeSpec) {
265
+ app.doc("/api/doc", {
266
+ openapi: "3.0.0",
267
+ info: {
268
+ title: "UnifiedCommerce API",
269
+ version: config.version ?? "0.0.1",
270
+ description: "Headless commerce engine REST API. Includes core and plugin endpoints.",
271
+ },
272
+ });
273
+ } else {
274
+ app.get("/api/doc", (c) => c.json({ error: { code: "NOT_FOUND", message: "Not found." } }, 404));
275
+ }
276
+
277
+ // ─── Job Queue ───────────────────────────────────────────────────────
278
+ // Background job processing: webhook delivery, scheduled orders, email tasks.
279
+ //
280
+ // Three runner strategies (inspired by Payload CMS):
281
+ // 1. autorun: in-process polling (long-running servers)
282
+ // 2. GET /api/jobs/run: cron endpoint (serverless — Vercel, Cloudflare)
283
+ // 3. runPendingJobs() export (custom worker process)
284
+
285
+ const { runPendingJobs } = await import("../kernel/jobs/runner.js");
286
+ const taskMap = new Map<string, unknown>();
287
+ for (const task of config.jobs?.tasks ?? []) {
288
+ const t = task as { slug?: string; name?: string };
289
+ taskMap.set(t.slug ?? t.name ?? "", task);
290
+ }
291
+
292
+ const runJobs = async (queue?: string, limit?: number) => {
293
+ return runPendingJobs({
294
+ db: kernel.database.db as DrizzleDatabase,
295
+ tasks: taskMap as Parameters<typeof runPendingJobs>[0]["tasks"],
296
+ queue: queue ?? "default",
297
+ limit: limit ?? 10,
298
+ logger: {
299
+ info: (msg: string, data?: unknown) => logger.info(data != null ? { data } : {}, msg),
300
+ warn: (msg: string, data?: unknown) => logger.warn(data != null ? { data } : {}, msg),
301
+ error: (msg: string, data?: unknown) => logger.error(data != null ? { data } : {}, msg),
302
+ },
303
+ services: kernel.services as Parameters<typeof runPendingJobs>[0]["services"],
304
+ });
305
+ };
306
+
307
+ // Strategy 1: Built-in cron endpoint for serverless deployments.
308
+ // Point Vercel Cron or Cloudflare Cron Trigger at GET /api/jobs/run
309
+ // Optional query params: ?queue=emails&limit=20
310
+ app.get("/api/jobs/run", async (c) => {
311
+ // Always require admin — cron triggers must authenticate
312
+ const actor = c.get("actor") as { permissions?: string[] } | null;
313
+ if (!actor?.permissions?.includes("*:*")) {
314
+ return c.json({ error: { code: "FORBIDDEN", message: "Job runner requires admin access" } }, 403);
315
+ }
316
+
317
+ const queue = c.req.query("queue") ?? "default";
318
+ const limit = parseInt(c.req.query("limit") ?? "10", 10);
319
+
320
+ try {
321
+ const result = await runJobs(queue, limit);
322
+ return c.json({ data: result });
323
+ } catch (err) {
324
+ logger.error({ err }, "Job runner endpoint failed");
325
+ return c.json({ error: { code: "INTERNAL_ERROR", message: "Job processing failed" } }, 500);
326
+ }
327
+ });
328
+
329
+ // Strategy 2: In-process polling for long-running servers (ECS, Cloud Run, Docker).
330
+ // Enable via config.jobs.autorun.enabled = true
331
+ if (config.jobs?.autorun?.enabled) {
332
+ const intervalMs = config.jobs.autorun.intervalMs ?? 10_000;
333
+
334
+ const jobInterval = setInterval(async () => {
335
+ try {
336
+ await runJobs();
337
+ } catch (err) {
338
+ logger.error({ err }, "Job runner iteration failed");
339
+ }
340
+ }, intervalMs);
341
+
342
+ const cleanup = () => clearInterval(jobInterval);
343
+ process.on("SIGTERM", cleanup);
344
+ process.on("SIGINT", cleanup);
345
+
346
+ logger.info({ intervalMs }, "Job queue autorun started (in-process polling)");
347
+ } else {
348
+ logger.info(
349
+ "Job queue autorun disabled. Use GET /api/jobs/run for serverless cron, " +
350
+ "or set config.jobs.autorun.enabled = true for in-process polling.",
351
+ );
352
+ }
353
+
354
+ return { app, kernel, logger, commerce };
355
+ }
@@ -0,0 +1,43 @@
1
+ import type { Logger } from "./logger.js";
2
+
3
+ /**
4
+ * Sets up graceful shutdown handlers for SIGTERM and SIGINT.
5
+ *
6
+ * On signal:
7
+ * 1. Stops accepting new connections
8
+ * 2. Runs cleanup (close DB pool, flush logs)
9
+ * 3. Force-exits after timeout if cleanup hangs
10
+ */
11
+ export function setupGracefulShutdown(opts: {
12
+ cleanup: () => Promise<void>;
13
+ logger: Logger;
14
+ timeoutMs?: number;
15
+ }): void {
16
+ const { cleanup, logger, timeoutMs = 30_000 } = opts;
17
+ let isShuttingDown = false;
18
+
19
+ async function shutdown(signal: string) {
20
+ if (isShuttingDown) return;
21
+ isShuttingDown = true;
22
+
23
+ logger.info({ signal }, "shutdown signal received, draining connections");
24
+
25
+ const forceTimer = setTimeout(() => {
26
+ logger.error("shutdown timeout exceeded, forcing exit");
27
+ process.exit(1);
28
+ }, timeoutMs);
29
+ forceTimer.unref();
30
+
31
+ try {
32
+ await cleanup();
33
+ logger.info("graceful shutdown complete");
34
+ process.exit(0);
35
+ } catch (err) {
36
+ logger.error({ err }, "error during shutdown");
37
+ process.exit(1);
38
+ }
39
+ }
40
+
41
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
42
+ process.on("SIGINT", () => shutdown("SIGINT"));
43
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * PGlite-backed test adapter for real PostgreSQL behavior in tests.
3
+ *
4
+ * Creates an in-memory PGlite (WASM PostgreSQL) instance, pushes the
5
+ * core Drizzle schema programmatically via drizzle-kit/api, and provides
6
+ * a cleanup function to reset data between tests.
7
+ *
8
+ * Benefits over in-memory repository doubles:
9
+ * - Real SQL execution (Drizzle query generation)
10
+ * - PostgreSQL type coercion and constraint enforcement
11
+ * - Real transaction rollback semantics
12
+ * - Production parity for query edge cases
13
+ */
14
+
15
+ import { PGlite } from "@electric-sql/pglite";
16
+ import { drizzle } from "drizzle-orm/pglite";
17
+ import { sql } from "drizzle-orm";
18
+ import { createRequire } from "node:module";
19
+ import type { DatabaseAdapter } from "../kernel/database/adapter.js";
20
+ import { getSchema } from "../kernel/database/migrate.js";
21
+ import { ensureDefaultOrg } from "../auth/org.js";
22
+
23
+ // Single barrel import --- includes all core modules + auth tables
24
+ import * as fullSchema from "../kernel/database/schema.js";
25
+ import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
26
+
27
+ // drizzle-kit/api uses CJS internally; createRequire provides ESM compat.
28
+ const require = createRequire(import.meta.url);
29
+
30
+ /**
31
+ * Pushes the core Drizzle schema to the database using drizzle-kit/api.
32
+ *
33
+ * drizzle-kit introspects the live database via information_schema, diffs
34
+ * against the pgTable definitions, and generates the minimal DDL needed.
35
+ * Typed as DrizzleDatabase (our concrete schema type) to avoid the
36
+ * PGlite → PgDatabase<HKT> invariance cast — drizzle-kit accepts any
37
+ * Drizzle instance at runtime regardless of the schema generic.
38
+ */
39
+ async function pushCoreSchema(db: DrizzleDatabase): Promise<void> {
40
+ const coreSchema = getSchema();
41
+ const drizzleKit = require("drizzle-kit/api") as {
42
+ pushSchema(
43
+ imports: Record<string, unknown>,
44
+ drizzleInstance: DrizzleDatabase,
45
+ ): Promise<{ apply: () => Promise<void> }>;
46
+ };
47
+ const { apply } = await drizzleKit.pushSchema(coreSchema, db);
48
+ await apply();
49
+ }
50
+
51
+ /**
52
+ * Creates a PGlite-backed database adapter for testing.
53
+ *
54
+ * Each call creates a new isolated PGlite instance with its own
55
+ * in-memory database. Core schema is pushed once during initialization.
56
+ *
57
+ * @returns A promise resolving to an object containing:
58
+ * - adapter: The DatabaseAdapter for use with createKernel
59
+ * - db: The Drizzle ORM instance for direct queries
60
+ * - cleanup: Function to truncate all tables (call between tests)
61
+ */
62
+ export async function createPGliteTestAdapter(): Promise<{
63
+ adapter: DatabaseAdapter;
64
+ db: DrizzleDatabase;
65
+ cleanup: () => Promise<void>;
66
+ }> {
67
+ // Create in-memory PGlite instance
68
+ const pg = new PGlite();
69
+
70
+ // Wrap with Drizzle ORM first (pushSchema needs the Drizzle instance)
71
+ const db = drizzle(pg, { schema: fullSchema });
72
+
73
+ // Push core schema via drizzle-kit/api (no migration files needed)
74
+ // PgliteDatabase<Schema> and DrizzleDatabase share the same Schema type;
75
+ // the HKT parameter differs (PgliteQueryResultHKT vs PgQueryResultHKT)
76
+ // but drizzle-kit's pushSchema doesn't use it at runtime.
77
+ await pushCoreSchema(db as DrizzleDatabase);
78
+
79
+ // Ensure the default organization exists for all tests
80
+ await ensureDefaultOrg(db);
81
+
82
+ /**
83
+ * PGlite-compatible transaction wrapper.
84
+ *
85
+ * Drizzle's transaction() method can hang with PGlite due to how
86
+ * the adapter manages transaction state. This implementation uses
87
+ * manual BEGIN/COMMIT control which works more reliably.
88
+ */
89
+ async function transaction<T>(fn: (tx: unknown) => Promise<T>): Promise<T> {
90
+ await pg.exec("BEGIN");
91
+ try {
92
+ const result = await fn(db);
93
+ await pg.exec("COMMIT");
94
+ return result;
95
+ } catch (error) {
96
+ await pg.exec("ROLLBACK");
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ const adapter: DatabaseAdapter = {
102
+ provider: "postgresql",
103
+ db,
104
+ transaction,
105
+ };
106
+
107
+ /**
108
+ * Cleanup function to reset data between tests.
109
+ * Truncates all tables in reverse dependency order with CASCADE.
110
+ */
111
+ async function cleanup(): Promise<void> {
112
+ await db.execute(sql`
113
+ DO $$ DECLARE
114
+ r RECORD;
115
+ BEGIN
116
+ FOR r IN (
117
+ SELECT tablename FROM pg_tables
118
+ WHERE schemaname = 'public'
119
+ ) LOOP
120
+ EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
121
+ END LOOP;
122
+ END $$;
123
+ `);
124
+ // Re-insert default org after truncation (CASCADE wipes it)
125
+ await ensureDefaultOrg(db);
126
+ }
127
+
128
+ return { adapter, db, cleanup };
129
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * One-call plugin E2E test setup.
3
+ *
4
+ * Boots a PGlite kernel (or real PG if overridden), programmatically pushes
5
+ * the merged schema (core + plugin tables) via drizzle-kit/api, mounts the
6
+ * test actor middleware on an OpenAPIHono instance, and registers all plugin
7
+ * routes --- matching the production server.ts boot sequence.
8
+ *
9
+ * Usage:
10
+ * import { createPluginTestApp, jsonHeaders, testAdminActor } from "@unifiedcommerce/core";
11
+ * const { app } = await createPluginTestApp(myPlugin());
12
+ * const res = await app.request("/api/my-route", {
13
+ * method: "POST",
14
+ * headers: jsonHeaders(testAdminActor),
15
+ * body: JSON.stringify({ ... }),
16
+ * });
17
+ * expect(res.status).toBe(201);
18
+ */
19
+
20
+ import { OpenAPIHono } from "@hono/zod-openapi";
21
+ import { createRequire } from "node:module";
22
+ import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
23
+ import type { CommerceConfig, CommercePlugin } from "../config/types.js";
24
+ import type { Actor } from "../auth/types.js";
25
+ import { createTestConfig } from "./create-test-config.js";
26
+ import { createKernel } from "../runtime/kernel.js";
27
+ import type { Kernel } from "../runtime/kernel.js";
28
+ import { buildSchema } from "../kernel/database/migrate.js";
29
+ import { ensureDefaultOrg } from "../auth/org.js";
30
+
31
+ // drizzle-kit/api uses CJS internally; createRequire provides ESM compat.
32
+ const require = createRequire(import.meta.url);
33
+
34
+ type DrizzleKitPushResult = {
35
+ hasDataLoss: boolean;
36
+ warnings: string[];
37
+ statementsToExecute: string[];
38
+ apply: () => Promise<void>;
39
+ };
40
+
41
+ /**
42
+ * Hono environment type for the test app. Declares the `actor` context
43
+ * variable so c.set("actor", ...) / c.get("actor") are properly typed
44
+ * without `as never` casts.
45
+ */
46
+ export type TestAppEnv = {
47
+ Variables: {
48
+ actor: Actor | null;
49
+ };
50
+ };
51
+
52
+ export interface PluginTestApp {
53
+ /** OpenAPIHono instance with test actor middleware and all plugin routes registered. */
54
+ app: OpenAPIHono<TestAppEnv>;
55
+ /** The booted kernel with database, services, and config. */
56
+ kernel: Kernel;
57
+ /** Drizzle database instance for direct queries in test assertions. */
58
+ db: PgDatabase<PgQueryResultHKT, Record<string, unknown>>;
59
+ }
60
+
61
+ /**
62
+ * Creates a fully-wired test application for plugin E2E testing.
63
+ *
64
+ * @param plugin - The plugin under test (e.g., `appointmentPlugin()`)
65
+ * @param configOverrides - Optional config overrides. Pass `databaseAdapter`
66
+ * to use a real PostgreSQL instance instead of PGlite.
67
+ */
68
+ export async function createPluginTestApp(
69
+ plugin: CommercePlugin,
70
+ configOverrides: Partial<CommerceConfig> = {},
71
+ ): Promise<PluginTestApp> {
72
+ // 1. Build config with plugin applied (PGlite auto-provisioned if no adapter)
73
+ const config = await createTestConfig({
74
+ plugins: [plugin],
75
+ ...configOverrides,
76
+ });
77
+
78
+ // 2. Boot kernel (creates core services, hook registry)
79
+ const kernel = createKernel(config);
80
+
81
+ // 3. Merge core + plugin schemas
82
+ const mergedSchema = buildSchema(config);
83
+
84
+ // 4. Programmatic schema push via drizzle-kit/api
85
+ // Diffs current DB state against pgTable definitions, generates DDL, applies it.
86
+ // On fresh PGlite: creates all tables. On existing DB: creates only missing tables.
87
+ const drizzleKit = require("drizzle-kit/api") as {
88
+ pushSchema(
89
+ imports: Record<string, unknown>,
90
+ drizzleInstance: PgDatabase<PgQueryResultHKT>,
91
+ ): Promise<DrizzleKitPushResult>;
92
+ };
93
+ const { apply } = await drizzleKit.pushSchema(
94
+ mergedSchema,
95
+ kernel.database.db as PgDatabase<PgQueryResultHKT>,
96
+ );
97
+ await apply();
98
+
99
+ // Ensure the default organization exists for plugin tests
100
+ await ensureDefaultOrg(kernel.database.db);
101
+
102
+ // 5. Create OpenAPIHono --- matching production server.ts
103
+ // Plugin routes register via manifest.ts which calls app.openapi().
104
+ const app = new OpenAPIHono<TestAppEnv>();
105
+
106
+ // 6. Test actor middleware: parse x-test-actor header -> set on context
107
+ app.use("*", async (c, next) => {
108
+ const header = c.req.header("x-test-actor");
109
+ if (header) {
110
+ try {
111
+ c.set("actor", JSON.parse(header) as Actor);
112
+ } catch { /* malformed JSON --- fall through without actor */ }
113
+ }
114
+ await next();
115
+ });
116
+
117
+ // 7. Register plugin routes (deferred via config.routes)
118
+ const routes = config.routes as
119
+ | ((app: unknown, kernel: unknown) => void)
120
+ | undefined;
121
+ routes?.(app, kernel);
122
+
123
+ return {
124
+ app,
125
+ kernel,
126
+ db: kernel.database.db as PgDatabase<PgQueryResultHKT, Record<string, unknown>>,
127
+ };
128
+ }