@porulle/plugin-channel-connector 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 unified-commerce-engine contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ import type { PluginHookRegistration } from "@porulle/core";
2
+ import { type ChannelConnectorPluginOptions } from "./service.js";
3
+ export declare function buildHooks(options: ChannelConnectorPluginOptions): PluginHookRegistration[];
package/dist/hooks.js ADDED
@@ -0,0 +1,24 @@
1
+ import { resolveOrgId } from "@porulle/core";
2
+ import { and, eq, inArray } from "@porulle/core/drizzle";
3
+ import { sellableEntities } from "@porulle/core/schema";
4
+ import { ChannelConnectorService, } from "./service.js";
5
+ export function buildHooks(options) {
6
+ return [{
7
+ key: "checkout.beforePayment",
8
+ async handler(args) {
9
+ const { data, context } = args;
10
+ const service = new ChannelConnectorService(context.db, context.services, options);
11
+ await service.validateLineStock(resolveOrgId(context.actor), data.lineItems, options.inventoryTimeoutMs);
12
+ return data;
13
+ },
14
+ }, {
15
+ key: "orders.afterCreate",
16
+ async handler(args) {
17
+ const { result, context } = args;
18
+ const orgId = resolveOrgId(context.actor);
19
+ const entities = await context.db.select({ id: sellableEntities.id, sourceStoreId: sellableEntities.sourceStoreId }).from(sellableEntities).where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.id, (result.lineItems ?? []).map((line) => line.entityId))));
20
+ const stores = new Set(entities.map((entity) => entity.sourceStoreId).filter((storeId) => storeId !== null));
21
+ await Promise.all([...stores].map((storeId) => context.jobs.enqueue("channel/push-order", { orgId, storeId, orderId: result.id }, { organizationId: orgId, concurrencyKey: `push:${result.id}:${storeId}`, supersedes: true })));
22
+ },
23
+ }];
24
+ }
@@ -0,0 +1,9 @@
1
+ import { type ChannelConnectorPluginOptions } from "./service.js";
2
+ export { mockChannelConnector } from "./mock-connector.js";
3
+ export type { MockChannelConnectorOptions } from "./mock-connector.js";
4
+ export { ChannelConnectorService, canExportTransition, } from "./service.js";
5
+ export { signState, verifyState } from "./oauth-state.js";
6
+ export type { ChannelComplianceData, ChannelConnectorPluginOptions, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
7
+ export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
8
+ export type { ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
9
+ export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
package/dist/index.js ADDED
@@ -0,0 +1,375 @@
1
+ import { createHash } from "node:crypto";
2
+ import { CommerceConflictError, CommerceInvalidTransitionError, CommerceNotFoundError, CommerceValidationError, defineCommercePlugin, router, createSystemActor, } from "@porulle/core";
3
+ import { z } from "@hono/zod-openapi";
4
+ import { and, eq } from "@porulle/core/drizzle";
5
+ import { processedWebhookEvents } from "@porulle/core/schema";
6
+ import { channelEntityMap, channelExportEvents, channelOrderExports, channelRefundEvents, channelRefundRequests, connectedStores, } from "./schema.js";
7
+ import { ChannelConnectorService, } from "./service.js";
8
+ import { buildHooks } from "./hooks.js";
9
+ import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
10
+ export { mockChannelConnector } from "./mock-connector.js";
11
+ export { ChannelConnectorService, canExportTransition, } from "./service.js";
12
+ export { signState, verifyState } from "./oauth-state.js";
13
+ function unwrap(result) {
14
+ if (result.ok)
15
+ return result.value;
16
+ switch (result.code) {
17
+ case "NOT_FOUND":
18
+ throw new CommerceNotFoundError(result.error);
19
+ case "INVALID_TRANSITION":
20
+ throw new CommerceInvalidTransitionError(result.error);
21
+ case "CONFLICT":
22
+ throw new CommerceConflictError(result.error);
23
+ default:
24
+ throw new CommerceValidationError(result.error);
25
+ }
26
+ }
27
+ function oauthError(status, code, message) {
28
+ return new Response(JSON.stringify({ error: { code, message } }), {
29
+ status,
30
+ headers: { "content-type": "application/json" },
31
+ });
32
+ }
33
+ function oauthRedirect(location) {
34
+ return new Response(null, { status: 302, headers: { location } });
35
+ }
36
+ function callbackUri(raw, redirect, provider) {
37
+ const request = raw.req.raw;
38
+ const requestUrl = new URL(request.url);
39
+ let origin = requestUrl.origin;
40
+ try {
41
+ const configured = new URL(redirect);
42
+ if (configured.protocol === "http:" || configured.protocol === "https:")
43
+ origin = configured.origin;
44
+ }
45
+ catch {
46
+ origin = requestUrl.origin;
47
+ }
48
+ return new URL(`/api/channels/oauth/${provider}/callback`, origin).toString();
49
+ }
50
+ export function channelConnectorPlugin(options = {}) {
51
+ const jobs = [
52
+ {
53
+ slug: "channel/reconcile",
54
+ concurrency: { key: (input) => String(input.storeId), supersedes: true },
55
+ handler: async ({ input, ctx }) => {
56
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
57
+ const orgId = String(input.orgId);
58
+ const result = await service.reconcile(orgId, String(input.storeId), createSystemActor(orgId));
59
+ if (!result.ok)
60
+ throw new Error(result.error);
61
+ if (result.value.driftAlert)
62
+ ctx.logger.warn("Channel reconciliation detected significant drift.", result.value);
63
+ return { output: result.value };
64
+ },
65
+ },
66
+ {
67
+ slug: "channel/reconcile-sweep",
68
+ handler: async ({ input, ctx }) => {
69
+ const orgId = String(input.orgId);
70
+ const jobs = ctx.services.jobs;
71
+ const stores = await ctx.db.select().from(connectedStores).where(and(eq(connectedStores.organizationId, orgId), eq(connectedStores.status, "connected")));
72
+ const window = options.reconcileJitterWindowMs ?? 60 * 60 * 1000;
73
+ for (const store of stores) {
74
+ const offset = createHash("sha256").update(store.id).digest().readUInt32BE(0) % window;
75
+ await jobs.enqueue("channel/reconcile", { orgId, storeId: store.id }, {
76
+ organizationId: orgId,
77
+ concurrencyKey: store.id,
78
+ supersedes: true,
79
+ delayMs: offset,
80
+ });
81
+ }
82
+ return { output: { enqueued: stores.length } };
83
+ },
84
+ },
85
+ {
86
+ slug: "channel/import-catalog",
87
+ concurrency: { key: (input) => String(input.storeId), supersedes: true },
88
+ handler: async ({ input, ctx }) => {
89
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
90
+ const result = await service.importCatalog(String(input.orgId), String(input.storeId), createSystemActor(String(input.orgId)));
91
+ if (!result.ok)
92
+ throw new Error(result.error);
93
+ return { output: { imported: result.value.imported, cursor: result.value.cursor } };
94
+ },
95
+ },
96
+ {
97
+ slug: "channel/sync-inventory",
98
+ concurrency: { key: (input) => String(input.storeId) },
99
+ handler: async ({ input, ctx }) => {
100
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
101
+ const result = await service.syncInventory(String(input.orgId), String(input.storeId), createSystemActor(String(input.orgId)));
102
+ if (!result.ok)
103
+ throw new Error(result.error);
104
+ return { output: { synced: result.value.synced } };
105
+ },
106
+ },
107
+ {
108
+ slug: "channel/push-order",
109
+ concurrency: { key: (input) => `push:${String(input.orderId)}:${String(input.storeId)}`, supersedes: true },
110
+ handler: async ({ input, ctx }) => {
111
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
112
+ const orgId = String(input.orgId);
113
+ const storeId = String(input.storeId);
114
+ const orderId = String(input.orderId);
115
+ const existing = await service.createExport(orgId, storeId, orderId);
116
+ if (!existing.ok)
117
+ throw new Error(existing.error);
118
+ const slice = await service.buildOrderSlice(orgId, storeId, orderId);
119
+ if (!slice.ok) {
120
+ if (existing.value.state === "pending")
121
+ await service.transitionExport(orgId, existing.value.id, "exported", "system", "Export attempt started.");
122
+ await service.transitionExport(orgId, existing.value.id, "failed", "system", slice.error);
123
+ return { output: { state: "failed" } };
124
+ }
125
+ const result = await service.exportOrder(orgId, storeId, slice.value, createSystemActor(orgId));
126
+ if (!result.ok)
127
+ throw new Error(result.error);
128
+ return { output: { exportId: result.value.id, state: result.value.state } };
129
+ },
130
+ },
131
+ {
132
+ slug: "channel/reap-exports",
133
+ handler: async ({ input, ctx }) => {
134
+ const service = new ChannelConnectorService(ctx.db, ctx.services, options);
135
+ const result = await service.reapExports({
136
+ definitiveMs: typeof input.definitiveMs === "number" ? input.definitiveMs : options.exportSla?.definitiveMs ?? 4 * 60 * 60 * 1000,
137
+ transientMs: typeof input.transientMs === "number" ? input.transientMs : options.exportSla?.transientMs ?? 24 * 60 * 60 * 1000,
138
+ });
139
+ return { output: result };
140
+ },
141
+ },
142
+ ];
143
+ return defineCommercePlugin({
144
+ id: "channel-connector",
145
+ version: "1.0.0",
146
+ permissions: [
147
+ { scope: "channels:read", description: "Read connected stores and channel export status." },
148
+ { scope: "channels:manage", description: "Connect stores and retry channel order exports." },
149
+ ],
150
+ schema: () => ({
151
+ connectedStores,
152
+ channelEntityMap,
153
+ channelOrderExports,
154
+ channelExportEvents,
155
+ channelRefundRequests,
156
+ channelRefundEvents,
157
+ }),
158
+ hooks: () => buildHooks(options),
159
+ jobs: () => jobs,
160
+ routes: (ctx) => {
161
+ const db = ctx.database.db;
162
+ if (!db)
163
+ return [];
164
+ const service = new ChannelConnectorService(db, ctx.services, options, ctx.database.transaction);
165
+ const channels = router("Channels", "/channels", ctx);
166
+ channels.get("/oauth/{provider}/start")
167
+ .summary("Start channel OAuth onboarding")
168
+ .permission("channels:manage")
169
+ .params(z.object({ provider: z.string().min(1) }))
170
+ .query(z.object({ shop: z.string().min(1).optional(), store: z.string().min(1).optional() }))
171
+ .handler(async ({ params, query, orgId, raw }) => {
172
+ const oauth = options.oauth;
173
+ if (!oauth?.stateSecret || !oauth.postConnectRedirect)
174
+ return oauthError(501, "OAUTH_NOT_CONFIGURED", "Channel OAuth is not configured.");
175
+ const provider = params.provider;
176
+ const connector = service.getConnector(provider);
177
+ if (!connector)
178
+ return oauthError(404, "CONNECTOR_NOT_FOUND", `No connector registered for provider "${provider}".`);
179
+ if (!connector.buildAuthUrl)
180
+ return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
181
+ const storeDomain = String(query.shop ?? query.store ?? "");
182
+ if (!storeDomain)
183
+ return oauthError(400, "STORE_DOMAIN_REQUIRED", "The shop or store query parameter is required.");
184
+ const state = signState({
185
+ provider,
186
+ orgId,
187
+ shopDomain: storeDomain,
188
+ exp: Math.floor(Date.now() / 1000) + 300,
189
+ jti: crypto.randomUUID(),
190
+ }, oauth.stateSecret);
191
+ const redirect = callbackUri(raw, oauth.postConnectRedirect, provider);
192
+ const authUrl = connector.buildAuthUrl({
193
+ storeDomain,
194
+ state,
195
+ redirectUri: redirect,
196
+ callbackUri: redirect,
197
+ scopes: [],
198
+ });
199
+ if (!authUrl.ok)
200
+ return oauthError(422, authUrl.error.code, authUrl.error.message);
201
+ return oauthRedirect(authUrl.value);
202
+ });
203
+ const handleOAuthCallback = async ({ params, raw }) => {
204
+ const oauth = options.oauth;
205
+ if (!oauth?.stateSecret || !oauth.postConnectRedirect)
206
+ return oauthError(501, "OAUTH_NOT_CONFIGURED", "Channel OAuth is not configured.");
207
+ const provider = params.provider;
208
+ const connector = service.getConnector(provider);
209
+ if (!connector)
210
+ return oauthError(404, "CONNECTOR_NOT_FOUND", `No connector registered for provider "${provider}".`);
211
+ if (!connector.completeAuth)
212
+ return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
213
+ const request = raw.req.raw;
214
+ const requestUrl = new URL(request.url);
215
+ const state = requestUrl.searchParams.get("state");
216
+ if (!state)
217
+ return oauthError(403, "INVALID_OAUTH_STATE", "OAuth state is missing.");
218
+ const landing = provider === "woocommerce" && request.method === "GET" && requestUrl.searchParams.get("return") === "1";
219
+ const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000), !landing);
220
+ if (!verified.ok || verified.value.provider !== provider)
221
+ return oauthError(403, "INVALID_OAUTH_STATE", "OAuth state is invalid or expired.");
222
+ if (landing)
223
+ return oauthRedirect(oauth.postConnectRedirect);
224
+ const [consumed] = await db.insert(processedWebhookEvents).values({
225
+ eventId: oauthStateEventId(verified.value.jti),
226
+ provider: `oauth:${provider}`,
227
+ eventType: "oauth_state",
228
+ }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
229
+ if (!consumed)
230
+ return oauthError(403, "OAUTH_STATE_REPLAYED", "OAuth state has already been used.");
231
+ const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain });
232
+ if (!completed.ok)
233
+ return oauthError(400, completed.error.code, completed.error.message);
234
+ if (completed.value.storeDomain !== verified.value.shopDomain)
235
+ return oauthError(400, "OAUTH_STORE_MISMATCH", "OAuth callback store does not match the signed state.");
236
+ const connected = await service.connectStore(verified.value.orgId, {
237
+ provider,
238
+ storeDomain: verified.value.shopDomain,
239
+ credentials: completed.value.credentials,
240
+ });
241
+ if (!connected.ok)
242
+ return oauthError(422, connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
243
+ return oauthRedirect(oauth.postConnectRedirect);
244
+ };
245
+ channels.get("/oauth/{provider}/callback")
246
+ .summary("Complete channel OAuth onboarding")
247
+ .params(z.object({ provider: z.string().min(1) }))
248
+ .handler(handleOAuthCallback);
249
+ channels.post("/oauth/{provider}/callback")
250
+ .summary("Receive channel OAuth credentials")
251
+ .params(z.object({ provider: z.string().min(1) }))
252
+ .handler(handleOAuthCallback);
253
+ channels.post("/webhooks/{storeId}")
254
+ .summary("Receive a channel webhook")
255
+ .handler(async ({ params, raw }) => {
256
+ const context = raw;
257
+ const storeId = params.storeId;
258
+ const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, storeId));
259
+ if (!store || !store.webhookSecret)
260
+ return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook store is not available." } }, 401);
261
+ const connector = service.getConnector(store.provider);
262
+ if (!connector)
263
+ return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured." } }, 401);
264
+ const verified = await connector.verifyWebhook(store, context.req.raw);
265
+ if (!verified.ok)
266
+ return context.json({ error: { code: "UNAUTHORIZED", message: "Invalid webhook signature." } }, 401);
267
+ const [inserted] = await db.insert(processedWebhookEvents).values({ eventId: verified.value.id, provider: store.provider, eventType: verified.value.type }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
268
+ if (!inserted)
269
+ return context.json({ data: { received: true, duplicate: true } });
270
+ const handled = await service.handleWebhook(store.organizationId, store.id, verified.value);
271
+ if (!handled.ok)
272
+ return context.json({ error: { code: "WEBHOOK_PROCESSING_FAILED", message: handled.error } }, 422);
273
+ return context.json({ data: {
274
+ received: true,
275
+ ...(handled.value.data ? { data: handled.value.data } : {}),
276
+ ...(handled.value.redacted !== undefined ? { redacted: handled.value.redacted } : {}),
277
+ } });
278
+ });
279
+ channels.post("/compliance/{provider}")
280
+ .summary("Receive a compliance webhook")
281
+ .params(z.object({ provider: z.string().min(1) }))
282
+ .handler(async ({ params, raw }) => {
283
+ const context = raw;
284
+ const provider = params.provider;
285
+ const connector = service.getConnector(provider);
286
+ if (!connector)
287
+ return context.json({ error: { code: "NOT_FOUND", message: `No connector registered for provider "${provider}".` } }, 404);
288
+ if (!connector.verifyAppWebhook)
289
+ return context.json({ error: { code: "NOT_IMPLEMENTED", message: `Compliance webhook is not supported for provider "${provider}".` } }, 501);
290
+ const verified = await connector.verifyAppWebhook(context.req.raw);
291
+ if (!verified.ok)
292
+ return context.json({ error: { code: "UNAUTHORIZED", message: verified.error.message } }, 401);
293
+ const request = context.req.raw;
294
+ const eventId = request.headers.get("x-shopify-event-id") ?? createHash("sha256").update(`${verified.value.topic}:${verified.value.shopDomain}:${JSON.stringify(verified.value.data)}`).digest("hex");
295
+ const [inserted] = await db.insert(processedWebhookEvents).values({ eventId, provider, eventType: verified.value.topic }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
296
+ if (!inserted)
297
+ return context.json({ data: { received: true, duplicate: true } });
298
+ const stores = await service.getStoresByDomain(verified.value.shopDomain);
299
+ if (stores.length === 0)
300
+ return context.json({ data: { received: true } });
301
+ let redacted = 0;
302
+ let redactedSeen = false;
303
+ let complianceData;
304
+ for (const store of stores) {
305
+ const handled = await service.handleWebhook(store.organizationId, store.id, { id: eventId, type: verified.value.topic, data: verified.value.data });
306
+ if (!handled.ok)
307
+ return context.json({ error: { code: "WEBHOOK_PROCESSING_FAILED", message: handled.error } }, 422);
308
+ if (handled.value.redacted !== undefined) {
309
+ redacted += handled.value.redacted;
310
+ redactedSeen = true;
311
+ }
312
+ if (handled.value.data) {
313
+ complianceData = complianceData
314
+ ? { customer: complianceData.customer, exports: [...complianceData.exports, ...handled.value.data.exports] }
315
+ : handled.value.data;
316
+ }
317
+ }
318
+ return context.json({ data: {
319
+ received: true,
320
+ ...(complianceData ? { data: complianceData } : {}),
321
+ ...(redactedSeen ? { redacted } : {}),
322
+ } });
323
+ });
324
+ channels.post("/stores")
325
+ .summary("Connect a channel store")
326
+ .permission("channels:manage")
327
+ .input(z.object({
328
+ provider: z.string().min(1),
329
+ credentials: z.record(z.string(), z.unknown()),
330
+ storeDomain: z.string().min(1),
331
+ webhookSecret: z.string().min(1).optional(),
332
+ }))
333
+ .handler(async ({ input, orgId }) => {
334
+ return unwrap(await service.connectStore(orgId, input));
335
+ });
336
+ channels.get("/stores")
337
+ .summary("List connected channel stores")
338
+ .permission("channels:read")
339
+ .handler(async ({ orgId }) => unwrap(await service.listStores(orgId)));
340
+ channels.get("/stores/{id}")
341
+ .summary("Get a connected channel store")
342
+ .permission("channels:read")
343
+ .handler(async ({ params, orgId }) => unwrap(await service.getStore(orgId, params.id)));
344
+ channels.get("/stores/{storeId}/reconcile-status")
345
+ .summary("Get channel reconciliation status")
346
+ .permission("channels:read")
347
+ .handler(async ({ params, orgId }) => unwrap(await service.getReconcileStatus(orgId, params.storeId)));
348
+ channels.post("/stores/{id}/disconnect")
349
+ .summary("Disconnect a channel store")
350
+ .permission("channels:manage")
351
+ .handler(async ({ params, orgId }) => unwrap(await service.disconnectStore(orgId, params.id)));
352
+ channels.get("/exports/failed")
353
+ .summary("List failed channel order exports")
354
+ .permission("channels:read")
355
+ .handler(async ({ orgId }) => unwrap(await service.listFailedExports(orgId)));
356
+ channels.get("/refund-requests")
357
+ .summary("List pending channel refund requests")
358
+ .permission("channels:manage")
359
+ .handler(async ({ orgId }) => unwrap(await service.listRefundRequests(orgId)));
360
+ channels.post("/refund-requests/{id}/approve")
361
+ .summary("Approve a channel refund request")
362
+ .permission("channels:manage")
363
+ .handler(async ({ params, orgId, actor }) => unwrap(await service.approveRefund(orgId, params.id, actor)));
364
+ channels.post("/refund-requests/{id}/reject")
365
+ .summary("Reject a channel refund request")
366
+ .permission("channels:manage")
367
+ .handler(async ({ params, orgId, actor }) => unwrap(await service.rejectRefund(orgId, params.id, actor)));
368
+ channels.post("/exports/{id}/retry")
369
+ .summary("Retry a failed channel order export")
370
+ .permission("channels:manage")
371
+ .handler(async ({ params, orgId, actor }) => unwrap(await service.retryExport(orgId, params.id, actor.userId)));
372
+ return channels.routes();
373
+ },
374
+ });
375
+ }
@@ -0,0 +1,58 @@
1
+ import { CommerceValidationError } from "@porulle/core";
2
+ import type { ChannelCatalogItem, ChannelInventoryLevel, ChannelOrderSlice } from "@porulle/core";
3
+ export interface MockChannelConnectorOptions {
4
+ catalog?: ChannelCatalogItem[];
5
+ inventory?: ChannelInventoryLevel[];
6
+ inventoryError?: Error;
7
+ throwOnInventory?: boolean;
8
+ inventoryDelayMs?: number;
9
+ onFetchInventory?: (ids: string[]) => void;
10
+ }
11
+ export declare function mockChannelConnector(options?: MockChannelConnectorOptions): {
12
+ providerId: string;
13
+ capabilities: {
14
+ importCatalog: true;
15
+ importInventory: true;
16
+ pushOrder: true;
17
+ receiveWebhooks: true;
18
+ };
19
+ importCatalog(): Promise<import("@porulle/core").Result<{
20
+ items: ChannelCatalogItem[];
21
+ nextCursor: null;
22
+ }, never>>;
23
+ fetchInventory(_store: import("@porulle/core").ChannelStore, ids: string[] | undefined): Promise<{
24
+ ok: true;
25
+ value: ChannelInventoryLevel[];
26
+ meta?: Record<string, unknown>;
27
+ } | {
28
+ ok: false;
29
+ error: CommerceValidationError;
30
+ }>;
31
+ pushOrder(_store: import("@porulle/core").ChannelStore, slice: ChannelOrderSlice): Promise<import("@porulle/core").Result<{
32
+ remoteOrderId: string;
33
+ remoteUrl: string;
34
+ }, never>>;
35
+ fetchOrderStatus(_store: import("@porulle/core").ChannelStore, remoteId: string): Promise<{
36
+ ok: false;
37
+ error: CommerceValidationError;
38
+ } | {
39
+ ok: true;
40
+ value: {
41
+ status: "confirmed";
42
+ };
43
+ meta?: Record<string, unknown>;
44
+ }>;
45
+ verifyWebhook(store: import("@porulle/core").ChannelStore, request: Request): Promise<{
46
+ ok: false;
47
+ error: CommerceValidationError;
48
+ } | {
49
+ ok: true;
50
+ value: {
51
+ id: string;
52
+ type: string;
53
+ data: unknown;
54
+ };
55
+ meta?: Record<string, unknown>;
56
+ }>;
57
+ refundExecute(): Promise<import("@porulle/core").Result<never, CommerceValidationError>>;
58
+ };
@@ -0,0 +1,62 @@
1
+ import { CommerceValidationError, Err, Ok, defineChannelConnector, } from "@porulle/core";
2
+ export function mockChannelConnector(options = {}) {
3
+ const orders = new Map();
4
+ return defineChannelConnector({
5
+ providerId: "mock",
6
+ capabilities: {
7
+ importCatalog: true,
8
+ importInventory: true,
9
+ pushOrder: true,
10
+ receiveWebhooks: true,
11
+ },
12
+ async importCatalog() {
13
+ return Ok({ items: options.catalog ?? [], nextCursor: null });
14
+ },
15
+ async fetchInventory(_store, ids) {
16
+ const requestedIds = ids ?? [];
17
+ options.onFetchInventory?.(requestedIds);
18
+ if (options.inventoryDelayMs !== undefined) {
19
+ await new Promise((resolve) => setTimeout(resolve, options.inventoryDelayMs));
20
+ }
21
+ if (options.throwOnInventory)
22
+ throw new Error("Mock inventory failure.");
23
+ if (options.inventoryError)
24
+ return Err(new CommerceValidationError(options.inventoryError.message));
25
+ const inventory = options.inventory ?? [];
26
+ return Ok(ids ? inventory.filter((item) => ids.includes(item.externalId)) : inventory);
27
+ },
28
+ async pushOrder(_store, slice) {
29
+ const remoteOrderId = `mock-order-${orders.size + 1}`;
30
+ orders.set(remoteOrderId, structuredClone(slice));
31
+ return Ok({
32
+ remoteOrderId,
33
+ remoteUrl: `https://mock.channel.test/orders/${remoteOrderId}`,
34
+ });
35
+ },
36
+ async fetchOrderStatus(_store, remoteId) {
37
+ if (!orders.has(remoteId)) {
38
+ return Err(new CommerceValidationError(`Mock order "${remoteId}" was not found.`));
39
+ }
40
+ return Ok({ status: "confirmed" });
41
+ },
42
+ async verifyWebhook(store, request) {
43
+ if (request.headers.get("x-mock-signature") !== store.webhookSecret) {
44
+ return Err(new CommerceValidationError("Invalid mock webhook signature."));
45
+ }
46
+ let data;
47
+ try {
48
+ data = await request.json();
49
+ }
50
+ catch {
51
+ return Err(new CommerceValidationError("Mock webhook body must be valid JSON."));
52
+ }
53
+ if (!data.id || !data.type) {
54
+ return Err(new CommerceValidationError("Mock webhook requires id and type."));
55
+ }
56
+ return Ok({ id: data.id, type: data.type, data: data.data });
57
+ },
58
+ async refundExecute() {
59
+ return Err(new CommerceValidationError("Channel refund execution is not implemented in the foundations slice."));
60
+ },
61
+ });
62
+ }
@@ -0,0 +1,17 @@
1
+ export interface OAuthStatePayload {
2
+ provider: string;
3
+ orgId: string;
4
+ shopDomain: string;
5
+ exp: number;
6
+ jti: string;
7
+ }
8
+ export type OAuthStateResult = {
9
+ ok: true;
10
+ value: OAuthStatePayload;
11
+ } | {
12
+ ok: false;
13
+ error: string;
14
+ };
15
+ export declare function signState(payload: OAuthStatePayload, secret: string): string;
16
+ export declare function verifyState(state: string, secret: string, now?: number, consume?: boolean): OAuthStateResult;
17
+ export declare function oauthStateEventId(jti: string): string;
@@ -0,0 +1,78 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ const consumedJtis = new Map();
3
+ function encodeText(value) {
4
+ return Buffer.from(value, "utf8").toString("base64url");
5
+ }
6
+ function encodeBytes(value) {
7
+ return Buffer.from(value).toString("base64url");
8
+ }
9
+ function decode(value) {
10
+ try {
11
+ return Buffer.from(value, "base64url").toString("utf8");
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ function signature(payload, secret) {
18
+ return createHmac("sha256", secret).update(payload).digest();
19
+ }
20
+ export function signState(payload, secret) {
21
+ if (!secret)
22
+ throw new Error("OAuth state secret is required.");
23
+ const encodedPayload = encodeText(JSON.stringify(payload));
24
+ return `${encodedPayload}.${encodeBytes(signature(encodedPayload, secret))}`;
25
+ }
26
+ export function verifyState(state, secret, now = Math.floor(Date.now() / 1000), consume = true) {
27
+ if (!secret)
28
+ return { ok: false, error: "OAuth state secret is required." };
29
+ const parts = state.split(".");
30
+ if (parts.length !== 2 || !parts[0] || !parts[1])
31
+ return { ok: false, error: "Malformed OAuth state." };
32
+ const expected = signature(parts[0], secret);
33
+ let actual;
34
+ try {
35
+ actual = Buffer.from(parts[1], "base64url");
36
+ }
37
+ catch {
38
+ return { ok: false, error: "Malformed OAuth state signature." };
39
+ }
40
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
41
+ return { ok: false, error: "Invalid OAuth state signature." };
42
+ }
43
+ const decoded = decode(parts[0]);
44
+ if (!decoded)
45
+ return { ok: false, error: "Malformed OAuth state payload." };
46
+ let payload;
47
+ try {
48
+ payload = JSON.parse(decoded);
49
+ }
50
+ catch {
51
+ return { ok: false, error: "Malformed OAuth state payload." };
52
+ }
53
+ if (!payload || typeof payload !== "object")
54
+ return { ok: false, error: "Malformed OAuth state payload." };
55
+ const candidate = payload;
56
+ const exp = candidate.exp;
57
+ if (typeof candidate.provider !== "string" ||
58
+ typeof candidate.orgId !== "string" ||
59
+ typeof candidate.shopDomain !== "string" ||
60
+ typeof candidate.jti !== "string" ||
61
+ typeof exp !== "number" ||
62
+ !Number.isInteger(exp))
63
+ return { ok: false, error: "Malformed OAuth state payload." };
64
+ if (exp <= now)
65
+ return { ok: false, error: "OAuth state has expired." };
66
+ for (const [jti, expiresAt] of consumedJtis) {
67
+ if (expiresAt <= now)
68
+ consumedJtis.delete(jti);
69
+ }
70
+ if (consume && consumedJtis.has(candidate.jti))
71
+ return { ok: false, error: "OAuth state has already been used." };
72
+ if (consume)
73
+ consumedJtis.set(candidate.jti, exp);
74
+ return { ok: true, value: candidate };
75
+ }
76
+ export function oauthStateEventId(jti) {
77
+ return `oauth-state:${jti}`;
78
+ }