@porulle/plugin-channel-connector 0.65.1 → 0.67.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/dist/index.d.ts +3 -2
- package/dist/index.js +99 -58
- package/dist/live-credentials.d.ts +24 -0
- package/dist/live-credentials.js +71 -0
- package/dist/oauth-state.d.ts +10 -1
- package/dist/oauth-state.js +10 -9
- package/dist/schema.d.ts +3 -3
- package/dist/service.d.ts +102 -10
- package/dist/service.js +209 -69
- package/package.json +2 -2
- package/src/index.ts +102 -51
- package/src/live-credentials.ts +70 -0
- package/src/oauth-state.ts +13 -7
- package/src/schema.ts +2 -2
- package/src/service.ts +257 -75
package/src/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ type ChannelRouteContext = {
|
|
|
47
47
|
actor: { userId: string | null } | null;
|
|
48
48
|
/**
|
|
49
49
|
* Core's documented request escape hatch, already present on `RouteHandlerContext` and simply
|
|
50
|
-
* never declared here. Read by `
|
|
50
|
+
* never declared here. Read by `confineStores` so a consumer can resolve the caller from the
|
|
51
51
|
* request — core's `actor.vendorId` cannot serve that purpose: core sources it from a column on
|
|
52
52
|
* `user` that this deployment never writes, and sets it to `null` outright for API-key actors.
|
|
53
53
|
*/
|
|
@@ -55,7 +55,8 @@ type ChannelRouteContext = {
|
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
|
|
58
|
-
export { channelSyncHash, withDistinctVariantSkus } from "./service.js";
|
|
58
|
+
export { channelOrderAddressSchema, channelSyncHash, withDistinctVariantSkus } from "./service.js";
|
|
59
|
+
export { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
|
|
59
60
|
export type { AbsentArchivePlan } from "./deletion-policy.js";
|
|
60
61
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
61
62
|
export type { MockChannelConnectorOptions } from "./mock-connector.js";
|
|
@@ -232,8 +233,15 @@ export type {
|
|
|
232
233
|
CatalogPushSkipReason,
|
|
233
234
|
CatalogConflictState,
|
|
234
235
|
CatalogWriteSettings,
|
|
236
|
+
AfterStoreConnected,
|
|
237
|
+
BindConnectedStore,
|
|
235
238
|
ChannelComplianceData,
|
|
236
239
|
ChannelConnectorPluginOptions,
|
|
240
|
+
ConfineStores,
|
|
241
|
+
ConnectClaims,
|
|
242
|
+
OnStoreCatalogChanged,
|
|
243
|
+
StoreConnectActor,
|
|
244
|
+
StoreReadContext,
|
|
237
245
|
ChannelStockLine,
|
|
238
246
|
ExportState,
|
|
239
247
|
PublicConnectedStore,
|
|
@@ -278,6 +286,18 @@ function oauthRedirect(location: string): Response {
|
|
|
278
286
|
return new Response(null, { status: 302, headers: { location } });
|
|
279
287
|
}
|
|
280
288
|
|
|
289
|
+
/** The merchant's browser lands on `postConnectRedirect` with the outcome in the query string. */
|
|
290
|
+
function connectOutcome(postConnectRedirect: string, outcome: { connected: string } | { error: string; message: string }): Response {
|
|
291
|
+
const url = new URL(postConnectRedirect);
|
|
292
|
+
if ("connected" in outcome) {
|
|
293
|
+
url.searchParams.set("connected", outcome.connected);
|
|
294
|
+
} else {
|
|
295
|
+
url.searchParams.set("connect_error", outcome.error);
|
|
296
|
+
url.searchParams.set("connect_message", outcome.message);
|
|
297
|
+
}
|
|
298
|
+
return oauthRedirect(url.toString());
|
|
299
|
+
}
|
|
300
|
+
|
|
281
301
|
function callbackUri(raw: unknown, redirect: string, provider: string): string {
|
|
282
302
|
const request = (raw as { req: { raw: Request } }).req.raw;
|
|
283
303
|
const requestUrl = new URL(request.url);
|
|
@@ -433,6 +453,23 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
433
453
|
return { output: result.value };
|
|
434
454
|
},
|
|
435
455
|
},
|
|
456
|
+
{
|
|
457
|
+
// One provider webhook delivery, applied off the request: Shopify gives a delivery five seconds,
|
|
458
|
+
// and a product re-read plus convergence is not bounded by that. Serialized per store, so two
|
|
459
|
+
// deliveries for one store apply in the order they were accepted.
|
|
460
|
+
slug: "channel/apply-webhook",
|
|
461
|
+
concurrency: { key: (input: Record<string, unknown>) => `webhook:${String(input.storeId)}` },
|
|
462
|
+
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
463
|
+
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
464
|
+
const result = await service.handleWebhook(String(input.orgId), String(input.storeId), {
|
|
465
|
+
id: String(input.id),
|
|
466
|
+
type: String(input.topic),
|
|
467
|
+
data: input.data,
|
|
468
|
+
});
|
|
469
|
+
if (!result.ok) throw new Error(result.error);
|
|
470
|
+
return { output: { processed: true } };
|
|
471
|
+
},
|
|
472
|
+
},
|
|
436
473
|
{
|
|
437
474
|
slug: "channel/reap-exports",
|
|
438
475
|
handler: async ({ input, ctx }: { input: Record<string, unknown>; ctx: import("@porulle/core").TaskContext }) => {
|
|
@@ -450,7 +487,8 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
450
487
|
version: "1.0.0",
|
|
451
488
|
permissions: [
|
|
452
489
|
{ scope: "channels:read", description: "Read connected stores and channel export status." },
|
|
453
|
-
{ scope: "channels:manage", description: "
|
|
490
|
+
{ scope: "channels:manage", description: "Operate every store in the organization: backfills, pushes, conflicts, exports." },
|
|
491
|
+
{ scope: "channels:connect", description: "Connect, reconnect and disconnect the caller's own stores." },
|
|
454
492
|
],
|
|
455
493
|
schema: () => ({
|
|
456
494
|
connectedStores,
|
|
@@ -480,23 +518,32 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
480
518
|
|
|
481
519
|
channels.get("/oauth/{provider}/start")
|
|
482
520
|
.summary("Start channel OAuth onboarding")
|
|
483
|
-
.permission("channels:
|
|
521
|
+
.permission("channels:connect")
|
|
484
522
|
.params(z.object({ provider: z.string().min(1) }))
|
|
485
523
|
.query(z.object({ shop: z.string().min(1).optional(), store: z.string().min(1).optional() }))
|
|
486
|
-
.handler(async ({ params, query, orgId, raw }) => {
|
|
524
|
+
.handler(async ({ params, query, orgId, actor, raw }) => {
|
|
487
525
|
const oauth = options.oauth;
|
|
488
526
|
if (!oauth?.stateSecret || !oauth.postConnectRedirect) return oauthError(501, "OAUTH_NOT_CONFIGURED", "Channel OAuth is not configured.");
|
|
489
527
|
const provider = params.provider!;
|
|
490
528
|
const connector = service.getConnector(provider);
|
|
491
529
|
if (!connector) return oauthError(404, "CONNECTOR_NOT_FOUND", `No connector registered for provider "${provider}".`);
|
|
492
530
|
if (!connector.buildAuthUrl) return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
|
|
493
|
-
|
|
494
|
-
|
|
531
|
+
// The callback arrives with no session of its own; the store is bound to the user who started
|
|
532
|
+
// here, carried in the signed state. A caller with no user (an API key) cannot start one.
|
|
533
|
+
const userId = actor?.userId;
|
|
534
|
+
if (!userId) return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
|
|
535
|
+
const claims = await service.connectClaims({ orgId, actor, raw });
|
|
536
|
+
if (!claims.ok) return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
|
|
537
|
+
const typed = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
|
|
538
|
+
const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
|
|
539
|
+
if (!storeDomain) return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
|
|
495
540
|
const state = signState({
|
|
496
541
|
provider,
|
|
497
542
|
orgId,
|
|
543
|
+
userId,
|
|
544
|
+
claims: claims.value,
|
|
498
545
|
shopDomain: storeDomain,
|
|
499
|
-
exp: Math.floor(Date.now() / 1000) +
|
|
546
|
+
exp: Math.floor(Date.now() / 1000) + 600,
|
|
500
547
|
jti: crypto.randomUUID(),
|
|
501
548
|
}, oauth.stateSecret);
|
|
502
549
|
const redirect = callbackUri(raw, oauth.postConnectRedirect, provider);
|
|
@@ -507,7 +554,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
507
554
|
callbackUri: redirect,
|
|
508
555
|
scopes: [],
|
|
509
556
|
});
|
|
510
|
-
if (!authUrl.ok) return
|
|
557
|
+
if (!authUrl.ok) return connectOutcome(oauth.postConnectRedirect, { error: authUrl.error.code, message: authUrl.error.message });
|
|
511
558
|
return oauthRedirect(authUrl.value);
|
|
512
559
|
});
|
|
513
560
|
|
|
@@ -521,27 +568,28 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
521
568
|
const request = (raw as { req: { raw: Request } }).req.raw;
|
|
522
569
|
const requestUrl = new URL(request.url);
|
|
523
570
|
const state = requestUrl.searchParams.get("state");
|
|
524
|
-
|
|
571
|
+
const refused = (error: string, message: string): Response => connectOutcome(oauth.postConnectRedirect, { error, message });
|
|
572
|
+
if (!state) return refused("INVALID_OAUTH_STATE", "The connection was not started here, or its link was altered.");
|
|
525
573
|
const landing = provider === "woocommerce" && request.method === "GET" && requestUrl.searchParams.get("return") === "1";
|
|
526
|
-
const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000)
|
|
527
|
-
if (!verified.ok || verified.value.provider !== provider) return
|
|
574
|
+
const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000));
|
|
575
|
+
if (!verified.ok || verified.value.provider !== provider) return refused("INVALID_OAUTH_STATE", "The connection link expired; start again.");
|
|
528
576
|
if (landing) return oauthRedirect(oauth.postConnectRedirect);
|
|
529
577
|
const [consumed] = await db.insert(processedWebhookEvents).values({
|
|
530
578
|
eventId: oauthStateEventId(verified.value.jti),
|
|
531
579
|
provider: `oauth:${provider}`,
|
|
532
580
|
eventType: "oauth_state",
|
|
533
581
|
}).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
|
|
534
|
-
if (!consumed) return
|
|
582
|
+
if (!consumed) return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.");
|
|
535
583
|
const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain });
|
|
536
|
-
if (!completed.ok) return
|
|
537
|
-
if (completed.value.storeDomain !== verified.value.shopDomain) return
|
|
584
|
+
if (!completed.ok) return refused(completed.error.code, completed.error.message);
|
|
585
|
+
if (completed.value.storeDomain !== verified.value.shopDomain) return refused("OAUTH_STORE_MISMATCH", "The store that answered is not the one the connection was started for.");
|
|
538
586
|
const connected = await service.connectStore(verified.value.orgId, {
|
|
539
587
|
provider,
|
|
540
588
|
storeDomain: verified.value.shopDomain,
|
|
541
589
|
credentials: completed.value.credentials,
|
|
542
|
-
});
|
|
543
|
-
if (!connected.ok) return
|
|
544
|
-
return
|
|
590
|
+
}, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
|
|
591
|
+
if (!connected.ok) return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
|
|
592
|
+
return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
|
|
545
593
|
};
|
|
546
594
|
|
|
547
595
|
channels.get("/oauth/{provider}/callback")
|
|
@@ -562,7 +610,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
562
610
|
const [store] = await db.select().from(connectedStores).where(eq(connectedStores.id, storeId));
|
|
563
611
|
if (!store || !store.webhookSecret) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook store is not available." } }, 401);
|
|
564
612
|
const connector = service.getConnector(store.provider);
|
|
565
|
-
if (!connector) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured." } }, 401);
|
|
613
|
+
if (!connector?.verifyWebhook) return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured for per-store deliveries." } }, 401);
|
|
566
614
|
const verified = await connector.verifyWebhook(store, context.req.raw);
|
|
567
615
|
if (!verified.ok) return context.json({ error: { code: "UNAUTHORIZED", message: "Invalid webhook signature." } }, 401);
|
|
568
616
|
const [inserted] = await db.insert(processedWebhookEvents).values({ eventId: verified.value.id, provider: store.provider, eventType: verified.value.type }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
|
|
@@ -576,41 +624,39 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
576
624
|
} });
|
|
577
625
|
});
|
|
578
626
|
|
|
579
|
-
|
|
580
|
-
|
|
627
|
+
// Every delivery for a provider that signs per APP — Shopify's catalogue, stock, order, uninstall
|
|
628
|
+
// and mandatory compliance topics alike — arrives here, at the one address its app configuration
|
|
629
|
+
// declares. Verified, deduplicated on the provider's delivery id, and handed to a job per store:
|
|
630
|
+
// the answer must be quick, and the work is not.
|
|
631
|
+
channels.post("/app-webhooks/{provider}")
|
|
632
|
+
.summary("Receive a provider's app-level webhook")
|
|
581
633
|
.params(z.object({ provider: z.string().min(1) }))
|
|
582
634
|
.handler(async ({ params, raw }) => {
|
|
583
|
-
const context = raw as { req: { raw: Request
|
|
635
|
+
const context = raw as { req: { raw: Request }; json(data: unknown, status?: number): Response };
|
|
584
636
|
const provider = params.provider!;
|
|
585
637
|
const connector = service.getConnector(provider);
|
|
586
|
-
if (!connector) return context.json({ error: { code: "NOT_FOUND", message: `No
|
|
587
|
-
if (!connector.verifyAppWebhook) return context.json({ error: { code: "NOT_IMPLEMENTED", message: `Compliance webhook is not supported for provider "${provider}".` } }, 501);
|
|
638
|
+
if (!connector?.verifyAppWebhook) return context.json({ error: { code: "NOT_FOUND", message: `No app-level webhooks for provider "${provider}".` } }, 404);
|
|
588
639
|
const verified = await connector.verifyAppWebhook(context.req.raw);
|
|
589
640
|
if (!verified.ok) return context.json({ error: { code: "UNAUTHORIZED", message: verified.error.message } }, 401);
|
|
590
|
-
const
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
if (handled.value.redacted !== undefined) { redacted += handled.value.redacted; redactedSeen = true; }
|
|
603
|
-
if (handled.value.data) {
|
|
604
|
-
complianceData = complianceData
|
|
605
|
-
? { customer: complianceData.customer, exports: [...complianceData.exports, ...handled.value.data.exports] }
|
|
606
|
-
: handled.value.data;
|
|
641
|
+
const event = verified.value;
|
|
642
|
+
const [marked] = await db.insert(processedWebhookEvents).values({ eventId: event.id, provider, eventType: event.topic }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
|
|
643
|
+
if (!marked) return context.json({ data: { received: true, duplicate: true } });
|
|
644
|
+
const stores = (await service.getStoresByDomain(event.shopDomain)).filter((store) => store.provider === provider);
|
|
645
|
+
const jobs = ctx.services.jobs as JobsAdapter;
|
|
646
|
+
try {
|
|
647
|
+
for (const store of stores) {
|
|
648
|
+
await jobs.enqueue("channel/apply-webhook", { orgId: store.organizationId, storeId: store.id, id: event.id, topic: event.topic, data: event.data }, {
|
|
649
|
+
organizationId: store.organizationId,
|
|
650
|
+
concurrencyKey: `webhook:${store.id}`,
|
|
651
|
+
supersedes: false,
|
|
652
|
+
});
|
|
607
653
|
}
|
|
654
|
+
} catch (error) {
|
|
655
|
+
// Not accepted: forget the delivery so the provider's retry is applied, not dropped as a duplicate.
|
|
656
|
+
await db.delete(processedWebhookEvents).where(eq(processedWebhookEvents.id, marked.id));
|
|
657
|
+
return context.json({ error: { code: "WEBHOOK_NOT_ACCEPTED", message: error instanceof Error ? error.message : "The delivery could not be queued." } }, 503);
|
|
608
658
|
}
|
|
609
|
-
return context.json({ data: {
|
|
610
|
-
received: true,
|
|
611
|
-
...(complianceData ? { data: complianceData } : {}),
|
|
612
|
-
...(redactedSeen ? { redacted } : {}),
|
|
613
|
-
} });
|
|
659
|
+
return context.json({ data: { received: true, stores: stores.length } });
|
|
614
660
|
});
|
|
615
661
|
|
|
616
662
|
channels.post("/stores")
|
|
@@ -622,7 +668,8 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
622
668
|
storeDomain: z.string().min(1),
|
|
623
669
|
webhookSecret: z.string().min(1).optional(),
|
|
624
670
|
}))
|
|
625
|
-
.handler(async ({ input, orgId }: ChannelRouteContext) => {
|
|
671
|
+
.handler(async ({ input, orgId, actor, raw }: ChannelRouteContext) => {
|
|
672
|
+
const claims = unwrap(await service.connectClaims({ orgId, actor, raw }));
|
|
626
673
|
return unwrap(await service.connectStore(
|
|
627
674
|
orgId,
|
|
628
675
|
input as {
|
|
@@ -631,6 +678,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
631
678
|
storeDomain: string;
|
|
632
679
|
webhookSecret?: string;
|
|
633
680
|
},
|
|
681
|
+
{ orgId, userId: actor?.userId ?? null, claims, raw },
|
|
634
682
|
));
|
|
635
683
|
});
|
|
636
684
|
|
|
@@ -643,7 +691,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
643
691
|
channels.get("/stores/{id}")
|
|
644
692
|
.summary("Get a connected channel store")
|
|
645
693
|
.permission("channels:read")
|
|
646
|
-
.handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.getStore(orgId, params.id
|
|
694
|
+
.handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.getStore(orgId, params.id!, { orgId, actor, raw })));
|
|
647
695
|
|
|
648
696
|
channels.get("/stores/{storeId}/catalog-write")
|
|
649
697
|
.summary("Get catalog write settings for a channel store")
|
|
@@ -667,7 +715,10 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
667
715
|
channels.get("/stores/{storeId}/reconcile-status")
|
|
668
716
|
.summary("Get channel reconciliation status")
|
|
669
717
|
.permission("channels:read")
|
|
670
|
-
.handler(async ({ params, orgId }: ChannelRouteContext) =>
|
|
718
|
+
.handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => {
|
|
719
|
+
unwrap(await service.reachableStore(orgId, params.storeId!, { orgId, actor, raw }));
|
|
720
|
+
return unwrap(await service.getReconcileStatus(orgId, params.storeId!));
|
|
721
|
+
});
|
|
671
722
|
|
|
672
723
|
channels.get("/conflicts")
|
|
673
724
|
.summary("List channel catalog conflicts")
|
|
@@ -745,8 +796,8 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
745
796
|
|
|
746
797
|
channels.post("/stores/{id}/disconnect")
|
|
747
798
|
.summary("Disconnect a channel store")
|
|
748
|
-
.permission("channels:
|
|
749
|
-
.handler(async ({ params, orgId }: ChannelRouteContext) => unwrap(await service.disconnectStore(orgId, params.id
|
|
799
|
+
.permission("channels:connect")
|
|
800
|
+
.handler(async ({ params, orgId, actor, raw }: ChannelRouteContext) => unwrap(await service.disconnectStore(orgId, params.id!, { orgId, actor, raw })));
|
|
750
801
|
|
|
751
802
|
channels.get("/exports/failed")
|
|
752
803
|
.summary("List failed channel order exports")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Err, Ok } from "@porulle/core";
|
|
2
|
+
import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, Result } from "@porulle/core";
|
|
3
|
+
import { and, eq, sql } from "@porulle/core/drizzle";
|
|
4
|
+
import { connectedStores } from "./schema.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The store with credentials good for the next call.
|
|
8
|
+
*
|
|
9
|
+
* A provider that grants EXPIRING credentials (Shopify: a one-hour access token and a rotating
|
|
10
|
+
* refresh token) says so through `liveCredentials`; this asks it and persists what it returns.
|
|
11
|
+
*
|
|
12
|
+
* Persisting is a compare-and-swap on the credentials the call started from. Two Workers that both
|
|
13
|
+
* find the token expiring both refresh; the first write wins and the second, finding the row moved,
|
|
14
|
+
* adopts what the first wrote rather than overwriting a newer refresh token with its own. (Shopify
|
|
15
|
+
* keeps a presented refresh token usable until the app presents the newer one, and a retired access
|
|
16
|
+
* token valid until its own expiry, so the loser's in-flight call still succeeds.)
|
|
17
|
+
*
|
|
18
|
+
* A refusal that is not retriable means the grant is gone — the merchant uninstalled, or the refresh
|
|
19
|
+
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
20
|
+
* later call with a credential error nobody is shown.
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>> {
|
|
23
|
+
if (!connector.liveCredentials) return Ok(store);
|
|
24
|
+
const answer = await connector.liveCredentials(store);
|
|
25
|
+
if (!answer.ok) {
|
|
26
|
+
if (answer.error.retriable !== true) {
|
|
27
|
+
await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
28
|
+
}
|
|
29
|
+
return answer;
|
|
30
|
+
}
|
|
31
|
+
if (answer.value === null) return Ok(store);
|
|
32
|
+
const swapped = await db.update(connectedStores)
|
|
33
|
+
.set({ credentials: answer.value, updatedAt: new Date() })
|
|
34
|
+
.where(and(eq(connectedStores.id, store.id), sql`${connectedStores.credentials} = ${JSON.stringify(store.credentials)}::jsonb`))
|
|
35
|
+
.returning({ id: connectedStores.id });
|
|
36
|
+
if (swapped.length > 0) return Ok({ ...store, credentials: answer.value });
|
|
37
|
+
const [current] = await db.select({ credentials: connectedStores.credentials }).from(connectedStores).where(eq(connectedStores.id, store.id));
|
|
38
|
+
if (!current) return Err({ code: "STORE_NOT_FOUND", message: `Connected store ${store.id} no longer exists.`, retriable: false });
|
|
39
|
+
return Ok({ ...store, credentials: current.credentials });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type StoreCall<A extends unknown[], T> = (store: ChannelStore, ...args: A) => Promise<Result<T, ChannelConnectorError>>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
46
|
+
* call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
|
|
47
|
+
* credentials never expire.
|
|
48
|
+
*/
|
|
49
|
+
export function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector {
|
|
50
|
+
if (!connector.liveCredentials) return connector;
|
|
51
|
+
const around = <A extends unknown[], T>(call: StoreCall<A, T>): StoreCall<A, T> => async (store, ...args) => {
|
|
52
|
+
const current = await resolveLiveCredentials(connector, db, store);
|
|
53
|
+
if (!current.ok) return current;
|
|
54
|
+
return call.call(connector, current.value, ...args);
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
...connector,
|
|
58
|
+
importCatalog: around(connector.importCatalog),
|
|
59
|
+
fetchInventory: around(connector.fetchInventory),
|
|
60
|
+
pushOrder: around(connector.pushOrder),
|
|
61
|
+
fetchOrderStatus: around(connector.fetchOrderStatus),
|
|
62
|
+
refundExecute: around(connector.refundExecute),
|
|
63
|
+
...(connector.fetchInventoryPage ? { fetchInventoryPage: around(connector.fetchInventoryPage) } : {}),
|
|
64
|
+
...(connector.fetchCatalogItems ? { fetchCatalogItems: around(connector.fetchCatalogItems) } : {}),
|
|
65
|
+
...(connector.fetchStoreProfile ? { fetchStoreProfile: around(connector.fetchStoreProfile) } : {}),
|
|
66
|
+
...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
|
|
67
|
+
...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
|
|
68
|
+
...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/oauth-state.ts
CHANGED
|
@@ -3,6 +3,10 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
3
3
|
export interface OAuthStatePayload {
|
|
4
4
|
provider: string;
|
|
5
5
|
orgId: string;
|
|
6
|
+
/** The signed-in user who started the connection: the callback arrives with no session of its own. */
|
|
7
|
+
userId: string;
|
|
8
|
+
/** The consumer's connect claims, resolved at start. */
|
|
9
|
+
claims: Record<string, string>;
|
|
6
10
|
shopDomain: string;
|
|
7
11
|
exp: number;
|
|
8
12
|
jti: string;
|
|
@@ -12,8 +16,6 @@ export type OAuthStateResult =
|
|
|
12
16
|
| { ok: true; value: OAuthStatePayload }
|
|
13
17
|
| { ok: false; error: string };
|
|
14
18
|
|
|
15
|
-
const consumedJtis = new Map<string, number>();
|
|
16
|
-
|
|
17
19
|
function encodeText(value: string): string {
|
|
18
20
|
return Buffer.from(value, "utf8").toString("base64url");
|
|
19
21
|
}
|
|
@@ -40,11 +42,15 @@ export function signState(payload: OAuthStatePayload, secret: string): string {
|
|
|
40
42
|
return `${encodedPayload}.${encodeBytes(signature(encodedPayload, secret))}`;
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Checks the signature and expiry. Single use is NOT decided here: an in-memory record of used states
|
|
47
|
+
* would forget on every Worker isolate, so the callback records each state's `jti` in the database and
|
|
48
|
+
* refuses a second use there.
|
|
49
|
+
*/
|
|
43
50
|
export function verifyState(
|
|
44
51
|
state: string,
|
|
45
52
|
secret: string,
|
|
46
53
|
now = Math.floor(Date.now() / 1000),
|
|
47
|
-
consume = true,
|
|
48
54
|
): OAuthStateResult {
|
|
49
55
|
if (!secret) return { ok: false, error: "OAuth state secret is required." };
|
|
50
56
|
const parts = state.split(".");
|
|
@@ -75,17 +81,17 @@ export function verifyState(
|
|
|
75
81
|
if (
|
|
76
82
|
typeof candidate.provider !== "string" ||
|
|
77
83
|
typeof candidate.orgId !== "string" ||
|
|
84
|
+
typeof candidate.userId !== "string" ||
|
|
78
85
|
typeof candidate.shopDomain !== "string" ||
|
|
79
86
|
typeof candidate.jti !== "string" ||
|
|
80
87
|
typeof exp !== "number" ||
|
|
81
88
|
!Number.isInteger(exp)
|
|
82
89
|
) return { ok: false, error: "Malformed OAuth state payload." };
|
|
83
90
|
if (exp <= now) return { ok: false, error: "OAuth state has expired." };
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
const claims: unknown = (candidate as { claims?: unknown }).claims;
|
|
92
|
+
if (typeof claims !== "object" || claims === null || Object.values(claims).some((value) => typeof value !== "string")) {
|
|
93
|
+
return { ok: false, error: "Malformed OAuth state payload." };
|
|
86
94
|
}
|
|
87
|
-
if (consume && consumedJtis.has(candidate.jti)) return { ok: false, error: "OAuth state has already been used." };
|
|
88
|
-
if (consume) consumedJtis.set(candidate.jti, exp);
|
|
89
95
|
|
|
90
96
|
return { ok: true, value: candidate as OAuthStatePayload };
|
|
91
97
|
}
|
package/src/schema.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
uuid,
|
|
11
11
|
sql,
|
|
12
12
|
} from "@porulle/core/drizzle";
|
|
13
|
-
import type { ChannelPushCatalogItem, FieldPath } from "@porulle/core";
|
|
13
|
+
import type { ChannelOrderAddress, ChannelPushCatalogItem, FieldPath } from "@porulle/core";
|
|
14
14
|
import { sellableEntities } from "@porulle/core/schema";
|
|
15
15
|
import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
|
|
16
16
|
|
|
@@ -144,7 +144,7 @@ export const channelOrderExports = pgTable(
|
|
|
144
144
|
customerData: jsonb("customer_data").$type<{
|
|
145
145
|
name: string;
|
|
146
146
|
email: string;
|
|
147
|
-
shippingAddress:
|
|
147
|
+
shippingAddress: ChannelOrderAddress;
|
|
148
148
|
}>(),
|
|
149
149
|
state: text("state", { enum: ["pending", "exported", "confirmed", "failed", "abandoned"] })
|
|
150
150
|
.notNull()
|