@shopimind/integration-kit-js 1.3.0 → 1.4.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/README.md +1 -1
- package/dist/contracts/lifecycle.d.ts +31 -1
- package/dist/contracts/sdk.d.ts +24 -4
- package/dist/http/routes.d.ts +11 -0
- package/dist/http/routes.js +53 -13
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/integration/define-bulk-step.d.ts +41 -0
- package/dist/integration/define-bulk-step.js +44 -0
- package/dist/integration/define-integration.d.ts +23 -0
- package/dist/integration/define-integration.js +23 -0
- package/dist/integration/types.d.ts +9 -1
- package/dist/lifecycle/dispatcher.d.ts +6 -1
- package/dist/lifecycle/dispatcher.js +8 -9
- package/dist/provisioning/ensure.d.ts +11 -5
- package/dist/provisioning/ensure.js +44 -8
- package/dist/provisioning/runner.d.ts +11 -1
- package/dist/provisioning/runner.js +85 -13
- package/dist/runtime/create-app.d.ts +12 -1
- package/dist/runtime/create-app.js +22 -3
- package/dist/runtime/health.d.ts +62 -0
- package/dist/runtime/health.js +70 -0
- package/dist/runtime/outbound.d.ts +71 -0
- package/dist/runtime/outbound.js +90 -0
- package/dist/security/signature.d.ts +9 -0
- package/dist/security/signature.js +25 -0
- package/dist/store/migrations.js +37 -0
- package/dist/store/repositories.d.ts +35 -1
- package/dist/store/repositories.js +66 -2
- package/dist/store/types.d.ts +25 -1
- package/dist/sync/engine.d.ts +44 -2
- package/dist/sync/engine.js +154 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ export const integration = defineIntegration({
|
|
|
62
62
|
cursorScope: 'global',
|
|
63
63
|
enabled: (s) => s.syncCustomers,
|
|
64
64
|
run: async (ctx) => {
|
|
65
|
-
// ctx.
|
|
65
|
+
// ctx.sendBulk(fn, items) — safe push · ctx.spm + SDK statics · ctx.paginate(...) · ctx.state · ctx.logger
|
|
66
66
|
return { items: 0, errors: [] };
|
|
67
67
|
},
|
|
68
68
|
},
|
|
@@ -4,10 +4,40 @@ import type { RawConfigs } from './common.js';
|
|
|
4
4
|
* HMAC-signed.
|
|
5
5
|
*/
|
|
6
6
|
export type LifecycleEvent = 'integration.installed' | 'integration.activated' | 'integration.deactivated' | 'integration.uninstalled' | 'integration.config_updated';
|
|
7
|
+
/**
|
|
8
|
+
* Common fields of a lifecycle webhook payload.
|
|
9
|
+
*
|
|
10
|
+
* IDENTITY CONTRACT — `installation_id` is the SINGLE source of truth. It is the
|
|
11
|
+
* OPAQUE token ShopiMind issues per installation; the integration never
|
|
12
|
+
* interprets it, it only correlates by it (see the store schema). NestJS sends
|
|
13
|
+
* `{ event, installation_id, ... }` — nothing else is required to route the event.
|
|
14
|
+
*
|
|
15
|
+
* The `id_shop_integration` numeric alias is LEGACY: it predates the opaque token
|
|
16
|
+
* and is kept ONLY so an older ShopiMind deployment (or an old fixture) that still
|
|
17
|
+
* emits it keeps working. The dispatcher reads `installation_id` FIRST and falls
|
|
18
|
+
* back to `id_shop_integration` only when the opaque token is absent. New payloads
|
|
19
|
+
* must not rely on it.
|
|
20
|
+
*/
|
|
7
21
|
export interface LifecyclePayloadBase {
|
|
8
22
|
event: LifecycleEvent;
|
|
9
|
-
|
|
23
|
+
/** OPAQUE installation token — the required identity of the installation. */
|
|
24
|
+
installation_id: string;
|
|
25
|
+
/**
|
|
26
|
+
* @deprecated Legacy numeric alias for `installation_id`. Retained for
|
|
27
|
+
* backward compatibility with older ShopiMind deployments/fixtures; the
|
|
28
|
+
* dispatcher only uses it as a fallback when `installation_id` is absent.
|
|
29
|
+
*/
|
|
30
|
+
id_shop_integration?: number;
|
|
31
|
+
/**
|
|
32
|
+
* @deprecated ShopiMind-internal shop id. Never sent by the current wire format
|
|
33
|
+
* and never needed by an integration (which correlates by `installation_id`).
|
|
34
|
+
* Kept optional so an old payload carrying it does not fail to type-check.
|
|
35
|
+
*/
|
|
10
36
|
id_shop?: number;
|
|
37
|
+
/**
|
|
38
|
+
* @deprecated The integration already knows its own slug (`integration.slug`);
|
|
39
|
+
* ShopiMind does not send this. Kept optional for backward compatibility only.
|
|
40
|
+
*/
|
|
11
41
|
integration_slug?: string;
|
|
12
42
|
shop_domain?: string;
|
|
13
43
|
shop_name?: string;
|
package/dist/contracts/sdk.d.ts
CHANGED
|
@@ -9,6 +9,18 @@ export interface NewDataSource {
|
|
|
9
9
|
type: string;
|
|
10
10
|
parent_id?: number;
|
|
11
11
|
config?: string;
|
|
12
|
+
/**
|
|
13
|
+
* STABLE MATCHING KEY (E16). Name of a property inside the source's `config`
|
|
14
|
+
* (parsed as JSON) that uniquely and PERMANENTLY identifies this source — e.g.
|
|
15
|
+
* `'hiboutik_store_id'`. When set, `ensureDataSource` matches an existing source by
|
|
16
|
+
* `config[stableConfigKey]` FIRST, falling back to the `label` only if no config
|
|
17
|
+
* match is found. This lets a source survive a LABEL RENAME (a merchant renaming
|
|
18
|
+
* their store no longer spawns a duplicate; the existing source's label is updated
|
|
19
|
+
* to the new one). Omit it to keep the legacy label-only behaviour unchanged.
|
|
20
|
+
*
|
|
21
|
+
* This field is kit-only authoring metadata: it is NOT forwarded to the API.
|
|
22
|
+
*/
|
|
23
|
+
stableConfigKey?: string;
|
|
12
24
|
}
|
|
13
25
|
/**
|
|
14
26
|
* Canonical type of a custom data field, aligned with the ShopiMind API / SDK.
|
|
@@ -53,14 +65,22 @@ export interface NewCustomDataDefinition {
|
|
|
53
65
|
targetField?: string;
|
|
54
66
|
}>;
|
|
55
67
|
}
|
|
56
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Order status to provision (declaration).
|
|
70
|
+
*
|
|
71
|
+
* `status_id`, `lang` and `name` are the AUTHORING essentials. The technical
|
|
72
|
+
* bookkeeping fields (`is_deleted`, `created_at`, `updated_at`) are OPTIONAL (E11):
|
|
73
|
+
* the kit fills sensible defaults at provisioning time (`is_deleted: false`,
|
|
74
|
+
* timestamps: now), so an author no longer has to hand-write ceremony the API needs
|
|
75
|
+
* but the integration doesn't care about. Supplying them explicitly still works.
|
|
76
|
+
*/
|
|
57
77
|
export interface SpmOrderStatus {
|
|
58
78
|
status_id: string;
|
|
59
79
|
lang: string;
|
|
60
80
|
name: string;
|
|
61
|
-
is_deleted
|
|
62
|
-
created_at
|
|
63
|
-
updated_at
|
|
81
|
+
is_deleted?: boolean;
|
|
82
|
+
created_at?: string;
|
|
83
|
+
updated_at?: string;
|
|
64
84
|
id_data_source?: number;
|
|
65
85
|
}
|
|
66
86
|
/**
|
package/dist/http/routes.d.ts
CHANGED
|
@@ -14,5 +14,16 @@ export interface RouteDeps<S> {
|
|
|
14
14
|
webhookRateLimit?(key: string): boolean;
|
|
15
15
|
runSyncForInstall(id: string, full: boolean): Promise<unknown>;
|
|
16
16
|
recentRuns(id: string): unknown;
|
|
17
|
+
/** E4 — dead-lettered rejected items for an installation (bounded). */
|
|
18
|
+
rejectedItems?(id: string, limit: number): unknown;
|
|
19
|
+
/**
|
|
20
|
+
* E5 — enriched health snapshot (DB ping, run ages, cursors in error). Routes
|
|
21
|
+
* only reads `status` (to pick 200 vs 503) and forwards the whole object as JSON.
|
|
22
|
+
*/
|
|
23
|
+
healthReport?(): {
|
|
24
|
+
status: 'ok' | 'degraded';
|
|
25
|
+
};
|
|
26
|
+
/** E5 — JSON overview across installations (admin). */
|
|
27
|
+
overview?(): object;
|
|
17
28
|
}
|
|
18
29
|
export declare function buildRoutes<S>(deps: RouteDeps<S>): ServerRoute[];
|
package/dist/http/routes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { handleWebhook, handleTestConnection, handleRemoteData, } from '../lifecycle/dispatcher.js';
|
|
3
3
|
import { handleInbound } from '../lifecycle/inbound.js';
|
|
4
|
-
import {
|
|
4
|
+
import { verifyShopimindSignatureMulti } from '../security/signature.js';
|
|
5
5
|
/** Ephemeral (per-process) HMAC key to compare the admin token at fixed length. */
|
|
6
6
|
const ADMIN_CMP_KEY = randomBytes(32);
|
|
7
7
|
/** Constant-time comparison WITHOUT length leakage (compares HMAC digests). */
|
|
@@ -28,13 +28,12 @@ const rawBody = (req) => {
|
|
|
28
28
|
return '';
|
|
29
29
|
return Buffer.isBuffer(p) ? p.toString('utf8') : String(p);
|
|
30
30
|
};
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return o;
|
|
31
|
+
/** Verifies a ShopiMind signature against the dispatcher's secret(s) (E6 rotation-aware). */
|
|
32
|
+
function verifyDispatcherSignature(d, body, headers) {
|
|
33
|
+
return verifyShopimindSignatureMulti(body, headers, d.secret, {
|
|
34
|
+
...(d.toleranceSeconds != null ? { toleranceSeconds: d.toleranceSeconds } : {}),
|
|
35
|
+
...(d.now ? { now: d.now } : {}),
|
|
36
|
+
}).ok;
|
|
38
37
|
}
|
|
39
38
|
function parseConfigs(body) {
|
|
40
39
|
try {
|
|
@@ -80,8 +79,7 @@ export function buildRoutes(deps) {
|
|
|
80
79
|
options: { payload: smallPayload },
|
|
81
80
|
handler: async (req, h) => {
|
|
82
81
|
const body = rawBody(req);
|
|
83
|
-
|
|
84
|
-
if (!sig.ok)
|
|
82
|
+
if (!verifyDispatcherSignature(deps.dispatcher, body, req.headers))
|
|
85
83
|
return h.response({ success: false, error: 'unauthorized' }).code(401);
|
|
86
84
|
return h.response(await handleTestConnection(parseConfigs(body), deps.dispatcher)).code(200);
|
|
87
85
|
},
|
|
@@ -92,8 +90,7 @@ export function buildRoutes(deps) {
|
|
|
92
90
|
options: { payload: smallPayload },
|
|
93
91
|
handler: async (req, h) => {
|
|
94
92
|
const body = rawBody(req);
|
|
95
|
-
|
|
96
|
-
if (!sig.ok)
|
|
93
|
+
if (!verifyDispatcherSignature(deps.dispatcher, body, req.headers))
|
|
97
94
|
return h.response({ success: false, error: 'unauthorized' }).code(401);
|
|
98
95
|
const resource = String(req.params.resource);
|
|
99
96
|
return h.response(await handleRemoteData(resource, parseConfigs(body), deps.dispatcher)).code(200);
|
|
@@ -112,7 +109,50 @@ export function buildRoutes(deps) {
|
|
|
112
109
|
{
|
|
113
110
|
method: 'GET',
|
|
114
111
|
path: '/health',
|
|
115
|
-
handler: (_req, h) =>
|
|
112
|
+
handler: (_req, h) => {
|
|
113
|
+
// E5 — enriched health: DB ping + last-run age per active installation +
|
|
114
|
+
// cursors in error. A degraded snapshot returns 503 so an orchestrator's
|
|
115
|
+
// readiness/liveness probe (Hiboutik F8) can act on it. UNAUTHENTICATED and
|
|
116
|
+
// deliberately COARSE (no secrets, no per-shop identifiers beyond ids) — it
|
|
117
|
+
// is a probe endpoint. If no report provider is wired, fall back to the
|
|
118
|
+
// original always-ok shape (backward compatible).
|
|
119
|
+
if (!deps.healthReport)
|
|
120
|
+
return h.response({ status: 'ok' }).code(200);
|
|
121
|
+
const report = deps.healthReport();
|
|
122
|
+
return h.response(report).code(report.status === 'degraded' ? 503 : 200);
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
method: 'GET',
|
|
127
|
+
path: '/admin/overview',
|
|
128
|
+
handler: (req, h) => {
|
|
129
|
+
if (deps.adminRateLimit && !deps.adminRateLimit(clientIp(req)))
|
|
130
|
+
return h.response({ success: false, error: 'rate_limited' }).code(429);
|
|
131
|
+
if (!adminOk(req, deps.adminToken))
|
|
132
|
+
return h.response({ success: false, error: 'unauthorized' }).code(401);
|
|
133
|
+
if (!deps.overview)
|
|
134
|
+
return h.response({ success: false, error: 'not_supported' }).code(501);
|
|
135
|
+
return h.response(deps.overview()).code(200);
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
method: 'GET',
|
|
140
|
+
path: '/admin/installations/{id}/rejected',
|
|
141
|
+
handler: (req, h) => {
|
|
142
|
+
if (deps.adminRateLimit && !deps.adminRateLimit(clientIp(req)))
|
|
143
|
+
return h.response({ success: false, error: 'rate_limited' }).code(429);
|
|
144
|
+
if (!adminOk(req, deps.adminToken))
|
|
145
|
+
return h.response({ success: false, error: 'unauthorized' }).code(401);
|
|
146
|
+
if (!deps.rejectedItems)
|
|
147
|
+
return h.response({ success: false, error: 'not_supported' }).code(501);
|
|
148
|
+
const id = String(req.params.id ?? '');
|
|
149
|
+
if (!id)
|
|
150
|
+
return h.response({ success: false, error: 'invalid_id' }).code(400);
|
|
151
|
+
// Bounded: default 100, hard cap 500 (the repo clamps again defensively).
|
|
152
|
+
const rawLimit = Number(req.query?.limit ?? 100);
|
|
153
|
+
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(rawLimit, 500)) : 100;
|
|
154
|
+
return h.response({ items: deps.rejectedItems(id, limit) }).code(200);
|
|
155
|
+
},
|
|
116
156
|
},
|
|
117
157
|
{
|
|
118
158
|
method: 'POST',
|
package/dist/index.d.ts
CHANGED
|
@@ -27,11 +27,14 @@ export * from './store/repositories.js';
|
|
|
27
27
|
export type * from './store/types.js';
|
|
28
28
|
export * from './logging/logger.js';
|
|
29
29
|
export * from './integration/define-integration.js';
|
|
30
|
+
export * from './integration/define-bulk-step.js';
|
|
30
31
|
export type * from './integration/types.js';
|
|
31
32
|
export * from './manifest.js';
|
|
32
33
|
export * from './http/server.js';
|
|
33
34
|
export * from './http/routes.js';
|
|
34
35
|
export * from './runtime/create-app.js';
|
|
35
36
|
export * from './runtime/rate-limiter.js';
|
|
37
|
+
export * from './runtime/health.js';
|
|
38
|
+
export * from './runtime/outbound.js';
|
|
36
39
|
export * from './testing/harness.js';
|
|
37
40
|
export type * from './contracts/index.js';
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,7 @@ export * from './store/repositories.js';
|
|
|
38
38
|
export * from './logging/logger.js';
|
|
39
39
|
// Integration authoring contract
|
|
40
40
|
export * from './integration/define-integration.js';
|
|
41
|
+
export * from './integration/define-bulk-step.js';
|
|
41
42
|
// Neutral integration manifest (describes the integration in a portable way)
|
|
42
43
|
export * from './manifest.js';
|
|
43
44
|
// HTTP runtime (Hapi server + routes + bootstrap)
|
|
@@ -45,5 +46,7 @@ export * from './http/server.js';
|
|
|
45
46
|
export * from './http/routes.js';
|
|
46
47
|
export * from './runtime/create-app.js';
|
|
47
48
|
export * from './runtime/rate-limiter.js';
|
|
49
|
+
export * from './runtime/health.js';
|
|
50
|
+
export * from './runtime/outbound.js';
|
|
48
51
|
// Test helpers
|
|
49
52
|
export * from './testing/harness.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SyncStep, SyncStepContext, IntegrationContext } from './types.js';
|
|
2
|
+
import type { BulkResult } from '../sdk/send-bulk.js';
|
|
3
|
+
/**
|
|
4
|
+
* `defineBulkStep` (E12) — pure sugar over the common "stream a window, map each raw
|
|
5
|
+
* item to a record, push in batches, advance the cursor to `window.until`" shape that
|
|
6
|
+
* every catalog/entity sync step repeats. The author supplies only the domain bits:
|
|
7
|
+
* where to read (`stream`), how to shape (`map`), and how to push (`push`). The kit
|
|
8
|
+
* handles the batching, the try/catch (an error is collected, the cursor holds), the
|
|
9
|
+
* item count, and — by default — `advanceCursorTo = window.until` on a clean run.
|
|
10
|
+
*
|
|
11
|
+
* It produces a plain {@link SyncStep}, so it composes with everything else (the
|
|
12
|
+
* engine's cursor rules, reject holding, backoff, dead-letter…) unchanged. Authors
|
|
13
|
+
* who need finer control keep writing steps by hand.
|
|
14
|
+
*/
|
|
15
|
+
export interface BulkStepConfig<S, Raw, Rec> {
|
|
16
|
+
entity: string;
|
|
17
|
+
/** Cursor scope. Defaults to `'global'` (E12). */
|
|
18
|
+
cursorScope?: 'global' | 'per-source';
|
|
19
|
+
/** Whether the step runs for the given settings. Defaults to `() => true` (E12). */
|
|
20
|
+
enabled?: (settings: S) => boolean;
|
|
21
|
+
/** Required for `cursorScope: 'per-source'` — the source keys to iterate. */
|
|
22
|
+
sources?: (ctx: IntegrationContext<S>) => Promise<string[]> | string[];
|
|
23
|
+
/** See {@link SyncStep.tolerateRejects}. Forwarded as-is. */
|
|
24
|
+
tolerateRejects?: boolean | {
|
|
25
|
+
maxRatio: number;
|
|
26
|
+
};
|
|
27
|
+
/** Batch size before a flush. Default 500. */
|
|
28
|
+
batchSize?: number;
|
|
29
|
+
/** Streams the raw items for the step's window (async iterable / generator). */
|
|
30
|
+
stream: (ctx: SyncStepContext<S>) => AsyncIterable<Raw>;
|
|
31
|
+
/** Maps a raw item to a record to push. Return `null`/`undefined` to skip it. */
|
|
32
|
+
map: (raw: Raw, ctx: SyncStepContext<S>) => Rec | null | undefined;
|
|
33
|
+
/** Pushes a batch of records (typically `ctx.sendBulk(...)` or `ctx.withSource(k).send(...)`). */
|
|
34
|
+
push: (ctx: SyncStepContext<S>, records: Rec[]) => Promise<BulkResult | unknown>;
|
|
35
|
+
/**
|
|
36
|
+
* Overrides the cursor bound on a clean run. Defaults to `ctx.window.until`
|
|
37
|
+
* (the standard windowed advance). Return `null` to NOT advance (a pure fan-out).
|
|
38
|
+
*/
|
|
39
|
+
advanceTo?: (ctx: SyncStepContext<S>) => Date | null;
|
|
40
|
+
}
|
|
41
|
+
export declare function defineBulkStep<S, Raw, Rec>(config: BulkStepConfig<S, Raw, Rec>): SyncStep<S>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function defineBulkStep(config) {
|
|
2
|
+
const batchSize = Math.max(1, config.batchSize ?? 500);
|
|
3
|
+
const step = {
|
|
4
|
+
entity: config.entity,
|
|
5
|
+
cursorScope: config.cursorScope ?? 'global',
|
|
6
|
+
enabled: config.enabled ?? (() => true),
|
|
7
|
+
...(config.sources ? { sources: config.sources } : {}),
|
|
8
|
+
...(config.tolerateRejects !== undefined ? { tolerateRejects: config.tolerateRejects } : {}),
|
|
9
|
+
run: async (ctx) => {
|
|
10
|
+
const errors = [];
|
|
11
|
+
let items = 0;
|
|
12
|
+
let batch = [];
|
|
13
|
+
const flush = async () => {
|
|
14
|
+
if (batch.length === 0)
|
|
15
|
+
return;
|
|
16
|
+
const toSend = batch;
|
|
17
|
+
batch = [];
|
|
18
|
+
await config.push(ctx, toSend);
|
|
19
|
+
items += toSend.length;
|
|
20
|
+
};
|
|
21
|
+
try {
|
|
22
|
+
for await (const raw of config.stream(ctx)) {
|
|
23
|
+
const rec = config.map(raw, ctx);
|
|
24
|
+
if (rec == null)
|
|
25
|
+
continue;
|
|
26
|
+
batch.push(rec);
|
|
27
|
+
if (batch.length >= batchSize)
|
|
28
|
+
await flush();
|
|
29
|
+
}
|
|
30
|
+
await flush();
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
// Collected, not thrown: the engine holds the cursor (window replays). Rejections
|
|
34
|
+
// reported by `push` still feed the engine's reject sink independently.
|
|
35
|
+
errors.push(`bulk step '${config.entity}': ${e instanceof Error ? e.message : String(e)}`);
|
|
36
|
+
return { items, errors };
|
|
37
|
+
}
|
|
38
|
+
// Clean run: advance to window.until by default (E12), unless overridden/null.
|
|
39
|
+
const advanceTo = config.advanceTo ? config.advanceTo(ctx) : ctx.window.until;
|
|
40
|
+
return advanceTo == null ? { items, errors } : { items, errors, advanceCursorTo: advanceTo };
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
return step;
|
|
44
|
+
}
|
|
@@ -14,3 +14,26 @@ export declare function validateIntegration<S>(c: Integration<S>): void;
|
|
|
14
14
|
export declare function validateProvisioningEvents(events: ReadonlyArray<{
|
|
15
15
|
code_name?: string;
|
|
16
16
|
}>): void;
|
|
17
|
+
/** Minimal shape a custom-data definition must satisfy for the E10 guards. */
|
|
18
|
+
interface GuardableDefinition {
|
|
19
|
+
name: string;
|
|
20
|
+
fields: Array<{
|
|
21
|
+
name: string;
|
|
22
|
+
}>;
|
|
23
|
+
unique_keys?: string[];
|
|
24
|
+
relationships?: Array<{
|
|
25
|
+
sourceField: string;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Structural guards for a custom-data definition (E10). A definition whose
|
|
30
|
+
* `unique_keys` or `relationships.sourceField` reference a field it does not
|
|
31
|
+
* declare is a MISCONFIGURATION that the API would reject opaquely at provisioning
|
|
32
|
+
* time (or, worse, accept and misbehave). Catch it early with a precise message.
|
|
33
|
+
*
|
|
34
|
+
* Called by the provisioning runner once the (async) plan is materialized — the
|
|
35
|
+
* plan is not reachable from the boot-time `validateIntegration`. Kept lenient in
|
|
36
|
+
* shape (only the checked fields are required) so the runner can pass its own DTO.
|
|
37
|
+
*/
|
|
38
|
+
export declare function validateCustomDataDefinition(def: GuardableDefinition): void;
|
|
39
|
+
export {};
|
|
@@ -48,3 +48,26 @@ export function validateProvisioningEvents(events) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Structural guards for a custom-data definition (E10). A definition whose
|
|
53
|
+
* `unique_keys` or `relationships.sourceField` reference a field it does not
|
|
54
|
+
* declare is a MISCONFIGURATION that the API would reject opaquely at provisioning
|
|
55
|
+
* time (or, worse, accept and misbehave). Catch it early with a precise message.
|
|
56
|
+
*
|
|
57
|
+
* Called by the provisioning runner once the (async) plan is materialized — the
|
|
58
|
+
* plan is not reachable from the boot-time `validateIntegration`. Kept lenient in
|
|
59
|
+
* shape (only the checked fields are required) so the runner can pass its own DTO.
|
|
60
|
+
*/
|
|
61
|
+
export function validateCustomDataDefinition(def) {
|
|
62
|
+
const fieldNames = new Set(def.fields.map((f) => f.name));
|
|
63
|
+
for (const key of def.unique_keys ?? []) {
|
|
64
|
+
if (!fieldNames.has(key)) {
|
|
65
|
+
throw new Error(`custom data '${def.name}': unique_keys contains '${key}' which is not a declared field`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const rel of def.relationships ?? []) {
|
|
69
|
+
if (!fieldNames.has(rel.sourceField)) {
|
|
70
|
+
throw new Error(`custom data '${def.name}': relationship sourceField '${rel.sourceField}' is not a declared field`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -103,8 +103,16 @@ export interface SyncStep<S> {
|
|
|
103
103
|
* stream where a PERMANENT rejection (a malformed item the API always rejects)
|
|
104
104
|
* would otherwise freeze the window forever ("poison pill"). Even when `true`,
|
|
105
105
|
* rejections are still surfaced (logged + counted); only the cursor may advance.
|
|
106
|
+
*
|
|
107
|
+
* `{ maxRatio }` (E8) is a middle ground: tolerate rejections (advance the cursor)
|
|
108
|
+
* ONLY while the reject ratio over the window stays at or below `maxRatio`
|
|
109
|
+
* (rejected / attempted, 0..1). Above it, revert to the strict behaviour and HOLD
|
|
110
|
+
* the cursor — a guard-rail so a suddenly-bad batch is not waved through blindly.
|
|
111
|
+
* `true` is equivalent to `{ maxRatio: 1 }`; `false`/omitted keeps the strict hold.
|
|
106
112
|
*/
|
|
107
|
-
tolerateRejects?: boolean
|
|
113
|
+
tolerateRejects?: boolean | {
|
|
114
|
+
maxRatio: number;
|
|
115
|
+
};
|
|
108
116
|
enabled(settings: S): boolean;
|
|
109
117
|
/** For 'per-source': the source keys to iterate over (e.g. store ids). */
|
|
110
118
|
sources?(ctx: IntegrationContext<S>): Promise<string[]> | string[];
|
|
@@ -9,7 +9,12 @@ export declare const PROVISIONING_KEY = "__provisioning";
|
|
|
9
9
|
export interface DispatcherDeps<S> {
|
|
10
10
|
integration: Integration<S>;
|
|
11
11
|
repos: Repositories;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Webhook signing secret(s). A single string is the common case; an array opens a
|
|
14
|
+
* rotation window (E6) where a request signed with ANY listed secret passes —
|
|
15
|
+
* used while swapping `current` -> `next`. Backward compatible with a plain string.
|
|
16
|
+
*/
|
|
17
|
+
secret: string | string[];
|
|
13
18
|
toleranceSeconds?: number;
|
|
14
19
|
logger: Logger;
|
|
15
20
|
/** Builds the ShopiMind SDK client for an access_token. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SpmClient } from '@shopimind/sdk-js';
|
|
2
|
-
import {
|
|
2
|
+
import { verifyShopimindSignatureMulti } from '../security/signature.js';
|
|
3
3
|
import { redact } from '../security/redaction.js';
|
|
4
4
|
import { saveConfigs, loadConfigs, sensitiveKeys } from '../config/config-store.js';
|
|
5
5
|
import { runProvisioning } from '../provisioning/runner.js';
|
|
@@ -30,12 +30,11 @@ const EVENT_TO_HANDLER = {
|
|
|
30
30
|
* body, logs a REDACTED payload (secrets never reach the log), then dispatches.
|
|
31
31
|
*/
|
|
32
32
|
export async function handleWebhook(rawBody, headers, deps) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const sig = verifyShopimindSignature(rawBody, headers, sigOpts);
|
|
33
|
+
// E6 — verify against one or several secrets (rotation window).
|
|
34
|
+
const sig = verifyShopimindSignatureMulti(rawBody, headers, deps.secret, {
|
|
35
|
+
...(deps.toleranceSeconds != null ? { toleranceSeconds: deps.toleranceSeconds } : {}),
|
|
36
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
37
|
+
});
|
|
39
38
|
let payload;
|
|
40
39
|
try {
|
|
41
40
|
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
@@ -143,7 +142,7 @@ async function onActivate(p, deps) {
|
|
|
143
142
|
}
|
|
144
143
|
if (deps.integration.provisioning) {
|
|
145
144
|
const plan = await deps.integration.provisioning(ctx);
|
|
146
|
-
const prov = await runProvisioning(ctx.spm, plan);
|
|
145
|
+
const prov = await runProvisioning(ctx.spm, plan, ctx.logger);
|
|
147
146
|
deps.repos.state.set(id, PROVISIONING_KEY, JSON.stringify({ sourceIds: prov.sourceIds, defIds: prov.defIds }));
|
|
148
147
|
if (prov.errors.length > 0) {
|
|
149
148
|
// Count ALL successful resources (sources, defs, events, statuses) — not
|
|
@@ -198,7 +197,7 @@ async function onConfigUpdated(p, deps) {
|
|
|
198
197
|
const ctx = buildContext(id, deps);
|
|
199
198
|
if (deps.integration.provisioning) {
|
|
200
199
|
const plan = await deps.integration.provisioning(ctx);
|
|
201
|
-
const prov = await runProvisioning(ctx.spm, plan);
|
|
200
|
+
const prov = await runProvisioning(ctx.spm, plan, ctx.logger);
|
|
202
201
|
deps.repos.state.set(id, PROVISIONING_KEY, JSON.stringify({ sourceIds: prov.sourceIds, defIds: prov.defIds }));
|
|
203
202
|
}
|
|
204
203
|
if (deps.integration.hooks?.onConfigUpdated)
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { type SpmHttpClient } from '@shopimind/sdk-js';
|
|
2
2
|
import type { NewDataSource, NewCustomDataDefinition, NewEvent } from '../contracts/index.js';
|
|
3
3
|
/**
|
|
4
|
-
* Finds a data source
|
|
4
|
+
* Finds a data source, otherwise creates it. Returns its id.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Matching (E16):
|
|
7
|
+
* - if `input.stableConfigKey` is set, match FIRST on `config[stableConfigKey]`
|
|
8
|
+
* (a permanent identifier, e.g. the store id) — a source found this way but with
|
|
9
|
+
* a DIFFERENT label has its label UPDATED to the new one (a rename no longer
|
|
10
|
+
* spawns a duplicate);
|
|
11
|
+
* - otherwise (or if no config match), fall back to matching by `label` as a
|
|
12
|
+
* natural key (the legacy behaviour, unchanged when `stableConfigKey` is absent).
|
|
13
|
+
*
|
|
14
|
+
* Comparisons are trimmed on both sides so incidental whitespace does not spawn a
|
|
15
|
+
* duplicate source. `stableConfigKey` is kit-only metadata — never sent to the API.
|
|
10
16
|
*/
|
|
11
17
|
export declare function ensureDataSource(client: SpmHttpClient, input: NewDataSource): Promise<number>;
|
|
12
18
|
/**
|
|
@@ -38,21 +38,57 @@ function toSdkDef(def) {
|
|
|
38
38
|
function normKey(v) {
|
|
39
39
|
return typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim();
|
|
40
40
|
}
|
|
41
|
+
/** Extracts a property from a source's `config` (a JSON string), tolerating malformed JSON. */
|
|
42
|
+
function configValue(config, key) {
|
|
43
|
+
if (typeof config !== 'string' || config === '')
|
|
44
|
+
return undefined;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(config);
|
|
47
|
+
const v = parsed?.[key];
|
|
48
|
+
return v == null ? undefined : normKey(v);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
41
54
|
/**
|
|
42
|
-
* Finds a data source
|
|
55
|
+
* Finds a data source, otherwise creates it. Returns its id.
|
|
43
56
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
57
|
+
* Matching (E16):
|
|
58
|
+
* - if `input.stableConfigKey` is set, match FIRST on `config[stableConfigKey]`
|
|
59
|
+
* (a permanent identifier, e.g. the store id) — a source found this way but with
|
|
60
|
+
* a DIFFERENT label has its label UPDATED to the new one (a rename no longer
|
|
61
|
+
* spawns a duplicate);
|
|
62
|
+
* - otherwise (or if no config match), fall back to matching by `label` as a
|
|
63
|
+
* natural key (the legacy behaviour, unchanged when `stableConfigKey` is absent).
|
|
64
|
+
*
|
|
65
|
+
* Comparisons are trimmed on both sides so incidental whitespace does not spawn a
|
|
66
|
+
* duplicate source. `stableConfigKey` is kit-only metadata — never sent to the API.
|
|
48
67
|
*/
|
|
49
68
|
export async function ensureDataSource(client, input) {
|
|
50
|
-
const existing = SpmHelpers.unwrapOrThrow(await SpmDataSources.list(client), 'listDataSources');
|
|
69
|
+
const existing = SpmHelpers.unwrapOrThrow(await SpmDataSources.list(client), 'listDataSources') ?? [];
|
|
51
70
|
const wantedLabel = normKey(input.label);
|
|
52
|
-
|
|
71
|
+
// E16 — stable-key match first (survives a label rename).
|
|
72
|
+
if (input.stableConfigKey) {
|
|
73
|
+
const wantedKeyValue = configValue(input.config, input.stableConfigKey);
|
|
74
|
+
if (wantedKeyValue !== undefined) {
|
|
75
|
+
const byKey = existing.find((s) => configValue(s.config, input.stableConfigKey) === wantedKeyValue);
|
|
76
|
+
if (byKey) {
|
|
77
|
+
const id = numId(byKey, 'id_data_source', 'id');
|
|
78
|
+
// Label drifted (merchant renamed the store) -> update it, do NOT duplicate.
|
|
79
|
+
if (normKey(byKey.label) !== wantedLabel) {
|
|
80
|
+
await SpmDataSources.update(client, id, { label: input.label });
|
|
81
|
+
}
|
|
82
|
+
return id;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const found = existing.find((s) => normKey(s.label) === wantedLabel);
|
|
53
87
|
if (found)
|
|
54
88
|
return numId(found, 'id_data_source', 'id');
|
|
55
|
-
|
|
89
|
+
// `stableConfigKey` is authoring-only metadata: strip it from the create DTO.
|
|
90
|
+
const { stableConfigKey: _drop, ...createDto } = input;
|
|
91
|
+
const created = SpmHelpers.unwrapOrThrow(await SpmDataSources.create(client, createDto), 'createDataSource');
|
|
56
92
|
return numId(created, 'id_data_source', 'id');
|
|
57
93
|
}
|
|
58
94
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type SpmHttpClient } from '@shopimind/sdk-js';
|
|
2
2
|
import type { ProvisioningPlan } from '../integration/types.js';
|
|
3
|
+
import type { NewCustomDataDefinition } from '../contracts/index.js';
|
|
4
|
+
import type { Logger } from '../logging/logger.js';
|
|
3
5
|
/**
|
|
4
6
|
* Runs a `ProvisioningPlan` (idempotent find-or-create). Best-effort per
|
|
5
7
|
* resource: an error is collected without interrupting the others. Returns the
|
|
@@ -13,4 +15,12 @@ export interface ProvisioningResult {
|
|
|
13
15
|
orderStatuses: number;
|
|
14
16
|
errors: string[];
|
|
15
17
|
}
|
|
16
|
-
export declare function runProvisioning(client: SpmHttpClient, plan: ProvisioningPlan): Promise<ProvisioningResult>;
|
|
18
|
+
export declare function runProvisioning(client: SpmHttpClient, plan: ProvisioningPlan, logger?: Logger): Promise<ProvisioningResult>;
|
|
19
|
+
/**
|
|
20
|
+
* Topologically sorts the custom-data plan so a definition is always emitted AFTER
|
|
21
|
+
* the sibling definitions it references via custom→custom relationships (E10). Only
|
|
22
|
+
* intra-plan custom targets create an edge; system targets and out-of-plan/numeric
|
|
23
|
+
* targets do not. Throws on a dependency cycle (unresolvable ordering). Definitions
|
|
24
|
+
* without in-plan dependencies keep their declaration order (stable).
|
|
25
|
+
*/
|
|
26
|
+
export declare function topoSortCustomData(defs: NewCustomDataDefinition[]): NewCustomDataDefinition[];
|