@shopimind/integration-kit-js 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +10 -0
- package/README.md +118 -0
- package/dist/config/config-store.d.ts +6 -0
- package/dist/config/config-store.js +56 -0
- package/dist/contracts/common.d.ts +11 -0
- package/dist/contracts/common.js +1 -0
- package/dist/contracts/config-schema.d.ts +79 -0
- package/dist/contracts/config-schema.js +1 -0
- package/dist/contracts/index.d.ts +11 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/lifecycle.d.ts +59 -0
- package/dist/contracts/lifecycle.js +1 -0
- package/dist/contracts/sdk.d.ts +68 -0
- package/dist/contracts/sdk.js +6 -0
- package/dist/contracts/widget.d.ts +70 -0
- package/dist/contracts/widget.js +1 -0
- package/dist/http/routes.d.ts +18 -0
- package/dist/http/routes.js +150 -0
- package/dist/http/server.d.ts +7 -0
- package/dist/http/server.js +19 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +47 -0
- package/dist/integration/define-integration.d.ts +16 -0
- package/dist/integration/define-integration.js +50 -0
- package/dist/integration/types.d.ts +148 -0
- package/dist/integration/types.js +1 -0
- package/dist/lifecycle/dispatcher.d.ts +33 -0
- package/dist/lifecycle/dispatcher.js +315 -0
- package/dist/lifecycle/inbound.d.ts +50 -0
- package/dist/lifecycle/inbound.js +124 -0
- package/dist/logging/logger.d.ts +23 -0
- package/dist/logging/logger.js +23 -0
- package/dist/manifest.d.ts +52 -0
- package/dist/manifest.js +36 -0
- package/dist/provisioning/ensure.d.ts +24 -0
- package/dist/provisioning/ensure.js +104 -0
- package/dist/provisioning/runner.d.ts +16 -0
- package/dist/provisioning/runner.js +49 -0
- package/dist/runtime/create-app.d.ts +66 -0
- package/dist/runtime/create-app.js +211 -0
- package/dist/runtime/rate-limiter.d.ts +19 -0
- package/dist/runtime/rate-limiter.js +46 -0
- package/dist/sdk/send-bulk.d.ts +46 -0
- package/dist/sdk/send-bulk.js +40 -0
- package/dist/sdk/source-scope.d.ts +38 -0
- package/dist/sdk/source-scope.js +34 -0
- package/dist/security/crypto.d.ts +19 -0
- package/dist/security/crypto.js +82 -0
- package/dist/security/redaction.d.ts +15 -0
- package/dist/security/redaction.js +56 -0
- package/dist/security/signature.d.ts +31 -0
- package/dist/security/signature.js +30 -0
- package/dist/store/db.d.ts +7 -0
- package/dist/store/db.js +22 -0
- package/dist/store/migrate.d.ts +10 -0
- package/dist/store/migrate.js +35 -0
- package/dist/store/migrations.d.ts +27 -0
- package/dist/store/migrations.js +128 -0
- package/dist/store/repositories.d.ts +102 -0
- package/dist/store/repositories.js +281 -0
- package/dist/store/types.d.ts +62 -0
- package/dist/store/types.js +1 -0
- package/dist/sync/concurrency.d.ts +12 -0
- package/dist/sync/concurrency.js +30 -0
- package/dist/sync/cursor.d.ts +16 -0
- package/dist/sync/cursor.js +14 -0
- package/dist/sync/engine.d.ts +49 -0
- package/dist/sync/engine.js +129 -0
- package/dist/sync/paginate.d.ts +14 -0
- package/dist/sync/paginate.js +42 -0
- package/dist/testing/harness.d.ts +49 -0
- package/dist/testing/harness.js +110 -0
- package/package.json +51 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { SpmClient } from '@shopimind/sdk-js';
|
|
2
|
+
import { verifyShopimindSignature } from '../security/signature.js';
|
|
3
|
+
import { redact } from '../security/redaction.js';
|
|
4
|
+
import { saveConfigs, loadConfigs, sensitiveKeys } from '../config/config-store.js';
|
|
5
|
+
import { runProvisioning } from '../provisioning/runner.js';
|
|
6
|
+
import { ensureInboundSecret } from './inbound.js';
|
|
7
|
+
import { makeWithSource } from '../sdk/source-scope.js';
|
|
8
|
+
import { makeSendBulk } from '../sdk/send-bulk.js';
|
|
9
|
+
export const ACCESS_TOKEN_KEY = '__access_token';
|
|
10
|
+
/** State key where the provisioning result (sourceIds/defIds) is stored. */
|
|
11
|
+
export const PROVISIONING_KEY = '__provisioning';
|
|
12
|
+
/** Opaque installation id from the payload. */
|
|
13
|
+
function installIdOf(p) {
|
|
14
|
+
if (p.installation_id != null && p.installation_id !== '')
|
|
15
|
+
return String(p.installation_id);
|
|
16
|
+
if (p.id_shop_integration != null)
|
|
17
|
+
return String(p.id_shop_integration);
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const EVENT_TO_HANDLER = {
|
|
21
|
+
'integration.installed': 'install',
|
|
22
|
+
'integration.activated': 'activate',
|
|
23
|
+
'integration.deactivated': 'deactivate',
|
|
24
|
+
'integration.uninstalled': 'uninstall',
|
|
25
|
+
'integration.config_updated': 'config_updated',
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Entry point for lifecycle webhooks. Verifies the signature against the RAW
|
|
29
|
+
* body, logs a REDACTED payload (secrets never reach the log), then dispatches.
|
|
30
|
+
*/
|
|
31
|
+
export async function handleWebhook(rawBody, headers, deps) {
|
|
32
|
+
const sigOpts = { secret: deps.secret };
|
|
33
|
+
if (deps.toleranceSeconds != null)
|
|
34
|
+
sigOpts.toleranceSeconds = deps.toleranceSeconds;
|
|
35
|
+
if (deps.now)
|
|
36
|
+
sigOpts.now = deps.now;
|
|
37
|
+
const sig = verifyShopimindSignature(rawBody, headers, sigOpts);
|
|
38
|
+
let payload;
|
|
39
|
+
try {
|
|
40
|
+
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
deps.repos.webhookLog.log({ event: 'unparseable', signature_ok: sig.ok, payload_json: '[unparseable body omitted]' });
|
|
44
|
+
return { status: 400, body: { success: false, error: 'invalid_json' } };
|
|
45
|
+
}
|
|
46
|
+
// REDACTED log — before any processing, signed or not. Masking by key NAME
|
|
47
|
+
// (`redact`) is not enough: partner secrets declared `sensitive` in the
|
|
48
|
+
// config_schema (e.g. `private_key`, `client_id`, `login`, `pin`...) do not
|
|
49
|
+
// necessarily match the regex and would be written IN CLEAR, bypassing the
|
|
50
|
+
// AES at rest. So first mask the sensitive `configs` per the schema, then redact.
|
|
51
|
+
deps.repos.webhookLog.log({
|
|
52
|
+
event: payload.event ?? 'unknown',
|
|
53
|
+
installation_id: installIdOf(payload) ?? null,
|
|
54
|
+
signature_ok: sig.ok,
|
|
55
|
+
payload_json: JSON.stringify(redact(maskSchemaSecrets(payload, deps.integration.configSchema))),
|
|
56
|
+
});
|
|
57
|
+
if (!sig.ok) {
|
|
58
|
+
// OPAQUE 401: the precise reason stays in the server logs (already logged
|
|
59
|
+
// above via signature_ok), never returned to the caller (anti-calibration).
|
|
60
|
+
deps.logger.warn('webhook rejected', { reason: sig.reason, event: payload.event });
|
|
61
|
+
return { status: 401, body: { success: false, error: 'unauthorized' } };
|
|
62
|
+
}
|
|
63
|
+
const handlerName = payload.event ? EVENT_TO_HANDLER[payload.event] : undefined;
|
|
64
|
+
if (!handlerName)
|
|
65
|
+
return { status: 200, body: { success: false, error: 'unknown_event' } };
|
|
66
|
+
// Anti-replay: the signature uniquely binds (timestamp, body). We claim this
|
|
67
|
+
// key BEFORE processing; a verbatim replay is short-circuited. The key is
|
|
68
|
+
// RELEASED if processing fails (!success response or exception) so ShopiMind
|
|
69
|
+
// can resend the webhook and it will be reprocessed.
|
|
70
|
+
const installId = installIdOf(payload);
|
|
71
|
+
const dedupKey = headerOf(headers, 'x-shopimind-signature');
|
|
72
|
+
const dedupable = !!(installId && dedupKey);
|
|
73
|
+
if (dedupable && !deps.repos.webhookSeen.claim(installId, dedupKey)) {
|
|
74
|
+
return { status: 200, body: { success: true } }; // already processed -> harmless replay
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const body = await runHandler(handlerName, payload, deps);
|
|
78
|
+
if (dedupable && body.success === false)
|
|
79
|
+
deps.repos.webhookSeen.release(installId, dedupKey);
|
|
80
|
+
return { status: 200, body };
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
if (dedupable)
|
|
84
|
+
deps.repos.webhookSeen.release(installId, dedupKey);
|
|
85
|
+
deps.logger.error('lifecycle handler failed', { event: payload.event, error: errMsg(e) });
|
|
86
|
+
return { status: 200, body: { success: false, error: 'internal_error' } };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function runHandler(name, p, deps) {
|
|
90
|
+
switch (name) {
|
|
91
|
+
case 'install':
|
|
92
|
+
return onInstall(p, deps);
|
|
93
|
+
case 'activate':
|
|
94
|
+
return onActivate(p, deps);
|
|
95
|
+
case 'deactivate':
|
|
96
|
+
return onDeactivate(p, deps);
|
|
97
|
+
case 'uninstall':
|
|
98
|
+
return onUninstall(p, deps);
|
|
99
|
+
case 'config_updated':
|
|
100
|
+
return onConfigUpdated(p, deps);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function onInstall(p, deps) {
|
|
104
|
+
const id = installIdOf(p);
|
|
105
|
+
if (!id)
|
|
106
|
+
return { success: true };
|
|
107
|
+
if (p.access_token)
|
|
108
|
+
deps.repos.state.setSecret(id, ACCESS_TOKEN_KEY, p.access_token);
|
|
109
|
+
deps.repos.installs.upsert({
|
|
110
|
+
installation_id: id,
|
|
111
|
+
shop_domain: p.shop_domain ?? null,
|
|
112
|
+
shop_name: p.shop_name ?? null,
|
|
113
|
+
status: 'inactive',
|
|
114
|
+
installed_at: p.installed_at ?? null,
|
|
115
|
+
});
|
|
116
|
+
if (p.configs)
|
|
117
|
+
saveConfigs(deps.repos.state, id, deps.integration.configSchema, p.configs);
|
|
118
|
+
return { success: true };
|
|
119
|
+
}
|
|
120
|
+
async function onActivate(p, deps) {
|
|
121
|
+
const id = installIdOf(p);
|
|
122
|
+
if (!id)
|
|
123
|
+
return { success: false, error: 'missing_installation_id' };
|
|
124
|
+
if (p.access_token)
|
|
125
|
+
deps.repos.state.setSecret(id, ACCESS_TOKEN_KEY, p.access_token);
|
|
126
|
+
// The install is persisted 'inactive' until activation is validated:
|
|
127
|
+
// the 'active' status is only set AT THE END (testConnection + provisioning OK).
|
|
128
|
+
deps.repos.installs.upsert({
|
|
129
|
+
installation_id: id,
|
|
130
|
+
shop_domain: p.shop_domain ?? null,
|
|
131
|
+
shop_name: p.shop_name ?? null,
|
|
132
|
+
status: 'inactive',
|
|
133
|
+
installed_at: p.installed_at ?? null,
|
|
134
|
+
});
|
|
135
|
+
if (p.configs)
|
|
136
|
+
saveConfigs(deps.repos.state, id, deps.integration.configSchema, p.configs);
|
|
137
|
+
const ctx = buildContext(id, deps);
|
|
138
|
+
const ok = await deps.integration.testConnection(ctx).catch(() => false);
|
|
139
|
+
if (!ok) {
|
|
140
|
+
deps.repos.installs.setStatus(id, 'inactive', {});
|
|
141
|
+
return { success: false, error: 'connection_failed' };
|
|
142
|
+
}
|
|
143
|
+
if (deps.integration.provisioning) {
|
|
144
|
+
const plan = await deps.integration.provisioning(ctx);
|
|
145
|
+
const prov = await runProvisioning(ctx.spm, plan);
|
|
146
|
+
deps.repos.state.set(id, PROVISIONING_KEY, JSON.stringify({ sourceIds: prov.sourceIds, defIds: prov.defIds }));
|
|
147
|
+
if (prov.errors.length > 0) {
|
|
148
|
+
// Count ALL successful resources (sources, defs, events, statuses) — not
|
|
149
|
+
// just sources/defs: an events-only integration (loyalty/reviews) provisions
|
|
150
|
+
// neither source nor def. HARD failure only if NOTHING succeeded; otherwise
|
|
151
|
+
// best-effort, but partial errors are TRACED (no more silent swallowing).
|
|
152
|
+
const provisionedCount = Object.keys(prov.sourceIds).length + Object.keys(prov.defIds).length + prov.events + prov.orderStatuses;
|
|
153
|
+
if (provisionedCount === 0) {
|
|
154
|
+
deps.repos.installs.setStatus(id, 'inactive', {});
|
|
155
|
+
return { success: false, error: `provisioning_failed: ${prov.errors[0]}` };
|
|
156
|
+
}
|
|
157
|
+
deps.logger.warn('partial provisioning: some resources failed', { installation_id: id, errors: prov.errors });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (deps.integration.hooks?.onActivate)
|
|
161
|
+
await deps.integration.hooks.onActivate(ctx);
|
|
162
|
+
deps.repos.installs.setStatus(id, 'active', { activated_at: p.activated_at ?? null }); // validated -> active
|
|
163
|
+
deps.afterActivate?.(id);
|
|
164
|
+
return { success: true };
|
|
165
|
+
}
|
|
166
|
+
async function onDeactivate(p, deps) {
|
|
167
|
+
const id = installIdOf(p);
|
|
168
|
+
if (!id)
|
|
169
|
+
return { success: true };
|
|
170
|
+
// Idempotent transition: already deactivated/uninstalled -> no-op (does not replay the hook).
|
|
171
|
+
const current = deps.repos.installs.find(id);
|
|
172
|
+
if (current && (current.status === 'inactive' || current.status === 'uninstalled'))
|
|
173
|
+
return { success: true };
|
|
174
|
+
deps.repos.installs.setStatus(id, 'inactive', { deactivated_at: p.deactivated_at ?? null });
|
|
175
|
+
await runHookSafe(deps.integration.hooks?.onDeactivate, id, deps);
|
|
176
|
+
return { success: true };
|
|
177
|
+
}
|
|
178
|
+
async function onUninstall(p, deps) {
|
|
179
|
+
const id = installIdOf(p);
|
|
180
|
+
if (!id)
|
|
181
|
+
return { success: true };
|
|
182
|
+
// Idempotent transition: already uninstalled -> no-op (does not replay the hook).
|
|
183
|
+
const current = deps.repos.installs.find(id);
|
|
184
|
+
if (current && current.status === 'uninstalled')
|
|
185
|
+
return { success: true };
|
|
186
|
+
deps.repos.installs.setStatus(id, 'uninstalled', { uninstalled_at: p.uninstalled_at ?? null });
|
|
187
|
+
await runHookSafe(deps.integration.hooks?.onUninstall, id, deps);
|
|
188
|
+
return { success: true };
|
|
189
|
+
}
|
|
190
|
+
async function onConfigUpdated(p, deps) {
|
|
191
|
+
const id = installIdOf(p);
|
|
192
|
+
if (!id)
|
|
193
|
+
return { success: true };
|
|
194
|
+
if (p.configs)
|
|
195
|
+
saveConfigs(deps.repos.state, id, deps.integration.configSchema, p.configs);
|
|
196
|
+
try {
|
|
197
|
+
const ctx = buildContext(id, deps);
|
|
198
|
+
if (deps.integration.provisioning) {
|
|
199
|
+
const plan = await deps.integration.provisioning(ctx);
|
|
200
|
+
const prov = await runProvisioning(ctx.spm, plan);
|
|
201
|
+
deps.repos.state.set(id, PROVISIONING_KEY, JSON.stringify({ sourceIds: prov.sourceIds, defIds: prov.defIds }));
|
|
202
|
+
}
|
|
203
|
+
if (deps.integration.hooks?.onConfigUpdated)
|
|
204
|
+
await deps.integration.hooks.onConfigUpdated(ctx);
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
// Best-effort: the config is already saved (above) even if reprovisioning
|
|
208
|
+
// failed; the failure is logged but NOT surfaced to ShopiMind. We keep the
|
|
209
|
+
// { success: true } return DELIBERATELY (ShopiMind contract): a config update
|
|
210
|
+
// must not be rejected just because reprovisioning could not complete. Logged
|
|
211
|
+
// at ERROR (not warn) because a failed reprovision can leave the integration
|
|
212
|
+
// out of sync with the new config and warrants operator attention.
|
|
213
|
+
deps.logger.error('config_updated reprovisioning failed (best-effort, not surfaced)', { error: errMsg(e) });
|
|
214
|
+
}
|
|
215
|
+
return { success: true };
|
|
216
|
+
}
|
|
217
|
+
/** Validates the integration credentials (wizard step). No ShopiMind access. */
|
|
218
|
+
export async function handleTestConnection(configs, deps) {
|
|
219
|
+
const ctx = ephemeralContext(configs, deps);
|
|
220
|
+
const ok = await deps.integration.testConnection(ctx).catch(() => false);
|
|
221
|
+
return ok ? { success: true } : { success: false, error: 'connection_failed' };
|
|
222
|
+
}
|
|
223
|
+
/** Populates a dynamic select in the config wizard. */
|
|
224
|
+
export async function handleRemoteData(resource, configs, deps) {
|
|
225
|
+
const resolver = deps.integration.remoteData?.[resource];
|
|
226
|
+
if (!resolver)
|
|
227
|
+
return { data: [] };
|
|
228
|
+
const ctx = ephemeralContext(configs, deps);
|
|
229
|
+
const options = await resolver(ctx).catch(() => []);
|
|
230
|
+
return { data: options };
|
|
231
|
+
}
|
|
232
|
+
function buildContext(id, deps) {
|
|
233
|
+
const token = deps.repos.state.get(id, ACCESS_TOKEN_KEY);
|
|
234
|
+
if (!token)
|
|
235
|
+
throw new Error(`no access_token for installation ${id}`);
|
|
236
|
+
const configs = loadConfigs(deps.repos.state, id, deps.integration.configSchema);
|
|
237
|
+
const spm = deps.makeSpmClient(token);
|
|
238
|
+
const logger = deps.logger.child({ installation_id: id });
|
|
239
|
+
const sendBulk = makeSendBulk(spm, logger);
|
|
240
|
+
return {
|
|
241
|
+
installationId: id,
|
|
242
|
+
settings: deps.integration.parseSettings(configs),
|
|
243
|
+
spm,
|
|
244
|
+
sendBulk,
|
|
245
|
+
state: deps.repos.state,
|
|
246
|
+
logger,
|
|
247
|
+
setExternalAccount: (acc) => deps.repos.installs.setExternalAccount(id, acc.id, acc.name ?? null),
|
|
248
|
+
inboundSecret: ensureInboundSecret(deps.repos.state, id),
|
|
249
|
+
withSource: makeWithSource(deps.repos.state, id, PROVISIONING_KEY, sendBulk),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** Context without an installation (config wizard): the ShopiMind SDK is not callable here. */
|
|
253
|
+
function ephemeralContext(configs, deps) {
|
|
254
|
+
const spm = unavailableSpmClient();
|
|
255
|
+
return {
|
|
256
|
+
installationId: '',
|
|
257
|
+
settings: deps.integration.parseSettings(configs),
|
|
258
|
+
spm,
|
|
259
|
+
sendBulk: makeSendBulk(spm, deps.logger),
|
|
260
|
+
state: deps.repos.state,
|
|
261
|
+
logger: deps.logger,
|
|
262
|
+
setExternalAccount: () => { },
|
|
263
|
+
inboundSecret: '',
|
|
264
|
+
withSource: () => {
|
|
265
|
+
throw new Error('withSource is unavailable during the configuration wizard (no installation)');
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
async function runHookSafe(hook, id, deps) {
|
|
270
|
+
if (!hook)
|
|
271
|
+
return;
|
|
272
|
+
try {
|
|
273
|
+
await hook(buildContext(id, deps));
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
deps.logger.warn('lifecycle hook failed', { error: errMsg(e) });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* "Unavailable" SDK client for the configuration wizard (no installation): its
|
|
281
|
+
* adapter rejects every call. The SDK therefore never calls ShopiMind here; an
|
|
282
|
+
* integration must not use `ctx.spm` during configuration.
|
|
283
|
+
*/
|
|
284
|
+
function unavailableSpmClient() {
|
|
285
|
+
const client = SpmClient.getClient('v1', '', { baseUrl: 'http://localhost', retry: false, labelSource: null });
|
|
286
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
287
|
+
client.defaults.adapter = () => {
|
|
288
|
+
throw new Error('ShopiMind SDK is unavailable during the configuration step');
|
|
289
|
+
};
|
|
290
|
+
return client;
|
|
291
|
+
}
|
|
292
|
+
function errMsg(e) {
|
|
293
|
+
return e instanceof Error ? e.message : String(e);
|
|
294
|
+
}
|
|
295
|
+
function headerOf(headers, name) {
|
|
296
|
+
const v = headers[name];
|
|
297
|
+
return Array.isArray(v) ? v[0] : v;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Masks, within `payload.configs`, the fields declared `sensitive` by the
|
|
301
|
+
* config_schema — before logging. Complements `redact()` (masking by key name)
|
|
302
|
+
* by covering secrets whose key does not match the generic regex.
|
|
303
|
+
*/
|
|
304
|
+
function maskSchemaSecrets(payload, schema) {
|
|
305
|
+
const configs = payload.configs;
|
|
306
|
+
if (!configs || typeof configs !== 'object')
|
|
307
|
+
return payload;
|
|
308
|
+
const sensitive = new Set(sensitiveKeys(schema));
|
|
309
|
+
if (sensitive.size === 0)
|
|
310
|
+
return payload;
|
|
311
|
+
const masked = {};
|
|
312
|
+
for (const [k, v] of Object.entries(configs))
|
|
313
|
+
masked[k] = sensitive.has(k) ? '[redacted]' : v;
|
|
314
|
+
return { ...payload, configs: masked };
|
|
315
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Integration, IntegrationContext } from '../integration/types.js';
|
|
2
|
+
import type { IntegrationStateRepo, Repositories } from '../store/repositories.js';
|
|
3
|
+
import type { Logger } from '../logging/logger.js';
|
|
4
|
+
/** State key where the PER-INSTALLATION HMAC secret for inbound routes lives. */
|
|
5
|
+
export declare const INBOUND_SECRET_KEY = "__inbound_secret";
|
|
6
|
+
/** Lazily ensures a per-installation inbound secret, encrypted at rest; returns it. */
|
|
7
|
+
export declare function ensureInboundSecret(state: IntegrationStateRepo, installationId: string): string;
|
|
8
|
+
export interface InboundDeps<S> {
|
|
9
|
+
integration: Integration<S>;
|
|
10
|
+
repos: Repositories;
|
|
11
|
+
logger: Logger;
|
|
12
|
+
/** Builds the context for an installation (decrypted token, `ctx.spm` ready) or null. */
|
|
13
|
+
buildContext(installationId: string): IntegrationContext<S> | null;
|
|
14
|
+
toleranceSeconds?: number;
|
|
15
|
+
/** Returns true if the call is allowed (per-installation rate limit). */
|
|
16
|
+
rateLimit?(installationId: string): boolean;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
}
|
|
19
|
+
export interface InboundResult {
|
|
20
|
+
status: number;
|
|
21
|
+
body: {
|
|
22
|
+
success: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
replayed?: boolean;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Handles an INBOUND call (integrator app -> integration). Enforced pipeline:
|
|
29
|
+
* per-installation HMAC auth -> rate limit -> idempotency (persisted) -> handler
|
|
30
|
+
* with a ready `ctx`. The ShopiMind API token is NEVER exposed to the caller.
|
|
31
|
+
*
|
|
32
|
+
* Return codes:
|
|
33
|
+
* - 200 'success': handler ran and finished 'done' (status 'done'); OR an
|
|
34
|
+
* already-processed replay (`replayed: true`) short-circuited via the prior
|
|
35
|
+
* 'done' fast-path, or via a non-fresh claim whose status is 'done'
|
|
36
|
+
* (replayed) or 'received' (a concurrent attempt is still in progress).
|
|
37
|
+
* - 400 'missing_installation_header' / 'invalid_json': malformed request.
|
|
38
|
+
* - 401 'unauthorized': unknown installation (no secret) OR signature mismatch.
|
|
39
|
+
* The reason is opaque to the caller and only logged server-side.
|
|
40
|
+
* - 404 'unknown_action': no inbound handler registered for `action`.
|
|
41
|
+
* - 409 'no_context': `buildContext` returned null — the installation exists and
|
|
42
|
+
* is authenticated, but its context cannot be built right now (e.g. missing
|
|
43
|
+
* access_token / not activated). The caller MAY retry later once activated;
|
|
44
|
+
* nothing is claimed or executed in this case.
|
|
45
|
+
* - 429 'rate_limited': per-installation rate limit tripped.
|
|
46
|
+
* - 500 'internal_error': the handler threw. The attempt is persisted 'failed'
|
|
47
|
+
* so a later identical call (same dedup key) RE-EXECUTES the handler; the
|
|
48
|
+
* caller can safely retry. Idempotency only dedupes on success ('done').
|
|
49
|
+
*/
|
|
50
|
+
export declare function handleInbound<S>(action: string, rawBody: string, headers: Record<string, string | string[] | undefined>, deps: InboundDeps<S>): Promise<InboundResult>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { verifyIntegratorSignature } from '../security/signature.js';
|
|
3
|
+
/** State key where the PER-INSTALLATION HMAC secret for inbound routes lives. */
|
|
4
|
+
export const INBOUND_SECRET_KEY = '__inbound_secret';
|
|
5
|
+
const INSTALLATION_HEADER = 'x-integration-installation';
|
|
6
|
+
const SIGNATURE_HEADER = 'x-integration-signature';
|
|
7
|
+
const IDEMPOTENCY_HEADER = 'x-idempotency-key';
|
|
8
|
+
/**
|
|
9
|
+
* Dummy secret to pay the HMAC cost on an unknown installation (anti-timing).
|
|
10
|
+
* NON-SENSITIVE CONSTANT: this value is not a credential and never validates any
|
|
11
|
+
* request. It exists solely to spend the same CPU as a real verification so an
|
|
12
|
+
* attacker cannot distinguish "unknown installation" from "bad signature" by
|
|
13
|
+
* timing. Leaking it is harmless; it grants no access.
|
|
14
|
+
*/
|
|
15
|
+
const DUMMY_SECRET = 'insec_unknown_installation_constant_time_guard';
|
|
16
|
+
/** Lazily ensures a per-installation inbound secret, encrypted at rest; returns it. */
|
|
17
|
+
export function ensureInboundSecret(state, installationId) {
|
|
18
|
+
const existing = state.get(installationId, INBOUND_SECRET_KEY);
|
|
19
|
+
if (existing)
|
|
20
|
+
return existing;
|
|
21
|
+
const secret = 'insec_' + randomBytes(24).toString('hex');
|
|
22
|
+
state.setSecret(installationId, INBOUND_SECRET_KEY, secret);
|
|
23
|
+
return secret;
|
|
24
|
+
}
|
|
25
|
+
const ok = (extra = {}) => ({ status: 200, body: { success: true, ...extra } });
|
|
26
|
+
const fail = (status, error) => ({ status, body: { success: false, error } });
|
|
27
|
+
/**
|
|
28
|
+
* Handles an INBOUND call (integrator app -> integration). Enforced pipeline:
|
|
29
|
+
* per-installation HMAC auth -> rate limit -> idempotency (persisted) -> handler
|
|
30
|
+
* with a ready `ctx`. The ShopiMind API token is NEVER exposed to the caller.
|
|
31
|
+
*
|
|
32
|
+
* Return codes:
|
|
33
|
+
* - 200 'success': handler ran and finished 'done' (status 'done'); OR an
|
|
34
|
+
* already-processed replay (`replayed: true`) short-circuited via the prior
|
|
35
|
+
* 'done' fast-path, or via a non-fresh claim whose status is 'done'
|
|
36
|
+
* (replayed) or 'received' (a concurrent attempt is still in progress).
|
|
37
|
+
* - 400 'missing_installation_header' / 'invalid_json': malformed request.
|
|
38
|
+
* - 401 'unauthorized': unknown installation (no secret) OR signature mismatch.
|
|
39
|
+
* The reason is opaque to the caller and only logged server-side.
|
|
40
|
+
* - 404 'unknown_action': no inbound handler registered for `action`.
|
|
41
|
+
* - 409 'no_context': `buildContext` returned null — the installation exists and
|
|
42
|
+
* is authenticated, but its context cannot be built right now (e.g. missing
|
|
43
|
+
* access_token / not activated). The caller MAY retry later once activated;
|
|
44
|
+
* nothing is claimed or executed in this case.
|
|
45
|
+
* - 429 'rate_limited': per-installation rate limit tripped.
|
|
46
|
+
* - 500 'internal_error': the handler threw. The attempt is persisted 'failed'
|
|
47
|
+
* so a later identical call (same dedup key) RE-EXECUTES the handler; the
|
|
48
|
+
* caller can safely retry. Idempotency only dedupes on success ('done').
|
|
49
|
+
*/
|
|
50
|
+
export async function handleInbound(action, rawBody, headers, deps) {
|
|
51
|
+
const installationId = headerValue(headers, INSTALLATION_HEADER);
|
|
52
|
+
if (!installationId)
|
|
53
|
+
return fail(400, 'missing_installation_header');
|
|
54
|
+
const secret = deps.repos.state.get(installationId, INBOUND_SECRET_KEY);
|
|
55
|
+
if (!secret) {
|
|
56
|
+
// OPAQUE 401 + still pay the HMAC cost (anti timing/installation-enumeration
|
|
57
|
+
// oracle). The precise reason stays in the server logs, never in the response.
|
|
58
|
+
verifyIntegratorSignature(rawBody, headers, { secret: DUMMY_SECRET, ...(deps.now ? { now: deps.now } : {}) });
|
|
59
|
+
deps.logger.warn('inbound rejected', { reason: 'unknown_installation', installationId });
|
|
60
|
+
return fail(401, 'unauthorized');
|
|
61
|
+
}
|
|
62
|
+
const sigOpts = { secret };
|
|
63
|
+
if (deps.toleranceSeconds != null)
|
|
64
|
+
sigOpts.toleranceSeconds = deps.toleranceSeconds;
|
|
65
|
+
if (deps.now)
|
|
66
|
+
sigOpts.now = deps.now;
|
|
67
|
+
const sig = verifyIntegratorSignature(rawBody, headers, sigOpts);
|
|
68
|
+
if (!sig.ok) {
|
|
69
|
+
deps.logger.warn('inbound rejected', { reason: sig.reason ?? 'signature_mismatch', installationId });
|
|
70
|
+
return fail(401, 'unauthorized');
|
|
71
|
+
}
|
|
72
|
+
if (deps.rateLimit && !deps.rateLimit(installationId))
|
|
73
|
+
return fail(429, 'rate_limited');
|
|
74
|
+
const handler = deps.integration.inbound?.[action];
|
|
75
|
+
if (!handler)
|
|
76
|
+
return fail(404, 'unknown_action');
|
|
77
|
+
let payload;
|
|
78
|
+
try {
|
|
79
|
+
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return fail(400, 'invalid_json');
|
|
83
|
+
}
|
|
84
|
+
// MANDATORY anti-replay, enforced by the server (never opt-in). The dedup key
|
|
85
|
+
// is, by default, derived from the SIGNATURE (which uniquely binds timestamp+body
|
|
86
|
+
// under the secret) -> any verbatim replay is short-circuited, even without an
|
|
87
|
+
// idempotency header. An `x-idempotency-key` provided by the caller takes
|
|
88
|
+
// precedence (application-level idempotency across possible re-signatures).
|
|
89
|
+
const sigRaw = headerValue(headers, SIGNATURE_HEADER) ?? '';
|
|
90
|
+
const dedupKey = headerValue(headers, IDEMPOTENCY_HEADER) ?? `sig:${action}:${sigRaw}`;
|
|
91
|
+
// Fast path: replay of a call already processed successfully (avoids building the ctx).
|
|
92
|
+
const prior = deps.repos.inboundEvents.find(installationId, dedupKey);
|
|
93
|
+
if (prior && prior.status === 'done')
|
|
94
|
+
return ok({ replayed: true });
|
|
95
|
+
// Context build BEFORE claiming: if it fails we return 409 'no_context' and
|
|
96
|
+
// claim NOTHING, so a later retry (once the installation is activated) is not
|
|
97
|
+
// wrongly short-circuited as an already-seen replay.
|
|
98
|
+
const ctx = deps.buildContext(installationId);
|
|
99
|
+
if (!ctx)
|
|
100
|
+
return fail(409, 'no_context');
|
|
101
|
+
// ATOMIC claim before execution (anti-TOCTOU): two concurrent calls cannot
|
|
102
|
+
// execute the handler twice.
|
|
103
|
+
const claim = deps.repos.inboundEvents.claim(installationId, dedupKey, action);
|
|
104
|
+
if (!claim.fresh && claim.status === 'done')
|
|
105
|
+
return ok({ replayed: true });
|
|
106
|
+
if (!claim.fresh && claim.status === 'received')
|
|
107
|
+
return ok({ replayed: true }); // already in progress
|
|
108
|
+
// fresh, or a previous 'failed' attempt -> (re)execute by reusing the claimed row.
|
|
109
|
+
try {
|
|
110
|
+
await handler(ctx, payload);
|
|
111
|
+
deps.repos.inboundEvents.finish(claim.rowId, 'done');
|
|
112
|
+
return ok();
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
116
|
+
deps.repos.inboundEvents.finish(claim.rowId, 'failed', msg);
|
|
117
|
+
deps.logger.error('inbound handler failed', { action, error: msg });
|
|
118
|
+
return fail(500, 'internal_error');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function headerValue(headers, name) {
|
|
122
|
+
const v = headers[name];
|
|
123
|
+
return Array.isArray(v) ? v[0] : v;
|
|
124
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger that REDACTS by default. No "raw" method is exposed: every piece of
|
|
3
|
+
* metadata passes through `redact()` before emission, so a secret cannot leak
|
|
4
|
+
* into the logs.
|
|
5
|
+
*/
|
|
6
|
+
export interface Logger {
|
|
7
|
+
info(message: string, meta?: unknown): void;
|
|
8
|
+
warn(message: string, meta?: unknown): void;
|
|
9
|
+
error(message: string, meta?: unknown): void;
|
|
10
|
+
child(bindings: Record<string, unknown>): Logger;
|
|
11
|
+
}
|
|
12
|
+
export interface LogLine {
|
|
13
|
+
level: 'info' | 'warn' | 'error';
|
|
14
|
+
message: string;
|
|
15
|
+
meta?: unknown;
|
|
16
|
+
bindings?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export interface LoggerOptions {
|
|
19
|
+
bindings?: Record<string, unknown>;
|
|
20
|
+
/** Injectable sink (tests). Defaults to JSON console output. */
|
|
21
|
+
sink?: (line: LogLine) => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function createLogger(opts?: LoggerOptions): Logger;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { redact } from '../security/redaction.js';
|
|
2
|
+
const defaultSink = (line) => {
|
|
3
|
+
const payload = JSON.stringify({ ...line.bindings, level: line.level, message: line.message, meta: line.meta });
|
|
4
|
+
if (line.level === 'error')
|
|
5
|
+
console.error(payload);
|
|
6
|
+
else if (line.level === 'warn')
|
|
7
|
+
console.warn(payload);
|
|
8
|
+
else
|
|
9
|
+
console.log(payload);
|
|
10
|
+
};
|
|
11
|
+
export function createLogger(opts = {}) {
|
|
12
|
+
const bindings = opts.bindings ?? {};
|
|
13
|
+
const sink = opts.sink ?? defaultSink;
|
|
14
|
+
const emit = (level, message, meta) => {
|
|
15
|
+
sink({ level, message, meta: meta === undefined ? undefined : redact(meta), bindings });
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
info: (m, meta) => emit('info', m, meta),
|
|
19
|
+
warn: (m, meta) => emit('warn', m, meta),
|
|
20
|
+
error: (m, meta) => emit('error', m, meta),
|
|
21
|
+
child: (extra) => createLogger({ bindings: { ...bindings, ...extra }, ...(opts.sink ? { sink: opts.sink } : {}) }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Integration } from './integration/types.js';
|
|
2
|
+
import type { ConfigSchema, WidgetDeclaration } from './contracts/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Integration manifest: a NEUTRAL, portable description of an integration,
|
|
5
|
+
* derived entirely from `defineIntegration`. It describes the integration
|
|
6
|
+
* without coupling to a deployment: no secret, no status, no absolute URL
|
|
7
|
+
* (endpoints are relative paths, the baseUrl is added at registration time).
|
|
8
|
+
*
|
|
9
|
+
* KNOWN LIMITATIONS (manifest v1):
|
|
10
|
+
* - It does NOT describe the integration's declared INBOUND routes
|
|
11
|
+
* (`Integration.inbound`): only the fixed lifecycle/test/remote webhooks are
|
|
12
|
+
* listed. Consumers cannot discover the available `POST /inbound/{action}`
|
|
13
|
+
* endpoints from the manifest alone.
|
|
14
|
+
* - It does NOT describe widget ENDPOINTS: `widgets` carries the declarations as
|
|
15
|
+
* authored, but any backing endpoint/route a widget needs is not surfaced here.
|
|
16
|
+
* Both are expected to be addressed in a future manifest version.
|
|
17
|
+
*/
|
|
18
|
+
export interface IntegrationManifest {
|
|
19
|
+
manifest_version: 1;
|
|
20
|
+
slug: string;
|
|
21
|
+
name: string;
|
|
22
|
+
version: string;
|
|
23
|
+
/** NEUTRAL category keys (e.g. `'pos'`). */
|
|
24
|
+
categories?: string[];
|
|
25
|
+
icon_url?: string;
|
|
26
|
+
short_description?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
documentation_url?: string;
|
|
29
|
+
/**
|
|
30
|
+
* EXPERIMENTAL flag: signals the integration expects external auth (OAuth).
|
|
31
|
+
* It is purely advisory for now — the OAuth flow is NOT wired into the runtime,
|
|
32
|
+
* so setting it does not yet change activation/auth behavior. Treat as unstable
|
|
33
|
+
* until the runtime supports it.
|
|
34
|
+
*/
|
|
35
|
+
requires_external_auth?: boolean;
|
|
36
|
+
config_schema: ConfigSchema;
|
|
37
|
+
widgets: WidgetDeclaration[];
|
|
38
|
+
/** RELATIVE endpoint paths (never an absolute URL: the baseUrl is added at deployment). */
|
|
39
|
+
webhooks: {
|
|
40
|
+
lifecycle: string;
|
|
41
|
+
test_connection: string;
|
|
42
|
+
remote_data: string;
|
|
43
|
+
};
|
|
44
|
+
/** Handled lifecycle events (fixed contract). */
|
|
45
|
+
lifecycle_events: string[];
|
|
46
|
+
/** Declared `remoteData` resources (keys). */
|
|
47
|
+
remote_resources: string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Derives the neutral manifest of an integration. PURE function (deterministic).
|
|
51
|
+
*/
|
|
52
|
+
export declare function buildIntegrationManifest<S>(integration: Integration<S>): IntegrationManifest;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Fixed lifecycle events of the protocol. */
|
|
2
|
+
const LIFECYCLE_EVENTS = ['install', 'activate', 'deactivate', 'uninstall', 'config_updated'];
|
|
3
|
+
/**
|
|
4
|
+
* Derives the neutral manifest of an integration. PURE function (deterministic).
|
|
5
|
+
*/
|
|
6
|
+
export function buildIntegrationManifest(integration) {
|
|
7
|
+
const m = integration.meta;
|
|
8
|
+
const manifest = {
|
|
9
|
+
manifest_version: 1,
|
|
10
|
+
slug: integration.slug,
|
|
11
|
+
name: m.name,
|
|
12
|
+
version: m.version,
|
|
13
|
+
config_schema: integration.configSchema,
|
|
14
|
+
widgets: integration.widgets ?? [],
|
|
15
|
+
webhooks: {
|
|
16
|
+
lifecycle: '/webhook/receive',
|
|
17
|
+
test_connection: '/webhook/test-connection',
|
|
18
|
+
remote_data: '/webhook/remote-data/{resource}',
|
|
19
|
+
},
|
|
20
|
+
lifecycle_events: [...LIFECYCLE_EVENTS],
|
|
21
|
+
remote_resources: Object.keys(integration.remoteData ?? {}),
|
|
22
|
+
};
|
|
23
|
+
if (m.categories !== undefined)
|
|
24
|
+
manifest.categories = m.categories;
|
|
25
|
+
if (m.icon_url !== undefined)
|
|
26
|
+
manifest.icon_url = m.icon_url;
|
|
27
|
+
if (m.short_description !== undefined)
|
|
28
|
+
manifest.short_description = m.short_description;
|
|
29
|
+
if (m.description !== undefined)
|
|
30
|
+
manifest.description = m.description;
|
|
31
|
+
if (m.documentation_url !== undefined)
|
|
32
|
+
manifest.documentation_url = m.documentation_url;
|
|
33
|
+
if (m.requires_external_auth !== undefined)
|
|
34
|
+
manifest.requires_external_auth = m.requires_external_auth;
|
|
35
|
+
return manifest;
|
|
36
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type SpmHttpClient } from '@shopimind/sdk-js';
|
|
2
|
+
import type { NewDataSource, NewCustomDataDefinition, NewEvent } from '../contracts/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Finds a data source by `label`, otherwise creates it. Returns its id.
|
|
5
|
+
*
|
|
6
|
+
* Invariant: `label` is expected to be UNIQUE across an account's data sources;
|
|
7
|
+
* the find-or-create matches on it as a natural key. The comparison is trimmed
|
|
8
|
+
* on both sides so trailing/leading whitespace differences do not spawn a
|
|
9
|
+
* duplicate source.
|
|
10
|
+
*/
|
|
11
|
+
export declare function ensureDataSource(client: SpmHttpClient, input: NewDataSource): Promise<number>;
|
|
12
|
+
/**
|
|
13
|
+
* Finds a custom-data definition by its natural key (`name`, falling back to the
|
|
14
|
+
* API's `schema_name`), otherwise creates and activates it. Convergent
|
|
15
|
+
* provisioning: if the integration has evolved, the existing definition is
|
|
16
|
+
* extended (only the missing fields are sent) instead of being left untouched.
|
|
17
|
+
*
|
|
18
|
+
* Invariant: a definition's `name` (alias `schema_name`) is expected to be UNIQUE
|
|
19
|
+
* across an account; it is matched as a natural key. The comparison is trimmed on
|
|
20
|
+
* both sides so incidental whitespace differences do not spawn a duplicate.
|
|
21
|
+
*/
|
|
22
|
+
export declare function ensureCustomDataDefinition(client: SpmHttpClient, def: NewCustomDataDefinition): Promise<number>;
|
|
23
|
+
/** Creates an event type, tolerating a 409 "already exists" (idempotent). */
|
|
24
|
+
export declare function ensureEvent(client: SpmHttpClient, event: NewEvent): Promise<void>;
|