@porulle/plugin-channel-connector 0.66.0 → 0.68.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 +1 -1
- package/dist/index.js +11 -2
- package/dist/live-credentials.d.ts +6 -1
- package/dist/live-credentials.js +13 -4
- package/dist/oauth-state.d.ts +2 -0
- package/dist/oauth-state.js +4 -0
- package/dist/service.d.ts +23 -0
- package/dist/service.js +31 -3
- package/package.json +4 -4
- package/src/index.ts +11 -2
- package/src/live-credentials.ts +11 -4
- package/src/oauth-state.ts +6 -0
- package/src/service.ts +48 -4
package/dist/index.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type { CatalogFieldMapping, CatalogFieldMappingInput, CatalogFieldMapping
|
|
|
25
25
|
*/
|
|
26
26
|
export declare const CHANNEL_MAX_BATCHES_PER_SWEEP = 5000;
|
|
27
27
|
export { signState, verifyState } from "./oauth-state.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, OnStoreCatalogChanged, StoreConnectActor, StoreReadContext, 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";
|
|
29
29
|
export type { OAuthStatePayload, OAuthStateResult } from "./oauth-state.js";
|
|
30
30
|
export type { ChannelCatalogPush, ChannelCatalogPushEvent, ChannelCatalogConflict, ChannelCatalogConflictEvent, ChannelEntityMapEntry, ChannelExportEvent, ChannelOrderExport, ChannelRefundEvent, ChannelRefundRequest, ConnectedStore, } from "./schema.js";
|
|
31
31
|
export declare function channelConnectorPlugin(options?: ChannelConnectorPluginOptions): import("@porulle/core").CommercePlugin;
|
package/dist/index.js
CHANGED
|
@@ -365,10 +365,16 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
365
365
|
const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
|
|
366
366
|
if (!storeDomain)
|
|
367
367
|
return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
|
|
368
|
+
// Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
|
|
369
|
+
// that shop's other grants, so a refusal at the callback would already have broken them.
|
|
370
|
+
const claims = await service.connectClaims({ orgId, actor, raw, storeDomain });
|
|
371
|
+
if (!claims.ok)
|
|
372
|
+
return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
|
|
368
373
|
const state = signState({
|
|
369
374
|
provider,
|
|
370
375
|
orgId,
|
|
371
376
|
userId,
|
|
377
|
+
claims: claims.value,
|
|
372
378
|
shopDomain: storeDomain,
|
|
373
379
|
exp: Math.floor(Date.now() / 1000) + 600,
|
|
374
380
|
jti: crypto.randomUUID(),
|
|
@@ -423,7 +429,7 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
423
429
|
provider,
|
|
424
430
|
storeDomain: verified.value.shopDomain,
|
|
425
431
|
credentials: completed.value.credentials,
|
|
426
|
-
}, { orgId: verified.value.orgId, userId: verified.value.userId });
|
|
432
|
+
}, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
|
|
427
433
|
if (!connected.ok)
|
|
428
434
|
return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
|
|
429
435
|
return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
|
|
@@ -510,7 +516,10 @@ export function channelConnectorPlugin(options = {}) {
|
|
|
510
516
|
webhookSecret: z.string().min(1).optional(),
|
|
511
517
|
}))
|
|
512
518
|
.handler(async ({ input, orgId, actor, raw }) => {
|
|
513
|
-
|
|
519
|
+
const requested = input;
|
|
520
|
+
const normalize = service.getConnector(requested.provider)?.normalizeStoreDomain;
|
|
521
|
+
const claims = unwrap(await service.connectClaims({ orgId, actor, raw, storeDomain: (normalize ? normalize(requested.storeDomain) : undefined) ?? requested.storeDomain }));
|
|
522
|
+
return unwrap(await service.connectStore(orgId, input, { orgId, userId: actor?.userId ?? null, claims, raw }));
|
|
514
523
|
});
|
|
515
524
|
channels.get("/stores")
|
|
516
525
|
.summary("List connected channel stores")
|
|
@@ -15,10 +15,15 @@ import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, R
|
|
|
15
15
|
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
16
16
|
* later call with a credential error nobody is shown.
|
|
17
17
|
*/
|
|
18
|
-
export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore
|
|
18
|
+
export declare function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options?: {
|
|
19
|
+
force?: boolean;
|
|
20
|
+
}): Promise<Result<ChannelStore, ChannelConnectorError>>;
|
|
19
21
|
/**
|
|
20
22
|
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
21
23
|
* call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
|
|
22
24
|
* credentials never expire.
|
|
25
|
+
*
|
|
26
|
+
* A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
|
|
27
|
+
* stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
|
|
23
28
|
*/
|
|
24
29
|
export declare function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector;
|
package/dist/live-credentials.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Err, Ok } from "@porulle/core";
|
|
1
|
+
import { CHANNEL_CREDENTIALS_REJECTED, Err, Ok } from "@porulle/core";
|
|
2
2
|
import { and, eq, sql } from "@porulle/core/drizzle";
|
|
3
3
|
import { connectedStores } from "./schema.js";
|
|
4
4
|
/**
|
|
@@ -17,10 +17,10 @@ import { connectedStores } from "./schema.js";
|
|
|
17
17
|
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
18
18
|
* later call with a credential error nobody is shown.
|
|
19
19
|
*/
|
|
20
|
-
export async function resolveLiveCredentials(connector, db, store) {
|
|
20
|
+
export async function resolveLiveCredentials(connector, db, store, options = {}) {
|
|
21
21
|
if (!connector.liveCredentials)
|
|
22
22
|
return Ok(store);
|
|
23
|
-
const answer = await connector.liveCredentials(store);
|
|
23
|
+
const answer = await connector.liveCredentials(store, options);
|
|
24
24
|
if (!answer.ok) {
|
|
25
25
|
if (answer.error.retriable !== true) {
|
|
26
26
|
await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
@@ -44,6 +44,9 @@ export async function resolveLiveCredentials(connector, db, store) {
|
|
|
44
44
|
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
45
45
|
* call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
|
|
46
46
|
* credentials never expire.
|
|
47
|
+
*
|
|
48
|
+
* A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
|
|
49
|
+
* stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
|
|
47
50
|
*/
|
|
48
51
|
export function withLiveCredentials(connector, db) {
|
|
49
52
|
if (!connector.liveCredentials)
|
|
@@ -52,7 +55,13 @@ export function withLiveCredentials(connector, db) {
|
|
|
52
55
|
const current = await resolveLiveCredentials(connector, db, store);
|
|
53
56
|
if (!current.ok)
|
|
54
57
|
return current;
|
|
55
|
-
|
|
58
|
+
const first = await call.call(connector, current.value, ...args);
|
|
59
|
+
if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED)
|
|
60
|
+
return first;
|
|
61
|
+
const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
|
|
62
|
+
if (!refreshed.ok)
|
|
63
|
+
return refreshed;
|
|
64
|
+
return call.call(connector, refreshed.value, ...args);
|
|
56
65
|
};
|
|
57
66
|
return {
|
|
58
67
|
...connector,
|
package/dist/oauth-state.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export interface OAuthStatePayload {
|
|
|
3
3
|
orgId: string;
|
|
4
4
|
/** The signed-in user who started the connection: the callback arrives with no session of its own. */
|
|
5
5
|
userId: string;
|
|
6
|
+
/** The consumer's connect claims, resolved at start. */
|
|
7
|
+
claims: Record<string, string>;
|
|
6
8
|
shopDomain: string;
|
|
7
9
|
exp: number;
|
|
8
10
|
jti: string;
|
package/dist/oauth-state.js
CHANGED
|
@@ -68,6 +68,10 @@ export function verifyState(state, secret, now = Math.floor(Date.now() / 1000))
|
|
|
68
68
|
return { ok: false, error: "Malformed OAuth state payload." };
|
|
69
69
|
if (exp <= now)
|
|
70
70
|
return { ok: false, error: "OAuth state has expired." };
|
|
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." };
|
|
74
|
+
}
|
|
71
75
|
return { ok: true, value: candidate };
|
|
72
76
|
}
|
|
73
77
|
export function oauthStateEventId(jti) {
|
package/dist/service.d.ts
CHANGED
|
@@ -109,9 +109,25 @@ export type ConfineStores = (context: StoreReadContext) => Promise<readonly stri
|
|
|
109
109
|
export interface StoreConnectActor {
|
|
110
110
|
orgId: string;
|
|
111
111
|
userId: string | null;
|
|
112
|
+
/**
|
|
113
|
+
* What the consumer's {@link ConnectClaims} resolved when the connection started — e.g. which of
|
|
114
|
+
* the user's vendors the store is for. Carried in the signed OAuth state, so the callback, which has
|
|
115
|
+
* no session or headers of its own, binds the store to exactly what was chosen at the start.
|
|
116
|
+
*/
|
|
117
|
+
claims: Readonly<Record<string, string>>;
|
|
112
118
|
/** Core's request escape hatch when the connect is a request; absent on the OAuth callback. */
|
|
113
119
|
raw?: unknown;
|
|
114
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
|
|
123
|
+
* later. Throw to refuse the start — before the merchant is sent to the provider, which matters: a
|
|
124
|
+
* provider such as Shopify retires a shop's other grants the moment a new one is issued, so a
|
|
125
|
+
* connection refused only at the callback has already broken the shop's existing one. `storeDomain` is
|
|
126
|
+
* the store being connected, canonicalised. Runs for OAuth start and for `POST /stores`.
|
|
127
|
+
*/
|
|
128
|
+
export type ConnectClaims = (context: StoreReadContext & {
|
|
129
|
+
storeDomain: string;
|
|
130
|
+
}) => Promise<Record<string, string>> | Record<string, string>;
|
|
115
131
|
/**
|
|
116
132
|
* Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
|
|
117
133
|
* that wrote the store row — so a store and its binding commit together or not at all. Throw to
|
|
@@ -127,6 +143,7 @@ export type AfterStoreConnected = (input: {
|
|
|
127
143
|
store: ConnectedStore;
|
|
128
144
|
actor: StoreConnectActor;
|
|
129
145
|
connector: ChannelConnector;
|
|
146
|
+
services: Record<string, unknown>;
|
|
130
147
|
}) => Promise<void>;
|
|
131
148
|
/** Entities a provider webhook just created or changed, converged; the host projects them. */
|
|
132
149
|
export type OnStoreCatalogChanged = (input: {
|
|
@@ -154,6 +171,8 @@ export interface ChannelConnectorPluginOptions {
|
|
|
154
171
|
* unaffected.
|
|
155
172
|
*/
|
|
156
173
|
confineStores?: ConfineStores;
|
|
174
|
+
/** See {@link ConnectClaims}. Absent, a connection carries no claims. */
|
|
175
|
+
connectClaims?: ConnectClaims;
|
|
157
176
|
/** See {@link BindConnectedStore}. */
|
|
158
177
|
bindConnectedStore?: BindConnectedStore;
|
|
159
178
|
/** See {@link AfterStoreConnected}. */
|
|
@@ -495,6 +514,10 @@ export declare class ChannelConnectorService {
|
|
|
495
514
|
}, actor: StoreConnectActor): Promise<PluginResult<PublicConnectedStore>>;
|
|
496
515
|
/** The store with credentials good for a call the host makes itself, e.g. its own Admin API write. */
|
|
497
516
|
liveStore(orgId: string, storeId: string): Promise<PluginResult<ChannelStore>>;
|
|
517
|
+
/** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
|
|
518
|
+
connectClaims(context: StoreReadContext & {
|
|
519
|
+
storeDomain: string;
|
|
520
|
+
}): Promise<PluginResult<Record<string, string>>>;
|
|
498
521
|
/** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
|
|
499
522
|
private allowedStores;
|
|
500
523
|
/** NOT_FOUND for a store outside the caller's set, exactly as for one that does not exist. */
|
package/dist/service.js
CHANGED
|
@@ -1299,6 +1299,16 @@ export class ChannelConnectorService {
|
|
|
1299
1299
|
continue;
|
|
1300
1300
|
}
|
|
1301
1301
|
variantIds.set(sourceVariant.externalId, variantId);
|
|
1302
|
+
// The provider's per-variant facts (the inventory item a stock webhook names, the weight shipping
|
|
1303
|
+
// prices by) merge into the variant per key, as entity metadata does: the source's keys overwrite
|
|
1304
|
+
// their own and nothing else. The import fast path writes them at creation; without this the
|
|
1305
|
+
// editor path — every reconcile — left them off, and a stock webhook could not find its variant.
|
|
1306
|
+
const sourceMetadata = sourceVariant.metadata ?? {};
|
|
1307
|
+
if (Object.keys(sourceMetadata).length > 0) {
|
|
1308
|
+
await this.db.update(variants)
|
|
1309
|
+
.set({ metadata: sql `coalesce(${variants.metadata}, '{}'::jsonb) || ${JSON.stringify(sourceMetadata)}::jsonb` })
|
|
1310
|
+
.where(and(eq(variants.id, variantId), sql `not (coalesce(${variants.metadata}, '{}'::jsonb) @> ${JSON.stringify(sourceMetadata)}::jsonb)`));
|
|
1311
|
+
}
|
|
1302
1312
|
if (applyOptionValues) {
|
|
1303
1313
|
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
1304
1314
|
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
@@ -2269,7 +2279,7 @@ export class ChannelConnectorService {
|
|
|
2269
2279
|
}
|
|
2270
2280
|
if (this.options.afterStoreConnected) {
|
|
2271
2281
|
try {
|
|
2272
|
-
await this.options.afterStoreConnected({ store, actor, connector });
|
|
2282
|
+
await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
|
|
2273
2283
|
}
|
|
2274
2284
|
catch (error) {
|
|
2275
2285
|
return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
|
|
@@ -2288,6 +2298,17 @@ export class ChannelConnectorService {
|
|
|
2288
2298
|
const live = await resolveLiveCredentials(connector, this.db, store);
|
|
2289
2299
|
return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
|
|
2290
2300
|
}
|
|
2301
|
+
/** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
|
|
2302
|
+
async connectClaims(context) {
|
|
2303
|
+
if (!this.options.connectClaims)
|
|
2304
|
+
return Ok({});
|
|
2305
|
+
try {
|
|
2306
|
+
return Ok(await this.options.connectClaims(context));
|
|
2307
|
+
}
|
|
2308
|
+
catch (error) {
|
|
2309
|
+
return PluginErr(error instanceof Error ? error.message : "The connection could not be started.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "CONNECT_REFUSED");
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2291
2312
|
/** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
|
|
2292
2313
|
async allowedStores(orgId, context) {
|
|
2293
2314
|
return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : null;
|
|
@@ -3465,14 +3486,21 @@ export class ChannelConnectorService {
|
|
|
3465
3486
|
archived += 1;
|
|
3466
3487
|
}
|
|
3467
3488
|
}
|
|
3468
|
-
|
|
3489
|
+
// Stock is levelled against the mappings as they stand AFTER convergence. Read before it, as the
|
|
3490
|
+
// archive plan above must be, a first import walked an empty list and left every product it had
|
|
3491
|
+
// just created with no inventory level at all. Re-read only when convergence wrote something: an
|
|
3492
|
+
// unchanged reconcile creates no mapping and keeps its statement budget.
|
|
3493
|
+
const levelled = converged.value.imported + converged.value.converged > 0
|
|
3494
|
+
? await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)))
|
|
3495
|
+
: mappings;
|
|
3496
|
+
const inventory = await connector.fetchInventory(store, levelled.map((mapping) => mapping.externalId));
|
|
3469
3497
|
if (!inventory.ok)
|
|
3470
3498
|
return PluginErr(inventory.error.message);
|
|
3471
3499
|
const existingLevels = await this.db.select().from(inventoryLevels).where(eq(inventoryLevels.organizationId, orgId));
|
|
3472
3500
|
const inventoryService = this.services.inventory;
|
|
3473
3501
|
let inventoryUpdated = 0;
|
|
3474
3502
|
for (const level of inventory.value) {
|
|
3475
|
-
const mapping =
|
|
3503
|
+
const mapping = levelled.find((entry) => entry.externalId === level.externalId);
|
|
3476
3504
|
if (!mapping)
|
|
3477
3505
|
continue;
|
|
3478
3506
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/plugin-channel-connector",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -22,15 +22,15 @@
|
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@hono/zod-openapi": "^1.2.2",
|
|
24
24
|
"hono": "^4.12.5",
|
|
25
|
-
"@porulle/core": "0.
|
|
25
|
+
"@porulle/core": "0.68.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^24.5.2",
|
|
29
29
|
"eslint": "^9.39.1",
|
|
30
30
|
"typescript": "5.9.2",
|
|
31
31
|
"vitest": "^3.2.4",
|
|
32
|
-
"@porulle/
|
|
33
|
-
"@porulle/
|
|
32
|
+
"@porulle/typescript-config": "0.1.0",
|
|
33
|
+
"@porulle/eslint-config": "0.1.0"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -238,6 +238,7 @@ export type {
|
|
|
238
238
|
ChannelComplianceData,
|
|
239
239
|
ChannelConnectorPluginOptions,
|
|
240
240
|
ConfineStores,
|
|
241
|
+
ConnectClaims,
|
|
241
242
|
OnStoreCatalogChanged,
|
|
242
243
|
StoreConnectActor,
|
|
243
244
|
StoreReadContext,
|
|
@@ -534,10 +535,15 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
534
535
|
const typed = String((query as { shop?: string; store?: string }).shop ?? (query as { store?: string }).store ?? "");
|
|
535
536
|
const storeDomain = connector.normalizeStoreDomain ? connector.normalizeStoreDomain(typed) : typed;
|
|
536
537
|
if (!storeDomain) return connectOutcome(oauth.postConnectRedirect, { error: "INVALID_STORE_DOMAIN", message: `"${typed}" does not name a ${provider} store.` });
|
|
538
|
+
// Refused HERE, before the merchant reaches the provider: a grant issued for a shop retires
|
|
539
|
+
// that shop's other grants, so a refusal at the callback would already have broken them.
|
|
540
|
+
const claims = await service.connectClaims({ orgId, actor, raw, storeDomain });
|
|
541
|
+
if (!claims.ok) return connectOutcome(oauth.postConnectRedirect, { error: claims.code ?? "CONNECT_REFUSED", message: claims.error });
|
|
537
542
|
const state = signState({
|
|
538
543
|
provider,
|
|
539
544
|
orgId,
|
|
540
545
|
userId,
|
|
546
|
+
claims: claims.value,
|
|
541
547
|
shopDomain: storeDomain,
|
|
542
548
|
exp: Math.floor(Date.now() / 1000) + 600,
|
|
543
549
|
jti: crypto.randomUUID(),
|
|
@@ -583,7 +589,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
583
589
|
provider,
|
|
584
590
|
storeDomain: verified.value.shopDomain,
|
|
585
591
|
credentials: completed.value.credentials,
|
|
586
|
-
}, { orgId: verified.value.orgId, userId: verified.value.userId });
|
|
592
|
+
}, { orgId: verified.value.orgId, userId: verified.value.userId, claims: verified.value.claims });
|
|
587
593
|
if (!connected.ok) return refused(connected.code ?? "STORE_CONNECTION_FAILED", connected.error);
|
|
588
594
|
return connectOutcome(oauth.postConnectRedirect, { connected: connected.value.id });
|
|
589
595
|
};
|
|
@@ -665,6 +671,9 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
665
671
|
webhookSecret: z.string().min(1).optional(),
|
|
666
672
|
}))
|
|
667
673
|
.handler(async ({ input, orgId, actor, raw }: ChannelRouteContext) => {
|
|
674
|
+
const requested = input as { provider: string; storeDomain: string };
|
|
675
|
+
const normalize = service.getConnector(requested.provider)?.normalizeStoreDomain;
|
|
676
|
+
const claims = unwrap(await service.connectClaims({ orgId, actor, raw, storeDomain: (normalize ? normalize(requested.storeDomain) : undefined) ?? requested.storeDomain }));
|
|
668
677
|
return unwrap(await service.connectStore(
|
|
669
678
|
orgId,
|
|
670
679
|
input as {
|
|
@@ -673,7 +682,7 @@ export function channelConnectorPlugin(options: ChannelConnectorPluginOptions =
|
|
|
673
682
|
storeDomain: string;
|
|
674
683
|
webhookSecret?: string;
|
|
675
684
|
},
|
|
676
|
-
{ orgId, userId: actor?.userId ?? null, raw },
|
|
685
|
+
{ orgId, userId: actor?.userId ?? null, claims, raw },
|
|
677
686
|
));
|
|
678
687
|
});
|
|
679
688
|
|
package/src/live-credentials.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Err, Ok } from "@porulle/core";
|
|
1
|
+
import { CHANNEL_CREDENTIALS_REJECTED, Err, Ok } from "@porulle/core";
|
|
2
2
|
import type { ChannelConnector, ChannelConnectorError, ChannelStore, PluginDb, Result } from "@porulle/core";
|
|
3
3
|
import { and, eq, sql } from "@porulle/core/drizzle";
|
|
4
4
|
import { connectedStores } from "./schema.js";
|
|
@@ -19,9 +19,9 @@ import { connectedStores } from "./schema.js";
|
|
|
19
19
|
* token lapsed. The store is then marked `error`, so it reads as "reconnect" instead of failing every
|
|
20
20
|
* later call with a credential error nobody is shown.
|
|
21
21
|
*/
|
|
22
|
-
export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore): Promise<Result<ChannelStore, ChannelConnectorError>> {
|
|
22
|
+
export async function resolveLiveCredentials(connector: ChannelConnector, db: PluginDb, store: ChannelStore, options: { force?: boolean } = {}): Promise<Result<ChannelStore, ChannelConnectorError>> {
|
|
23
23
|
if (!connector.liveCredentials) return Ok(store);
|
|
24
|
-
const answer = await connector.liveCredentials(store);
|
|
24
|
+
const answer = await connector.liveCredentials(store, options);
|
|
25
25
|
if (!answer.ok) {
|
|
26
26
|
if (answer.error.retriable !== true) {
|
|
27
27
|
await db.update(connectedStores).set({ status: "error", updatedAt: new Date() }).where(eq(connectedStores.id, store.id));
|
|
@@ -45,13 +45,20 @@ type StoreCall<A extends unknown[], T> = (store: ChannelStore, ...args: A) => Pr
|
|
|
45
45
|
* The connector with every store-taking method routed through {@link resolveLiveCredentials}, so no
|
|
46
46
|
* call site can start on a lapsed token by forgetting to ask. Returned unchanged when the connector's
|
|
47
47
|
* credentials never expire.
|
|
48
|
+
*
|
|
49
|
+
* A call the provider answers with {@link CHANNEL_CREDENTIALS_REJECTED} — a token retired before its
|
|
50
|
+
* stated expiry — is retried ONCE on credentials refreshed by force. A second rejection is the answer.
|
|
48
51
|
*/
|
|
49
52
|
export function withLiveCredentials(connector: ChannelConnector, db: PluginDb): ChannelConnector {
|
|
50
53
|
if (!connector.liveCredentials) return connector;
|
|
51
54
|
const around = <A extends unknown[], T>(call: StoreCall<A, T>): StoreCall<A, T> => async (store, ...args) => {
|
|
52
55
|
const current = await resolveLiveCredentials(connector, db, store);
|
|
53
56
|
if (!current.ok) return current;
|
|
54
|
-
|
|
57
|
+
const first = await call.call(connector, current.value, ...args);
|
|
58
|
+
if (first.ok || first.error.code !== CHANNEL_CREDENTIALS_REJECTED) return first;
|
|
59
|
+
const refreshed = await resolveLiveCredentials(connector, db, current.value, { force: true });
|
|
60
|
+
if (!refreshed.ok) return refreshed;
|
|
61
|
+
return call.call(connector, refreshed.value, ...args);
|
|
55
62
|
};
|
|
56
63
|
return {
|
|
57
64
|
...connector,
|
package/src/oauth-state.ts
CHANGED
|
@@ -5,6 +5,8 @@ export interface OAuthStatePayload {
|
|
|
5
5
|
orgId: string;
|
|
6
6
|
/** The signed-in user who started the connection: the callback arrives with no session of its own. */
|
|
7
7
|
userId: string;
|
|
8
|
+
/** The consumer's connect claims, resolved at start. */
|
|
9
|
+
claims: Record<string, string>;
|
|
8
10
|
shopDomain: string;
|
|
9
11
|
exp: number;
|
|
10
12
|
jti: string;
|
|
@@ -86,6 +88,10 @@ export function verifyState(
|
|
|
86
88
|
!Number.isInteger(exp)
|
|
87
89
|
) return { ok: false, error: "Malformed OAuth state payload." };
|
|
88
90
|
if (exp <= now) return { ok: false, error: "OAuth state has expired." };
|
|
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." };
|
|
94
|
+
}
|
|
89
95
|
|
|
90
96
|
return { ok: true, value: candidate as OAuthStatePayload };
|
|
91
97
|
}
|
package/src/service.ts
CHANGED
|
@@ -254,10 +254,25 @@ export type ConfineStores = (context: StoreReadContext) => Promise<readonly stri
|
|
|
254
254
|
export interface StoreConnectActor {
|
|
255
255
|
orgId: string;
|
|
256
256
|
userId: string | null;
|
|
257
|
+
/**
|
|
258
|
+
* What the consumer's {@link ConnectClaims} resolved when the connection started — e.g. which of
|
|
259
|
+
* the user's vendors the store is for. Carried in the signed OAuth state, so the callback, which has
|
|
260
|
+
* no session or headers of its own, binds the store to exactly what was chosen at the start.
|
|
261
|
+
*/
|
|
262
|
+
claims: Readonly<Record<string, string>>;
|
|
257
263
|
/** Core's request escape hatch when the connect is a request; absent on the OAuth callback. */
|
|
258
264
|
raw?: unknown;
|
|
259
265
|
}
|
|
260
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Resolves, from the request that STARTS a connection, the facts the consumer needs to bind the store
|
|
269
|
+
* later. Throw to refuse the start — before the merchant is sent to the provider, which matters: a
|
|
270
|
+
* provider such as Shopify retires a shop's other grants the moment a new one is issued, so a
|
|
271
|
+
* connection refused only at the callback has already broken the shop's existing one. `storeDomain` is
|
|
272
|
+
* the store being connected, canonicalised. Runs for OAuth start and for `POST /stores`.
|
|
273
|
+
*/
|
|
274
|
+
export type ConnectClaims = (context: StoreReadContext & { storeDomain: string }) => Promise<Record<string, string>> | Record<string, string>;
|
|
275
|
+
|
|
261
276
|
/**
|
|
262
277
|
* Binds a just-connected store to whatever the consumer means by an owner, INSIDE the transaction
|
|
263
278
|
* that wrote the store row — so a store and its binding commit together or not at all. Throw to
|
|
@@ -266,7 +281,7 @@ export interface StoreConnectActor {
|
|
|
266
281
|
export type BindConnectedStore = (input: { db: PluginDb; store: ConnectedStore; actor: StoreConnectActor }) => Promise<void>;
|
|
267
282
|
|
|
268
283
|
/** Work that follows a committed connection: the first import, provider-attested facts, keys. */
|
|
269
|
-
export type AfterStoreConnected = (input: { store: ConnectedStore; actor: StoreConnectActor; connector: ChannelConnector }) => Promise<void>;
|
|
284
|
+
export type AfterStoreConnected = (input: { store: ConnectedStore; actor: StoreConnectActor; connector: ChannelConnector; services: Record<string, unknown> }) => Promise<void>;
|
|
270
285
|
|
|
271
286
|
/** Entities a provider webhook just created or changed, converged; the host projects them. */
|
|
272
287
|
export type OnStoreCatalogChanged = (input: { orgId: string; storeId: string; entityIds: string[]; convergence: CatalogPageConvergence }) => Promise<void>;
|
|
@@ -288,6 +303,8 @@ export interface ChannelConnectorPluginOptions {
|
|
|
288
303
|
* unaffected.
|
|
289
304
|
*/
|
|
290
305
|
confineStores?: ConfineStores;
|
|
306
|
+
/** See {@link ConnectClaims}. Absent, a connection carries no claims. */
|
|
307
|
+
connectClaims?: ConnectClaims;
|
|
291
308
|
/** See {@link BindConnectedStore}. */
|
|
292
309
|
bindConnectedStore?: BindConnectedStore;
|
|
293
310
|
/** See {@link AfterStoreConnected}. */
|
|
@@ -2043,6 +2060,16 @@ export class ChannelConnectorService {
|
|
|
2043
2060
|
continue;
|
|
2044
2061
|
}
|
|
2045
2062
|
variantIds.set(sourceVariant.externalId, variantId);
|
|
2063
|
+
// The provider's per-variant facts (the inventory item a stock webhook names, the weight shipping
|
|
2064
|
+
// prices by) merge into the variant per key, as entity metadata does: the source's keys overwrite
|
|
2065
|
+
// their own and nothing else. The import fast path writes them at creation; without this the
|
|
2066
|
+
// editor path — every reconcile — left them off, and a stock webhook could not find its variant.
|
|
2067
|
+
const sourceMetadata = sourceVariant.metadata ?? {};
|
|
2068
|
+
if (Object.keys(sourceMetadata).length > 0) {
|
|
2069
|
+
await this.db.update(variants)
|
|
2070
|
+
.set({ metadata: sql`coalesce(${variants.metadata}, '{}'::jsonb) || ${JSON.stringify(sourceMetadata)}::jsonb` })
|
|
2071
|
+
.where(and(eq(variants.id, variantId), sql`not (coalesce(${variants.metadata}, '{}'::jsonb) @> ${JSON.stringify(sourceMetadata)}::jsonb)`));
|
|
2072
|
+
}
|
|
2046
2073
|
if (applyOptionValues) {
|
|
2047
2074
|
const desiredOptionValueIds = Object.entries(sourceVariant.optionValues ?? {})
|
|
2048
2075
|
.map(([name, value]) => optionValueIds.get(name)?.get(value))
|
|
@@ -3165,7 +3192,7 @@ export class ChannelConnectorService {
|
|
|
3165
3192
|
}
|
|
3166
3193
|
if (this.options.afterStoreConnected) {
|
|
3167
3194
|
try {
|
|
3168
|
-
await this.options.afterStoreConnected({ store, actor, connector });
|
|
3195
|
+
await this.options.afterStoreConnected({ store, actor, connector, services: this.services });
|
|
3169
3196
|
} catch (error) {
|
|
3170
3197
|
return PluginErr(error instanceof Error ? error.message : "The store connected but its follow-on work failed.", "AFTER_CONNECT_FAILED");
|
|
3171
3198
|
}
|
|
@@ -3183,6 +3210,16 @@ export class ChannelConnectorService {
|
|
|
3183
3210
|
return live.ok ? Ok(live.value) : PluginErr(live.error.message, live.error.code);
|
|
3184
3211
|
}
|
|
3185
3212
|
|
|
3213
|
+
/** The consumer's claims for a connection starting from this request. See {@link ConnectClaims}. */
|
|
3214
|
+
async connectClaims(context: StoreReadContext & { storeDomain: string }): Promise<PluginResult<Record<string, string>>> {
|
|
3215
|
+
if (!this.options.connectClaims) return Ok({});
|
|
3216
|
+
try {
|
|
3217
|
+
return Ok(await this.options.connectClaims(context));
|
|
3218
|
+
} catch (error) {
|
|
3219
|
+
return PluginErr(error instanceof Error ? error.message : "The connection could not be started.", error instanceof CommerceNotFoundError ? "NOT_FOUND" : "CONNECT_REFUSED");
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3186
3223
|
/** The caller's allow-list, or null for unconfined. See {@link ConfineStores}. */
|
|
3187
3224
|
private async allowedStores(orgId: string, context: StoreReadContext | undefined): Promise<readonly string[] | null> {
|
|
3188
3225
|
return this.options.confineStores ? await this.options.confineStores(context ?? { orgId, actor: null, raw: undefined }) : null;
|
|
@@ -4522,7 +4559,14 @@ export class ChannelConnectorService {
|
|
|
4522
4559
|
}
|
|
4523
4560
|
}
|
|
4524
4561
|
|
|
4525
|
-
|
|
4562
|
+
// Stock is levelled against the mappings as they stand AFTER convergence. Read before it, as the
|
|
4563
|
+
// archive plan above must be, a first import walked an empty list and left every product it had
|
|
4564
|
+
// just created with no inventory level at all. Re-read only when convergence wrote something: an
|
|
4565
|
+
// unchanged reconcile creates no mapping and keeps its statement budget.
|
|
4566
|
+
const levelled = converged.value.imported + converged.value.converged > 0
|
|
4567
|
+
? await this.db.select().from(channelEntityMap).where(and(eq(channelEntityMap.organizationId, orgId), eq(channelEntityMap.storeId, storeId)))
|
|
4568
|
+
: mappings;
|
|
4569
|
+
const inventory = await connector.fetchInventory(store as ChannelStore, levelled.map((mapping) => mapping.externalId));
|
|
4526
4570
|
if (!inventory.ok) return PluginErr(inventory.error.message);
|
|
4527
4571
|
const existingLevels = await this.db.select().from(inventoryLevels).where(eq(inventoryLevels.organizationId, orgId));
|
|
4528
4572
|
const inventoryService = this.services.inventory as {
|
|
@@ -4530,7 +4574,7 @@ export class ChannelConnectorService {
|
|
|
4530
4574
|
};
|
|
4531
4575
|
let inventoryUpdated = 0;
|
|
4532
4576
|
for (const level of inventory.value) {
|
|
4533
|
-
const mapping =
|
|
4577
|
+
const mapping = levelled.find((entry) => entry.externalId === level.externalId);
|
|
4534
4578
|
if (!mapping) continue;
|
|
4535
4579
|
const current = existingLevels.find((entry) => entry.entityId === mapping.entityId && entry.variantId === (mapping.variantId ?? null));
|
|
4536
4580
|
// Stock cannot sit below zero here, so negative remote stock compares as the zero it is stored as.
|