@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/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ChannelConnectorPluginOptions } from "./service.js";
|
|
2
2
|
export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
|
|
3
|
-
export { channelSyncHash, withDistinctVariantSkus } from "./service.js";
|
|
3
|
+
export { channelOrderAddressSchema, channelSyncHash, withDistinctVariantSkus } from "./service.js";
|
|
4
|
+
export { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
|
|
4
5
|
export type { AbsentArchivePlan } from "./deletion-policy.js";
|
|
5
6
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
6
7
|
export type { MockChannelConnectorOptions } from "./mock-connector.js";
|
|
@@ -24,7 +25,7 @@ export type { CatalogFieldMapping, CatalogFieldMappingInput, CatalogFieldMapping
|
|
|
24
25
|
*/
|
|
25
26
|
export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
|
|
26
27
|
export { signState, verifyState } from "./oauth-state.js";
|
|
27
|
-
export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, ChannelComplianceData, ChannelConnectorPluginOptions, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
|
|
28
|
+
export type { BackfillCatalogOptions, BackfillCatalogReport, BuildCatalogPushItemsOptions, BuildCatalogPushItemsResult, CatalogPushAssemblyField, CatalogPushAssemblyImage, CatalogPushAssemblyItem, CatalogPushPreviewBefore, CatalogPushPreviewBeforeStatus, CatalogPushPreviewDiff, CatalogPushPreviewItem, CatalogPushPreviewResult, CatalogPushPreviewUnavailable, PushCatalogToStoreResult, CatalogPushJobResult, CatalogConvergenceFailure, CatalogFieldConflict, CatalogFieldSkip, CatalogPushFieldSkip, CatalogPushSkipReason, CatalogConflictState, CatalogWriteSettings, AfterStoreConnected, BindConnectedStore, ChannelComplianceData, ChannelConnectorPluginOptions, ConfineStores, ConnectClaims, OnStoreCatalogChanged, StoreConnectActor, StoreReadContext, ChannelStockLine, ExportState, PublicConnectedStore, ReconcileReport, } from "./service.js";
|
|
28
29
|
export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
|
|
29
30
|
export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
|
|
30
31
|
export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,8 @@ import { ChannelConnectorService, catalogPushConcurrencyKey, CHANNEL_INVENTORY_M
|
|
|
8
8
|
import { buildHooks } from "./hooks.js";
|
|
9
9
|
import { oauthStateEventId, signState, verifyState } from "./oauth-state.js";
|
|
10
10
|
export { ABSENT_ARCHIVE_FLOOR, ABSENT_ARCHIVE_FRACTION, planAbsentArchives } from "./deletion-policy.js";
|
|
11
|
-
export { channelSyncHash, withDistinctVariantSkus } from "./service.js";
|
|
11
|
+
export { channelOrderAddressSchema, channelSyncHash, withDistinctVariantSkus } from "./service.js";
|
|
12
|
+
export { resolveLiveCredentials, withLiveCredentials } from "./live-credentials.js";
|
|
12
13
|
export { mockChannelConnector } from "./mock-connector.js";
|
|
13
14
|
export { ChannelConnectorService, HERO_IMAGE_BYTE_CAP, selectImportImages, CATALOG_OUTBOUND_SUPPRESSION_WINDOW_MS, CATALOG_PUSH_BATCH_SIZES, CATALOG_PUSH_MAX_ATTEMPTS, canCatalogPushTransition, canExportTransition, catalogPushConcurrencyKey, catalogPushRetryDelayMs, CHANNEL_INVENTORY_MAX_ITEMS_PER_INVOCATION, isCatalogPushBreakerOpen, } from "./service.js";
|
|
14
15
|
export { isValidCatalogMappingFieldPath, matchFieldPath, mergeCatalogFieldMapping, normalizeCatalogFieldMapping, compareCatalogFieldMappingSpecificity, providerCatalogFieldMappingDefaults, selectCatalogFieldMapping, validateCatalogMappingRow, } from "./catalog-field-mapping.js";
|
|
@@ -116,6 +117,18 @@ function oauthError(status, code, message) {
|
|
|
116
117
|
function oauthRedirect(location) {
|
|
117
118
|
return new Response(null, { status: 302, headers: { location } });
|
|
118
119
|
}
|
|
120
|
+
/** The merchant's browser lands on `postConnectRedirect` with the outcome in the query string. */
|
|
121
|
+
function connectOutcome(postConnectRedirect, outcome) {
|
|
122
|
+
const url = new URL(postConnectRedirect);
|
|
123
|
+
if ("connected" in outcome) {
|
|
124
|
+
url.searchParams.set("connected", outcome.connected);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
url.searchParams.set("connect_error", outcome.error);
|
|
128
|
+
url.searchParams.set("connect_message", outcome.message);
|
|
129
|
+
}
|
|
130
|
+
return oauthRedirect(url.toString());
|
|
131
|
+
}
|
|
119
132
|
function callbackUri(raw, redirect, provider) {
|
|
120
133
|
const request = raw.req.raw;
|
|
121
134
|
const requestUrl = new URL(request.url);
|
|
@@ -269,6 +282,24 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
269
282
|
return { output: result.value };
|
|
270
283
|
},
|
|
271
284
|
},
|
|
285
|
+
{
|
|
286
|
+
// One provider webhook delivery, applied off the request: Shopify gives a delivery five seconds,
|
|
287
|
+
// and a product re-read plus convergence is not bounded by that. Serialized per store, so two
|
|
288
|
+
// deliveries for one store apply in the order they were accepted.
|
|
289
|
+
slug: "channel/apply-webhook",
|
|
290
|
+
concurrency: { key: (input) => `webhook:${String(input.storeId)}` },
|
|
291
|
+
handler: async ({ input, ctx }) => {
|
|
292
|
+
const service = new ChannelConnectorService(ctx.db, ctx.services, options);
|
|
293
|
+
const result = await service.handleWebhook(String(input.orgId), String(input.storeId), {
|
|
294
|
+
id: String(input.id),
|
|
295
|
+
type: String(input.topic),
|
|
296
|
+
data: input.data,
|
|
297
|
+
});
|
|
298
|
+
if (!result.ok)
|
|
299
|
+
throw new Error(result.error);
|
|
300
|
+
return { output: { processed: true } };
|
|
301
|
+
},
|
|
302
|
+
},
|
|
272
303
|
{
|
|
273
304
|
slug: "channel/reap-exports",
|
|
274
305
|
handler: async ({ input, ctx }) => {
|
|
@@ -286,7 +317,8 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
286
317
|
version: "1.0.0",
|
|
287
318
|
permissions: [
|
|
288
319
|
{ scope: "channels:read", description: "Read connected stores and channel export status." },
|
|
289
|
-
{ scope: "channels:manage", description: "
|
|
320
|
+
{ scope: "channels:manage", description: "Operate every store in the organization: backfills, pushes, conflicts, exports." },
|
|
321
|
+
{ scope: "channels:connect", description: "Connect, reconnect and disconnect the caller's own stores." },
|
|
290
322
|
],
|
|
291
323
|
schema: () => ({
|
|
292
324
|
connectedStores,
|
|
@@ -311,10 +343,10 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
311
343
|
const channels = router("Channels", "/channels", ctx);
|
|
312
344
|
channels.get("/oauth/{provider}/start")
|
|
313
345
|
.summary("Start channel OAuth onboarding")
|
|
314
|
-
.permission("channels:
|
|
346
|
+
.permission("channels:connect")
|
|
315
347
|
.params(z.object({ provider: z.string().min(1) }))
|
|
316
348
|
.query(z.object({ shop: z.string().min(1).optional(), store: z.string().min(1).optional() }))
|
|
317
|
-
.handler(async ({ params, query, orgId, raw }) => {
|
|
349
|
+
.handler(async ({ params, query, orgId, actor, raw }) => {
|
|
318
350
|
const oauth = options.oauth;
|
|
319
351
|
if (!oauth?.stateSecret || !oauth.postConnectRedirect)
|
|
320
352
|
return oauthError(501, "OAUTH_NOT_CONFIGURED", "Channel OAuth is not configured.");
|
|
@@ -324,14 +356,25 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
324
356
|
return oauthError(404, "CONNECTOR_NOT_FOUND", `No connector registered for provider "${provider}".`);
|
|
325
357
|
if (!connector.buildAuthUrl)
|
|
326
358
|
return oauthError(501, "OAUTH_UNSUPPORTED", `Connector "${provider}" does not support OAuth onboarding.`);
|
|
327
|
-
|
|
359
|
+
// The callback arrives with no session of its own; the store is bound to the user who started
|
|
360
|
+
// here, carried in the signed state. A caller with no user (an API key) cannot start one.
|
|
361
|
+
const userId = actor?.userId;
|
|
362
|
+
if (!userId)
|
|
363
|
+
return oauthError(403, "USER_REQUIRED", "Connecting a store needs a signed-in user.");
|
|
364
|
+
const claims = await service.connectClaims({ orgId, actor, raw });
|
|
365
|
+
if (!claims.ok)
|
|
366
|
+
return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
|
|
367
|
+
const typed = String(query.shop ?? query.store ?? "");
|
|
368
|
+
const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
|
|
328
369
|
if (!storeDomain)
|
|
329
|
-
return
|
|
370
|
+
return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
|
|
330
371
|
const state = signState({
|
|
331
372
|
provider,
|
|
332
373
|
orgId,
|
|
374
|
+
userId,
|
|
375
|
+
claims: claims.value,
|
|
333
376
|
shopDomain: storeDomain,
|
|
334
|
-
exp: Math.floor(Date.now() / 1000) +
|
|
377
|
+
exp: Math.floor(Date.now() / 1000) + 600,
|
|
335
378
|
jti: crypto.randomUUID(),
|
|
336
379
|
}, oauth.stateSecret);
|
|
337
380
|
const redirect = callbackUri(raw, oauth.postConnectRedirect, provider);
|
|
@@ -343,7 +386,7 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
343
386
|
scopes: [],
|
|
344
387
|
});
|
|
345
388
|
if (!authUrl.ok)
|
|
346
|
-
return
|
|
389
|
+
return connectOutcome(oauth.postConnectRedirect, { error: authUrl.error.code, message: authUrl.error.message });
|
|
347
390
|
return oauthRedirect(authUrl.value);
|
|
348
391
|
});
|
|
349
392
|
const handleOAuthCallback = async ({ params, raw }) => {
|
|
@@ -359,12 +402,13 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
359
402
|
const request = raw.req.raw;
|
|
360
403
|
const requestUrl = new URL(request.url);
|
|
361
404
|
const state = requestUrl.searchParams.get("state");
|
|
405
|
+
const refused = (error, message) => connectOutcome(oauth.postConnectRedirect, { error, message });
|
|
362
406
|
if (!state)
|
|
363
|
-
return
|
|
407
|
+
return refused("INVALID_OAUTH_STATE", "The connection was not started here, or its link was altered.");
|
|
364
408
|
const landing = provider === "woocommerce" && request.method === "GET" && requestUrl.searchParams.get("return") === "1";
|
|
365
|
-
const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000)
|
|
409
|
+
const verified = verifyState(state, oauth.stateSecret, Math.floor(Date.now() / 1000));
|
|
366
410
|
if (!verified.ok || verified.value.provider !== provider)
|
|
367
|
-
return
|
|
411
|
+
return refused("INVALID_OAUTH_STATE", "The connection link expired; start again.");
|
|
368
412
|
if (landing)
|
|
369
413
|
return oauthRedirect(oauth.postConnectRedirect);
|
|
370
414
|
const [consumed] = await db.insert(processedWebhookEvents).values({
|
|
@@ -373,20 +417,20 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
373
417
|
eventType: "oauth_state",
|
|
374
418
|
}).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
|
|
375
419
|
if (!consumed)
|
|
376
|
-
return
|
|
420
|
+
return refused("OAUTH_STATE_REPLAYED", "This connection link was already used; start again.");
|
|
377
421
|
const completed = await connector.completeAuth(request, { storeDomain: verified.value.shopDomain });
|
|
378
422
|
if (!completed.ok)
|
|
379
|
-
return
|
|
423
|
+
return refused(completed.error.code, completed.error.message);
|
|
380
424
|
if (completed.value.storeDomain !== verified.value.shopDomain)
|
|
381
|
-
return
|
|
425
|
+
return refused("OAUTH_STORE_MISMATCH", "The store that answered is not the one the connection was started for.");
|
|
382
426
|
const connected = await service.connectStore(verified.value.orgId, {
|
|
383
427
|
provider,
|
|
384
428
|
storeDomain: verified.value.shopDomain,
|
|
385
429
|
credentials: completed.value.credentials,
|
|
386
|
-
});
|
|
430
|
+
}, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
|
|
387
431
|
if (!connected.ok)
|
|
388
|
-
return
|
|
389
|
-
return
|
|
432
|
+
return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
|
|
433
|
+
return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
|
|
390
434
|
};
|
|
391
435
|
channels.get("/oauth/{provider}/callback")
|
|
392
436
|
.summary("Complete channel OAuth onboarding")
|
|
@@ -405,8 +449,8 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
405
449
|
if (!store || !store.webhookSecret)
|
|
406
450
|
return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook store is not available." } }, 401);
|
|
407
451
|
const connector = service.getConnector(store.provider);
|
|
408
|
-
if (!connector)
|
|
409
|
-
return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured." } }, 401);
|
|
452
|
+
if (!connector?.verifyWebhook)
|
|
453
|
+
return context.json({ error: { code: "UNAUTHORIZED", message: "Webhook provider is not configured for per-store deliveries." } }, 401);
|
|
410
454
|
const verified = await connector.verifyWebhook(store, context.req.raw);
|
|
411
455
|
if (!verified.ok)
|
|
412
456
|
return context.json({ error: { code: "UNAUTHORIZED", message: "Invalid webhook signature." } }, 401);
|
|
@@ -422,50 +466,43 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
422
466
|
...(handled.value.redacted !== undefined ? { redacted: handled.value.redacted } : {}),
|
|
423
467
|
} });
|
|
424
468
|
});
|
|
425
|
-
|
|
426
|
-
|
|
469
|
+
// Every delivery for a provider that signs per APP — Shopify's catalogue, stock, order, uninstall
|
|
470
|
+
// and mandatory compliance topics alike — arrives here, at the one address its app configuration
|
|
471
|
+
// declares. Verified, deduplicated on the provider's delivery id, and handed to a job per store:
|
|
472
|
+
// the answer must be quick, and the work is not.
|
|
473
|
+
channels.post("/app-webhooks/{provider}")
|
|
474
|
+
.summary("Receive a provider's app-level webhook")
|
|
427
475
|
.params(z.object({ provider: z.string().min(1) }))
|
|
428
476
|
.handler(async ({ params, raw }) => {
|
|
429
477
|
const context = raw;
|
|
430
478
|
const provider = params.provider;
|
|
431
479
|
const connector = service.getConnector(provider);
|
|
432
|
-
if (!connector)
|
|
433
|
-
return context.json({ error: { code: "NOT_FOUND", message: `No
|
|
434
|
-
if (!connector.verifyAppWebhook)
|
|
435
|
-
return context.json({ error: { code: "NOT_IMPLEMENTED", message: `Compliance webhook is not supported for provider "${provider}".` } }, 501);
|
|
480
|
+
if (!connector?.verifyAppWebhook)
|
|
481
|
+
return context.json({ error: { code: "NOT_FOUND", message: `No app-level webhooks for provider "${provider}".` } }, 404);
|
|
436
482
|
const verified = await connector.verifyAppWebhook(context.req.raw);
|
|
437
483
|
if (!verified.ok)
|
|
438
484
|
return context.json({ error: { code: "UNAUTHORIZED", message: verified.error.message } }, 401);
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
if (!inserted)
|
|
485
|
+
const event = verified.value;
|
|
486
|
+
const [marked] = await db.insert(processedWebhookEvents).values({ eventId: event.id, provider, eventType: event.topic }).onConflictDoNothing().returning({ id: processedWebhookEvents.id });
|
|
487
|
+
if (!marked)
|
|
443
488
|
return context.json({ data: { received: true, duplicate: true } });
|
|
444
|
-
const stores = await service.getStoresByDomain(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
return context.json({ error: { code: "WEBHOOK_PROCESSING_FAILED", message: handled.error } }, 422);
|
|
454
|
-
if (handled.value.redacted !== undefined) {
|
|
455
|
-
redacted += handled.value.redacted;
|
|
456
|
-
redactedSeen = true;
|
|
457
|
-
}
|
|
458
|
-
if (handled.value.data) {
|
|
459
|
-
complianceData = complianceData
|
|
460
|
-
? { customer: complianceData.customer, exports: [...complianceData.exports, ...handled.value.data.exports] }
|
|
461
|
-
: handled.value.data;
|
|
489
|
+
const stores = (await service.getStoresByDomain(event.shopDomain)).filter((store) => store.provider === provider);
|
|
490
|
+
const jobs = ctx.services.jobs;
|
|
491
|
+
try {
|
|
492
|
+
for (const store of stores) {
|
|
493
|
+
await jobs.enqueue("channel/apply-webhook", { orgId: store.organizationId, storeId: store.id, id: event.id, topic: event.topic, data: event.data }, {
|
|
494
|
+
organizationId: store.organizationId,
|
|
495
|
+
concurrencyKey: `webhook:${store.id}`,
|
|
496
|
+
supersedes: false,
|
|
497
|
+
});
|
|
462
498
|
}
|
|
463
499
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
500
|
+
catch (error) {
|
|
501
|
+
// Not accepted: forget the delivery so the provider's retry is applied, not dropped as a duplicate.
|
|
502
|
+
await db.delete(processedWebhookEvents).where(eq(processedWebhookEvents.id, marked.id));
|
|
503
|
+
return context.json({ error: { code: "WEBHOOK_NOT_ACCEPTED", message: error instanceof Error ? error.message : "The delivery could not be queued." } }, 503);
|
|
504
|
+
}
|
|
505
|
+
return context.json({ data: { received: true, stores: stores.length } });
|
|
469
506
|
});
|
|
470
507
|
channels.post("/stores")
|
|
471
508
|
.summary("Connect a channel store")
|
|
@@ -476,8 +513,9 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
476
513
|
storeDomain: z.string().min(1),
|
|
477
514
|
webhookSecret: z.string().min(1).optional(),
|
|
478
515
|
}))
|
|
479
|
-
.handler(async ({ input, orgId }) => {
|
|
480
|
-
|
|
516
|
+
.handler(async ({ input, orgId, actor, raw }) => {
|
|
517
|
+
const claims = unwrap(await service.connectClaims({ orgId, actor, raw }));
|
|
518
|
+
return unwrap(await service.connectStore(orgId, input, { orgId, userId: actor?.userId ?? null, claims, raw }));
|
|
481
519
|
});
|
|
482
520
|
channels.get("/stores")
|
|
483
521
|
.summary("List connected channel stores")
|
|
@@ -486,7 +524,7 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
486
524
|
channels.get("/stores/{id}")
|
|
487
525
|
.summary("Get a connected channel store")
|
|
488
526
|
.permission("channels:read")
|
|
489
|
-
.handler(async ({ params, orgId }) => unwrap(await service.getStore(orgId, params.id)));
|
|
527
|
+
.handler(async ({ params, orgId, actor, raw }) => unwrap(await service.getStore(orgId, params.id, { orgId, actor, raw })));
|
|
490
528
|
channels.get("/stores/{storeId}/catalog-write")
|
|
491
529
|
.summary("Get catalog write settings for a channel store")
|
|
492
530
|
.permission("channels:manage")
|
|
@@ -509,7 +547,10 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
509
547
|
channels.get("/stores/{storeId}/reconcile-status")
|
|
510
548
|
.summary("Get channel reconciliation status")
|
|
511
549
|
.permission("channels:read")
|
|
512
|
-
.handler(async ({ params, orgId }) =>
|
|
550
|
+
.handler(async ({ params, orgId, actor, raw }) => {
|
|
551
|
+
unwrap(await service.reachableStore(orgId, params.storeId, { orgId, actor, raw }));
|
|
552
|
+
return unwrap(await service.getReconcileStatus(orgId, params.storeId));
|
|
553
|
+
});
|
|
513
554
|
channels.get("/conflicts")
|
|
514
555
|
.summary("List channel catalog conflicts")
|
|
515
556
|
.permission("channels:read")
|
|
@@ -581,8 +622,8 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
581
622
|
});
|
|
582
623
|
channels.post("/stores/{id}/disconnect")
|
|
583
624
|
.summary("Disconnect a channel store")
|
|
584
|
-
.permission("channels:
|
|
585
|
-
.handler(async ({ params, orgId }) => unwrap(await service.disconnectStore(orgId, params.id)));
|
|
625
|
+
.permission("channels:connect")
|
|
626
|
+
.handler(async ({ params, orgId, actor, raw }) => unwrap(await service.disconnectStore(orgId, params.id, { orgId, actor, raw })));
|
|
586
627
|
channels.get("/exports/failed")
|
|
587
628
|
.summary("List failed channel order exports")
|
|
588
629
|
.permission("channels:read")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, Result } from "@porulle/core";
|
|
2
|
+
/**
|
|
3
|
+
* The store with credentials good for the next call.
|
|
4
|
+
*
|
|
5
|
+
* A provider that grants EXPIRING credentials (Shopify: a one-hour access token and a rotating
|
|
6
|
+
* refresh token) says so through `liveCredentials`; this asks it and persists what it returns.
|
|
7
|
+
*
|
|
8
|
+
* Persisting is a compare-and-swap on the credentials the call started from. Two Workers that both
|
|
9
|
+
* find the token expiring both refresh; the first write wins and the second, finding the row moved,
|
|
10
|
+
* adopts what the first wrote rather than overwriting a newer refresh token with its own. (Shopify
|
|
11
|
+
* keeps a presented refresh token usable until the app presents the newer one, and a retired access
|
|
12
|
+
* token valid until its own expiry, so the loser's in-flight call still succeeds.)
|
|
13
|
+
*
|
|
14
|
+
* A refusal that is not retriable means the grant is gone — the merchant uninstalled, or the refresh
|
|
15
|
+
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
16
|
+
* later call with a credential error nobody is shown.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>>;
|
|
19
|
+
/**
|
|
20
|
+
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
21
|
+
* call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
|
|
22
|
+
* credentials never expire.
|
|
23
|
+
*/
|
|
24
|
+
export declare function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Err, Ok } from "@porulle/core";
|
|
2
|
+
import { and, eq, sql } from "@porulle/core/drizzle";
|
|
3
|
+
import { connectedStores } from "./schema.js";
|
|
4
|
+
/**
|
|
5
|
+
* The store with credentials good for the next call.
|
|
6
|
+
*
|
|
7
|
+
* A provider that grants EXPIRING credentials (Shopify: a one-hour access token and a rotating
|
|
8
|
+
* refresh token) says so through `liveCredentials`; this asks it and persists what it returns.
|
|
9
|
+
*
|
|
10
|
+
* Persisting is a compare-and-swap on the credentials the call started from. Two Workers that both
|
|
11
|
+
* find the token expiring both refresh; the first write wins and the second, finding the row moved,
|
|
12
|
+
* adopts what the first wrote rather than overwriting a newer refresh token with its own. (Shopify
|
|
13
|
+
* keeps a presented refresh token usable until the app presents the newer one, and a retired access
|
|
14
|
+
* token valid until its own expiry, so the loser's in-flight call still succeeds.)
|
|
15
|
+
*
|
|
16
|
+
* A refusal that is not retriable means the grant is gone — the merchant uninstalled, or the refresh
|
|
17
|
+
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
18
|
+
* later call with a credential error nobody is shown.
|
|
19
|
+
*/
|
|
20
|
+
export async function resolveLiveCredentials(connector, db, store) {
|
|
21
|
+
if (!connector.liveCredentials)
|
|
22
|
+
return Ok(store);
|
|
23
|
+
const answer = await connector.liveCredentials(store);
|
|
24
|
+
if (!answer.ok) {
|
|
25
|
+
if (answer.error.retriable !== true) {
|
|
26
|
+
await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
27
|
+
}
|
|
28
|
+
return answer;
|
|
29
|
+
}
|
|
30
|
+
if (answer.value === null)
|
|
31
|
+
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)
|
|
37
|
+
return Ok({ ...store, credentials: answer.value });
|
|
38
|
+
const [current] = await db.select({ credentials: connectedStores.credentials }).from(connectedStores).where(eq(connectedStores.id, store.id));
|
|
39
|
+
if (!current)
|
|
40
|
+
return Err({ code: "STORE_NOT_FOUND", message: `Connected store ${store.id} no longer exists.`, retriable: false });
|
|
41
|
+
return Ok({ ...store, credentials: current.credentials });
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
45
|
+
* call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
|
|
46
|
+
* credentials never expire.
|
|
47
|
+
*/
|
|
48
|
+
export function withLiveCredentials(connector, db) {
|
|
49
|
+
if (!connector.liveCredentials)
|
|
50
|
+
return connector;
|
|
51
|
+
const around = (call) => async (store, ...args) => {
|
|
52
|
+
const current = await resolveLiveCredentials(connector, db, store);
|
|
53
|
+
if (!current.ok)
|
|
54
|
+
return current;
|
|
55
|
+
return call.call(connector, current.value, ...args);
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
...connector,
|
|
59
|
+
importCatalog: around(connector.importCatalog),
|
|
60
|
+
fetchInventory: around(connector.fetchInventory),
|
|
61
|
+
pushOrder: around(connector.pushOrder),
|
|
62
|
+
fetchOrderStatus: around(connector.fetchOrderStatus),
|
|
63
|
+
refundExecute: around(connector.refundExecute),
|
|
64
|
+
...(connector.fetchInventoryPage ? { fetchInventoryPage: around(connector.fetchInventoryPage) } : {}),
|
|
65
|
+
...(connector.fetchCatalogItems ? { fetchCatalogItems: around(connector.fetchCatalogItems) } : {}),
|
|
66
|
+
...(connector.fetchStoreProfile ? { fetchStoreProfile: around(connector.fetchStoreProfile) } : {}),
|
|
67
|
+
...(connector.pushCatalog ? { pushCatalog: around(connector.pushCatalog) } : {}),
|
|
68
|
+
...(connector.reserve ? { reserve: around(connector.reserve) } : {}),
|
|
69
|
+
...(connector.registerWebhooks ? { registerWebhooks: around(connector.registerWebhooks) } : {}),
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/oauth-state.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export interface OAuthStatePayload {
|
|
2
2
|
provider: string;
|
|
3
3
|
orgId: string;
|
|
4
|
+
/** The signed-in user who started the connection: the callback arrives with no session of its own. */
|
|
5
|
+
userId: string;
|
|
6
|
+
/** The consumer's connect claims, resolved at start. */
|
|
7
|
+
claims: Record<string, string>;
|
|
4
8
|
shopDomain: string;
|
|
5
9
|
exp: number;
|
|
6
10
|
jti: string;
|
|
@@ -13,5 +17,10 @@ export type OAuthStateResult = {
|
|
|
13
17
|
error: string;
|
|
14
18
|
};
|
|
15
19
|
export declare function signState(payload: OAuthStatePayload, secret: string): string;
|
|
16
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Checks the signature and expiry. Single use is NOT decided here: an in-memory record of used states
|
|
22
|
+
* would forget on every Worker isolate, so the callback records each state's `jti` in the database and
|
|
23
|
+
* refuses a second use there.
|
|
24
|
+
*/
|
|
25
|
+
export declare function verifyState(state: string, secret: string, now?: number): OAuthStateResult;
|
|
17
26
|
export declare function oauthStateEventId(jti: string): string;
|
package/dist/oauth-state.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
-
const consumedJtis = new Map();
|
|
3
2
|
function encodeText(value) {
|
|
4
3
|
return Buffer.from(value, "utf8").toString("base64url");
|
|
5
4
|
}
|
|
@@ -23,7 +22,12 @@ export function signState(payload, secret) {
|
|
|
23
22
|
const encodedPayload = encodeText(JSON.stringify(payload));
|
|
24
23
|
return `${encodedPayload}.${encodeBytes(signature(encodedPayload, secret))}`;
|
|
25
24
|
}
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Checks the signature and expiry. Single use is NOT decided here: an in-memory record of used states
|
|
27
|
+
* would forget on every Worker isolate, so the callback records each state's `jti` in the database and
|
|
28
|
+
* refuses a second use there.
|
|
29
|
+
*/
|
|
30
|
+
export function verifyState(state, secret, now = Math.floor(Date.now() / 1000)) {
|
|
27
31
|
if (!secret)
|
|
28
32
|
return { ok: false, error: "OAuth state secret is required." };
|
|
29
33
|
const parts = state.split(".");
|
|
@@ -56,6 +60,7 @@ export function verifyState(state, secret, now = Math.floor(Date.now() / 1000),
|
|
|
56
60
|
const exp = candidate.exp;
|
|
57
61
|
if (typeof candidate.provider !== "string" ||
|
|
58
62
|
typeof candidate.orgId !== "string" ||
|
|
63
|
+
typeof candidate.userId !== "string" ||
|
|
59
64
|
typeof candidate.shopDomain !== "string" ||
|
|
60
65
|
typeof candidate.jti !== "string" ||
|
|
61
66
|
typeof exp !== "number" ||
|
|
@@ -63,14 +68,10 @@ export function verifyState(state, secret, now = Math.floor(Date.now() / 1000),
|
|
|
63
68
|
return { ok: false, error: "Malformed OAuth state payload." };
|
|
64
69
|
if (exp <= now)
|
|
65
70
|
return { ok: false, error: "OAuth state has expired." };
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
const claims = candidate.claims;
|
|
72
|
+
if (typeof claims !== "object" || claims === null || Object.values(claims).some((value) => typeof value !== "string")) {
|
|
73
|
+
return { ok: false, error: "Malformed OAuth state payload." };
|
|
69
74
|
}
|
|
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
75
|
return { ok: true, value: candidate };
|
|
75
76
|
}
|
|
76
77
|
export function oauthStateEventId(jti) {
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChannelPushCatalogItem } from "@porulle/core";
|
|
1
|
+
import type { ChannelOrderAddress, ChannelPushCatalogItem } from "@porulle/core";
|
|
2
2
|
import type { CatalogFieldMapping } from "./catalog-field-mapping.js";
|
|
3
3
|
export declare const connectedStores: import("drizzle-orm/pg-core/table").PgTableWithColumns<{
|
|
4
4
|
name: "connected_stores";
|
|
@@ -1074,7 +1074,7 @@ export declare const channelOrderExports: import("drizzle-orm/pg-core/table").Pg
|
|
|
1074
1074
|
data: {
|
|
1075
1075
|
name: string;
|
|
1076
1076
|
email: string;
|
|
1077
|
-
shippingAddress:
|
|
1077
|
+
shippingAddress: ChannelOrderAddress;
|
|
1078
1078
|
};
|
|
1079
1079
|
driverParam: unknown;
|
|
1080
1080
|
notNull: false;
|
|
@@ -1090,7 +1090,7 @@ export declare const channelOrderExports: import("drizzle-orm/pg-core/table").Pg
|
|
|
1090
1090
|
$type: {
|
|
1091
1091
|
name: string;
|
|
1092
1092
|
email: string;
|
|
1093
|
-
shippingAddress:
|
|
1093
|
+
shippingAddress: ChannelOrderAddress;
|
|
1094
1094
|
};
|
|
1095
1095
|
}>;
|
|
1096
1096
|
state: import("@porulle/core/drizzle").PgColumn<{
|