@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,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds `ctx.withSource`: resolves the `id_data_source` of a PROVISIONED source
|
|
3
|
+
* (from the integration state) and returns a {@link SourceHandle}. Throws if the
|
|
4
|
+
* source was not declared in `provisioning.dataSources` (guard: pushing catalog data
|
|
5
|
+
* without a dedicated source is not allowed).
|
|
6
|
+
*/
|
|
7
|
+
export function makeWithSource(state, installationId, provisioningKey, sendBulk) {
|
|
8
|
+
return (sourceKey) => {
|
|
9
|
+
const raw = state.get(installationId, provisioningKey);
|
|
10
|
+
// Parse defensively: corrupt/unreadable persisted state must surface as the
|
|
11
|
+
// business error "source not provisioned" (below), never as an opaque
|
|
12
|
+
// SyntaxError. An unparseable blob is treated as "no sources provisioned".
|
|
13
|
+
let sourceIds = {};
|
|
14
|
+
if (raw) {
|
|
15
|
+
try {
|
|
16
|
+
sourceIds = JSON.parse(raw).sourceIds ?? {};
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
sourceIds = {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const id = sourceIds[sourceKey];
|
|
23
|
+
if (id == null) {
|
|
24
|
+
throw new Error(`withSource("${sourceKey}"): source not provisioned — declare it in provisioning.dataSources`);
|
|
25
|
+
}
|
|
26
|
+
const tag = (items) => items.map((i) => ({ ...i, id_data_source: id }));
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
tag,
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
send: (fn, items, opts) => sendBulk(fn, tag(items), opts),
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface CryptoOptions {
|
|
2
|
+
/** 64-character hex key (32 bytes). */
|
|
3
|
+
key?: string | null;
|
|
4
|
+
/** `true` => a missing key throws at construction. */
|
|
5
|
+
production?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare class SecretCipher {
|
|
8
|
+
private readonly key;
|
|
9
|
+
constructor(opts: CryptoOptions);
|
|
10
|
+
/** `true` when no key is configured -> secrets stored IN PLAINTEXT (dev only). */
|
|
11
|
+
get insecure(): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Encrypts a secret. `aad` (Additional Authenticated Data) binds the ciphertext
|
|
14
|
+
* to its location (e.g. `${installationId}:${key}`): a valid blob then cannot be
|
|
15
|
+
* relocated/swapped from one row to another (the auth tag covers the AAD).
|
|
16
|
+
*/
|
|
17
|
+
encrypt(plaintext: string, aad?: string): string;
|
|
18
|
+
decrypt(stored: string, aad?: string): string;
|
|
19
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* AES-256-GCM encryption of secrets at rest (partner API keys).
|
|
4
|
+
*
|
|
5
|
+
* In PRODUCTION, a missing key is FATAL. In dev, a `plain:` fallback is allowed
|
|
6
|
+
* so a key is not required locally.
|
|
7
|
+
*/
|
|
8
|
+
const ALGO = 'aes-256-gcm';
|
|
9
|
+
const PLAIN_PREFIX = 'plain:';
|
|
10
|
+
const GCM_PREFIX = 'gcm:';
|
|
11
|
+
export class SecretCipher {
|
|
12
|
+
key;
|
|
13
|
+
constructor(opts) {
|
|
14
|
+
this.key = parseKey(opts.key);
|
|
15
|
+
if ((opts.production ?? false) && !this.key) {
|
|
16
|
+
throw new Error('CREDENTIALS_KEY is required in production (64 hex characters / 32 bytes) to encrypt secrets at rest');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** `true` when no key is configured -> secrets stored IN PLAINTEXT (dev only). */
|
|
20
|
+
get insecure() {
|
|
21
|
+
return this.key === null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Encrypts a secret. `aad` (Additional Authenticated Data) binds the ciphertext
|
|
25
|
+
* to its location (e.g. `${installationId}:${key}`): a valid blob then cannot be
|
|
26
|
+
* relocated/swapped from one row to another (the auth tag covers the AAD).
|
|
27
|
+
*/
|
|
28
|
+
encrypt(plaintext, aad) {
|
|
29
|
+
if (!this.key) {
|
|
30
|
+
// Dev only -- in production the constructor has already thrown.
|
|
31
|
+
return `${PLAIN_PREFIX}${plaintext}`;
|
|
32
|
+
}
|
|
33
|
+
const iv = randomBytes(12);
|
|
34
|
+
const cipher = createCipheriv(ALGO, this.key, iv);
|
|
35
|
+
if (aad)
|
|
36
|
+
cipher.setAAD(Buffer.from(aad, 'utf8'));
|
|
37
|
+
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
38
|
+
const tag = cipher.getAuthTag();
|
|
39
|
+
return `${GCM_PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
|
|
40
|
+
}
|
|
41
|
+
decrypt(stored, aad) {
|
|
42
|
+
if (stored.startsWith(PLAIN_PREFIX)) {
|
|
43
|
+
// The format prefix is part of the stored value (so it is forgeable).
|
|
44
|
+
// Only accept plaintext if no key is configured (dev). If a key exists, a
|
|
45
|
+
// `plain:` blob is tampering -> reject (GCM integrity takes precedence).
|
|
46
|
+
if (this.key)
|
|
47
|
+
throw new Error('plaintext secret rejected: a CREDENTIALS_KEY is configured');
|
|
48
|
+
return stored.slice(PLAIN_PREFIX.length);
|
|
49
|
+
}
|
|
50
|
+
if (!stored.startsWith(GCM_PREFIX))
|
|
51
|
+
throw new Error('unrecognized ciphertext format');
|
|
52
|
+
if (!this.key)
|
|
53
|
+
throw new Error('CREDENTIALS_KEY is required to decrypt a stored secret');
|
|
54
|
+
const parts = stored.slice(GCM_PREFIX.length).split(':');
|
|
55
|
+
if (parts.length !== 3)
|
|
56
|
+
throw new Error('malformed GCM ciphertext');
|
|
57
|
+
const [ivHex, tagHex, ctHex] = parts;
|
|
58
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
59
|
+
const tag = Buffer.from(tagHex, 'hex');
|
|
60
|
+
if (iv.length !== 12)
|
|
61
|
+
throw new Error('invalid GCM IV (12 bytes expected)');
|
|
62
|
+
if (tag.length !== 16)
|
|
63
|
+
throw new Error('invalid GCM tag (16 bytes expected)');
|
|
64
|
+
const decipher = createDecipheriv(ALGO, this.key, iv);
|
|
65
|
+
if (aad)
|
|
66
|
+
decipher.setAAD(Buffer.from(aad, 'utf8'));
|
|
67
|
+
decipher.setAuthTag(tag);
|
|
68
|
+
return Buffer.concat([
|
|
69
|
+
decipher.update(Buffer.from(ctHex, 'hex')),
|
|
70
|
+
decipher.final(),
|
|
71
|
+
]).toString('utf8');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function parseKey(key) {
|
|
75
|
+
if (!key)
|
|
76
|
+
return null;
|
|
77
|
+
const buf = Buffer.from(key, 'hex');
|
|
78
|
+
if (buf.length !== 32) {
|
|
79
|
+
throw new Error(`CREDENTIALS_KEY must be 32 bytes (64 hex), received ${buf.length} byte(s)`);
|
|
80
|
+
}
|
|
81
|
+
return buf;
|
|
82
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Masks secrets before logging.
|
|
3
|
+
*
|
|
4
|
+
* Run values through this function before writing them to logs so that no API key
|
|
5
|
+
* appears in plaintext.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Recursively replaces the values of sensitive keys with `[redacted]`.
|
|
9
|
+
*
|
|
10
|
+
* Guards against circular references (a node already on the current path is replaced
|
|
11
|
+
* with `[Circular]`) and against excessive nesting (beyond {@link MAX_DEPTH} levels the
|
|
12
|
+
* value is truncated). Plain JSON-shaped objects are unaffected by these guards.
|
|
13
|
+
*/
|
|
14
|
+
export declare function redact<T>(value: T): T;
|
|
15
|
+
export declare function isSensitiveKey(key: string): boolean;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Masks secrets before logging.
|
|
3
|
+
*
|
|
4
|
+
* Run values through this function before writing them to logs so that no API key
|
|
5
|
+
* appears in plaintext.
|
|
6
|
+
*/
|
|
7
|
+
const SENSITIVE = /(secret|password|passwd|api[_-]?key|token|access[_-]?token|client[_-]?secret|authorization|credential)/i;
|
|
8
|
+
/**
|
|
9
|
+
* Maximum recursion depth. Beyond this level the value is truncated (replaced with a
|
|
10
|
+
* sentinel) to guard against pathologically deep structures that could exhaust the
|
|
11
|
+
* stack — and so a deeply-nested secret can never leak unredacted.
|
|
12
|
+
*/
|
|
13
|
+
const MAX_DEPTH = 8;
|
|
14
|
+
/**
|
|
15
|
+
* Recursively replaces the values of sensitive keys with `[redacted]`.
|
|
16
|
+
*
|
|
17
|
+
* Guards against circular references (a node already on the current path is replaced
|
|
18
|
+
* with `[Circular]`) and against excessive nesting (beyond {@link MAX_DEPTH} levels the
|
|
19
|
+
* value is truncated). Plain JSON-shaped objects are unaffected by these guards.
|
|
20
|
+
*/
|
|
21
|
+
export function redact(value) {
|
|
22
|
+
return redactInternal(value, new WeakSet(), 0);
|
|
23
|
+
}
|
|
24
|
+
function redactInternal(value, seen, depth) {
|
|
25
|
+
if (value === null || typeof value !== 'object') {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
// Bound the recursion depth: beyond this level TRUNCATE rather than return the value
|
|
29
|
+
// as-is, so a sensitive key nested very deep can never leak unredacted to logs (fail-closed).
|
|
30
|
+
if (depth >= MAX_DEPTH) {
|
|
31
|
+
return '[truncated: max depth]';
|
|
32
|
+
}
|
|
33
|
+
// Break cycles: if this object is already on the current traversal path, stop.
|
|
34
|
+
if (seen.has(value)) {
|
|
35
|
+
return '[Circular]';
|
|
36
|
+
}
|
|
37
|
+
seen.add(value);
|
|
38
|
+
let result;
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
result = value.map((v) => redactInternal(v, seen, depth + 1));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const [k, v] of Object.entries(value)) {
|
|
45
|
+
out[k] = SENSITIVE.test(k) ? '[redacted]' : redactInternal(v, seen, depth + 1);
|
|
46
|
+
}
|
|
47
|
+
result = out;
|
|
48
|
+
}
|
|
49
|
+
// Allow the same object to reappear on sibling branches; we only forbid it on the
|
|
50
|
+
// active path (true cycles), not on legitimate shared references.
|
|
51
|
+
seen.delete(value);
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export function isSensitiveKey(key) {
|
|
55
|
+
return SENSITIVE.test(key);
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMAC signature verification for incoming webhooks.
|
|
3
|
+
*
|
|
4
|
+
* The algorithm (HMAC-SHA256 over `${timestamp}.${rawBody}`, constant-time
|
|
5
|
+
* comparison, asymmetric anti-replay window) is provided by `SpmWebhookSignature`
|
|
6
|
+
* from the SDK. This module only binds the headers to that scheme:
|
|
7
|
+
* - ShopiMind -> integration (lifecycle webhooks): `x-shopimind-*` headers,
|
|
8
|
+
* secret = `webhook_secret` (shared between ShopiMind and the integration);
|
|
9
|
+
* - Integrator app -> integration (inbound routes / middleware): `x-integration-*`
|
|
10
|
+
* headers, PER-INSTALLATION secret (never the ShopiMind API token).
|
|
11
|
+
*/
|
|
12
|
+
export interface SignatureCheck {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
reason?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SignatureOptions {
|
|
17
|
+
secret: string;
|
|
18
|
+
/** Anti-replay window in seconds (default 300). */
|
|
19
|
+
toleranceSeconds?: number;
|
|
20
|
+
/** Injectable clock; defaults to `Date.now`. */
|
|
21
|
+
now?: () => number;
|
|
22
|
+
}
|
|
23
|
+
/** Verifies a ShopiMind -> integration webhook (`x-shopimind-*` headers). */
|
|
24
|
+
export declare function verifyShopimindSignature(rawBody: string, headers: Record<string, string | string[] | undefined>, opts: SignatureOptions): SignatureCheck;
|
|
25
|
+
/**
|
|
26
|
+
* Verifies an inbound integrator-app -> integration call (`x-integration-*` headers).
|
|
27
|
+
* The `secret` is resolved PER-INSTALLATION by the caller.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyIntegratorSignature(rawBody: string, headers: Record<string, string | string[] | undefined>, opts: SignatureOptions): SignatureCheck;
|
|
30
|
+
/** Builds the signature for a body (`${ts}.${body}`). */
|
|
31
|
+
export declare function signShopimindBody(rawBody: string, secret: string, timestampSeconds: number): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { SpmWebhookSignature } from '@shopimind/sdk-js';
|
|
2
|
+
const SHOPIMIND_TIMESTAMP_HEADER = 'x-shopimind-timestamp';
|
|
3
|
+
const SHOPIMIND_SIGNATURE_HEADER = 'x-shopimind-signature';
|
|
4
|
+
const INTEGRATION_TIMESTAMP_HEADER = 'x-integration-timestamp';
|
|
5
|
+
const INTEGRATION_SIGNATURE_HEADER = 'x-integration-signature';
|
|
6
|
+
/** Verifies a ShopiMind -> integration webhook (`x-shopimind-*` headers). */
|
|
7
|
+
export function verifyShopimindSignature(rawBody, headers, opts) {
|
|
8
|
+
return SpmWebhookSignature.verifyFromHeaders(rawBody, headers, opts.secret, {
|
|
9
|
+
timestampHeader: SHOPIMIND_TIMESTAMP_HEADER,
|
|
10
|
+
signatureHeader: SHOPIMIND_SIGNATURE_HEADER,
|
|
11
|
+
toleranceSeconds: opts.toleranceSeconds,
|
|
12
|
+
now: opts.now,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Verifies an inbound integrator-app -> integration call (`x-integration-*` headers).
|
|
17
|
+
* The `secret` is resolved PER-INSTALLATION by the caller.
|
|
18
|
+
*/
|
|
19
|
+
export function verifyIntegratorSignature(rawBody, headers, opts) {
|
|
20
|
+
return SpmWebhookSignature.verifyFromHeaders(rawBody, headers, opts.secret, {
|
|
21
|
+
timestampHeader: INTEGRATION_TIMESTAMP_HEADER,
|
|
22
|
+
signatureHeader: INTEGRATION_SIGNATURE_HEADER,
|
|
23
|
+
toleranceSeconds: opts.toleranceSeconds,
|
|
24
|
+
now: opts.now,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Builds the signature for a body (`${ts}.${body}`). */
|
|
28
|
+
export function signShopimindBody(rawBody, secret, timestampSeconds) {
|
|
29
|
+
return SpmWebhookSignature.sign(rawBody, secret, timestampSeconds);
|
|
30
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
export type Db = Database.Database;
|
|
3
|
+
/**
|
|
4
|
+
* Opens (or creates) the SQLite store, applies the PRAGMAs and runs the migrations.
|
|
5
|
+
* Use `:memory:` for tests. The parent directory is created if needed.
|
|
6
|
+
*/
|
|
7
|
+
export declare function openDatabase(path: string): Db;
|
package/dist/store/db.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
4
|
+
import { MIGRATIONS } from './migrations.js';
|
|
5
|
+
import { runMigrations } from './migrate.js';
|
|
6
|
+
/**
|
|
7
|
+
* Opens (or creates) the SQLite store, applies the PRAGMAs and runs the migrations.
|
|
8
|
+
* Use `:memory:` for tests. The parent directory is created if needed.
|
|
9
|
+
*/
|
|
10
|
+
export function openDatabase(path) {
|
|
11
|
+
if (path !== ':memory:') {
|
|
12
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
const db = new Database(path);
|
|
15
|
+
db.pragma('journal_mode = WAL');
|
|
16
|
+
// Wait up to 5s on a locked database before raising SQLITE_BUSY, so a concurrent
|
|
17
|
+
// writer does not fail immediately under contention.
|
|
18
|
+
db.pragma('busy_timeout = 5000');
|
|
19
|
+
db.pragma('foreign_keys = ON');
|
|
20
|
+
runMigrations(db, MIGRATIONS);
|
|
21
|
+
return db;
|
|
22
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type DatabaseT from 'better-sqlite3';
|
|
2
|
+
import type { Migration } from './migrations.js';
|
|
3
|
+
/**
|
|
4
|
+
* Applies the missing migrations in order, each within a transaction.
|
|
5
|
+
* Keeps a `schema_migrations` registry. Idempotent: re-running only applies what
|
|
6
|
+
* is missing (returns the number of migrations applied).
|
|
7
|
+
*/
|
|
8
|
+
export declare function runMigrations(db: DatabaseT.Database, migrations: Migration[]): number;
|
|
9
|
+
/** Currently applied schema version (0 if blank). */
|
|
10
|
+
export declare function currentSchemaVersion(db: DatabaseT.Database): number;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies the missing migrations in order, each within a transaction.
|
|
3
|
+
* Keeps a `schema_migrations` registry. Idempotent: re-running only applies what
|
|
4
|
+
* is missing (returns the number of migrations applied).
|
|
5
|
+
*/
|
|
6
|
+
export function runMigrations(db, migrations) {
|
|
7
|
+
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
8
|
+
version INTEGER PRIMARY KEY,
|
|
9
|
+
name TEXT NOT NULL,
|
|
10
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
11
|
+
);`);
|
|
12
|
+
const row = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get();
|
|
13
|
+
const current = row.v ?? 0;
|
|
14
|
+
const pending = migrations
|
|
15
|
+
.filter((m) => m.version > current)
|
|
16
|
+
.sort((a, b) => a.version - b.version);
|
|
17
|
+
const record = db.prepare('INSERT INTO schema_migrations (version, name) VALUES (?, ?)');
|
|
18
|
+
const apply = db.transaction((m) => {
|
|
19
|
+
db.exec(m.sql);
|
|
20
|
+
record.run(m.version, m.name);
|
|
21
|
+
});
|
|
22
|
+
for (const m of pending)
|
|
23
|
+
apply(m);
|
|
24
|
+
return pending.length;
|
|
25
|
+
}
|
|
26
|
+
/** Currently applied schema version (0 if blank). */
|
|
27
|
+
export function currentSchemaVersion(db) {
|
|
28
|
+
const exists = db
|
|
29
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'`)
|
|
30
|
+
.get();
|
|
31
|
+
if (!exists)
|
|
32
|
+
return 0;
|
|
33
|
+
const row = db.prepare('SELECT MAX(version) AS v FROM schema_migrations').get();
|
|
34
|
+
return row.v ?? 0;
|
|
35
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VERSIONED SQLite migrations. The SQL is embedded (no asset to copy into
|
|
3
|
+
* `dist`). The runner applies the missing versions within a transaction.
|
|
4
|
+
*
|
|
5
|
+
* Schema centered on `installation_id` (OPAQUE token issued by ShopiMind, treated
|
|
6
|
+
* as a string). The local store is the integrator's CORRELATION REGISTRY: it links
|
|
7
|
+
* a ShopiMind installation (`installation_id`) to ITS internal account
|
|
8
|
+
* (`external_account_ref`/`_name`). No internal ShopiMind id (PK, `id_shop`) is
|
|
9
|
+
* stored here.
|
|
10
|
+
*
|
|
11
|
+
* Encryption — be honest about what is and is NOT protected:
|
|
12
|
+
* - ONLY secrets written via `integration_state.setSecret` are encrypted at rest
|
|
13
|
+
* (AES-256-GCM, `encrypted = 1`).
|
|
14
|
+
* - PII (`shop_domain`, `shop_name`, `external_account_name`) and plain values
|
|
15
|
+
* written via `integration_state.set` are stored IN CLEAR TEXT.
|
|
16
|
+
* - The SQLite file itself is NOT encrypted. Protect it via filesystem/disk
|
|
17
|
+
* controls if at-rest confidentiality of the whole store is required.
|
|
18
|
+
*
|
|
19
|
+
* Migrations are APPEND-ONLY: NEVER edit a migration that has already been
|
|
20
|
+
* published/shipped — add a new versioned migration instead.
|
|
21
|
+
*/
|
|
22
|
+
export interface Migration {
|
|
23
|
+
version: number;
|
|
24
|
+
name: string;
|
|
25
|
+
sql: string;
|
|
26
|
+
}
|
|
27
|
+
export declare const MIGRATIONS: Migration[];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VERSIONED SQLite migrations. The SQL is embedded (no asset to copy into
|
|
3
|
+
* `dist`). The runner applies the missing versions within a transaction.
|
|
4
|
+
*
|
|
5
|
+
* Schema centered on `installation_id` (OPAQUE token issued by ShopiMind, treated
|
|
6
|
+
* as a string). The local store is the integrator's CORRELATION REGISTRY: it links
|
|
7
|
+
* a ShopiMind installation (`installation_id`) to ITS internal account
|
|
8
|
+
* (`external_account_ref`/`_name`). No internal ShopiMind id (PK, `id_shop`) is
|
|
9
|
+
* stored here.
|
|
10
|
+
*
|
|
11
|
+
* Encryption — be honest about what is and is NOT protected:
|
|
12
|
+
* - ONLY secrets written via `integration_state.setSecret` are encrypted at rest
|
|
13
|
+
* (AES-256-GCM, `encrypted = 1`).
|
|
14
|
+
* - PII (`shop_domain`, `shop_name`, `external_account_name`) and plain values
|
|
15
|
+
* written via `integration_state.set` are stored IN CLEAR TEXT.
|
|
16
|
+
* - The SQLite file itself is NOT encrypted. Protect it via filesystem/disk
|
|
17
|
+
* controls if at-rest confidentiality of the whole store is required.
|
|
18
|
+
*
|
|
19
|
+
* Migrations are APPEND-ONLY: NEVER edit a migration that has already been
|
|
20
|
+
* published/shipped — add a new versioned migration instead.
|
|
21
|
+
*/
|
|
22
|
+
export const MIGRATIONS = [
|
|
23
|
+
{
|
|
24
|
+
version: 1,
|
|
25
|
+
name: 'core',
|
|
26
|
+
sql: `
|
|
27
|
+
CREATE TABLE installs (
|
|
28
|
+
installation_id TEXT PRIMARY KEY,
|
|
29
|
+
shop_domain TEXT,
|
|
30
|
+
shop_name TEXT,
|
|
31
|
+
external_account_ref TEXT,
|
|
32
|
+
external_account_name TEXT,
|
|
33
|
+
status TEXT NOT NULL DEFAULT 'inactive',
|
|
34
|
+
installed_at TEXT,
|
|
35
|
+
activated_at TEXT,
|
|
36
|
+
deactivated_at TEXT,
|
|
37
|
+
uninstalled_at TEXT,
|
|
38
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
39
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
40
|
+
);
|
|
41
|
+
CREATE INDEX idx_installs_status ON installs(status);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE webhook_log (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
event TEXT,
|
|
46
|
+
installation_id TEXT,
|
|
47
|
+
signature_ok INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
payload_json TEXT,
|
|
49
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX idx_webhook_log_install ON webhook_log(installation_id);
|
|
52
|
+
|
|
53
|
+
CREATE TABLE integration_state (
|
|
54
|
+
installation_id TEXT NOT NULL,
|
|
55
|
+
key TEXT NOT NULL,
|
|
56
|
+
value TEXT,
|
|
57
|
+
encrypted INTEGER NOT NULL DEFAULT 0,
|
|
58
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
59
|
+
PRIMARY KEY (installation_id, key)
|
|
60
|
+
);
|
|
61
|
+
`,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
version: 2,
|
|
65
|
+
name: 'sync',
|
|
66
|
+
sql: `
|
|
67
|
+
CREATE TABLE sync_run (
|
|
68
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
+
installation_id TEXT NOT NULL,
|
|
70
|
+
status TEXT NOT NULL DEFAULT 'running',
|
|
71
|
+
summary_json TEXT,
|
|
72
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
73
|
+
finished_at TEXT
|
|
74
|
+
);
|
|
75
|
+
CREATE INDEX idx_sync_run_install ON sync_run(installation_id);
|
|
76
|
+
|
|
77
|
+
-- Cursor per (installation, entity, source_key). source_key = '' for the
|
|
78
|
+
-- global scope; a store id for the 'per-source' scope.
|
|
79
|
+
CREATE TABLE sync_cursor (
|
|
80
|
+
installation_id TEXT NOT NULL,
|
|
81
|
+
entity TEXT NOT NULL,
|
|
82
|
+
source_key TEXT NOT NULL DEFAULT '',
|
|
83
|
+
last_synced_at TEXT,
|
|
84
|
+
last_status TEXT,
|
|
85
|
+
last_error TEXT,
|
|
86
|
+
items INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
88
|
+
PRIMARY KEY (installation_id, entity, source_key)
|
|
89
|
+
);
|
|
90
|
+
`,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
version: 3,
|
|
94
|
+
name: 'inbound',
|
|
95
|
+
sql: `
|
|
96
|
+
-- Log of INBOUND calls (integrator app -> integration, the middleware).
|
|
97
|
+
-- Backs idempotency (unique key per installation) and audit. Persisted BEFORE
|
|
98
|
+
-- processing -> no event lost; a replay after success is short-circuited.
|
|
99
|
+
CREATE TABLE inbound_event (
|
|
100
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
101
|
+
installation_id TEXT NOT NULL,
|
|
102
|
+
idempotency_key TEXT NOT NULL,
|
|
103
|
+
action TEXT,
|
|
104
|
+
status TEXT NOT NULL DEFAULT 'received',
|
|
105
|
+
error TEXT,
|
|
106
|
+
received_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
107
|
+
processed_at TEXT
|
|
108
|
+
);
|
|
109
|
+
CREATE UNIQUE INDEX idx_inbound_event_key ON inbound_event(installation_id, idempotency_key);
|
|
110
|
+
`,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
version: 4,
|
|
114
|
+
name: 'webhook_dedup',
|
|
115
|
+
sql: `
|
|
116
|
+
-- Replay protection for lifecycle webhooks: we claim a key derived from the
|
|
117
|
+
-- SIGNATURE (unique per timestamp+body) BEFORE processing. A verbatim replay
|
|
118
|
+
-- (same signature) within the tolerance window is short-circuited. The row is
|
|
119
|
+
-- kept only if processing SUCCEEDS (otherwise released -> retry allowed).
|
|
120
|
+
CREATE TABLE webhook_seen (
|
|
121
|
+
installation_id TEXT NOT NULL,
|
|
122
|
+
dedup_key TEXT NOT NULL,
|
|
123
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
124
|
+
PRIMARY KEY (installation_id, dedup_key)
|
|
125
|
+
);
|
|
126
|
+
`,
|
|
127
|
+
},
|
|
128
|
+
];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type DatabaseT from 'better-sqlite3';
|
|
2
|
+
import type { SecretCipher } from '../security/crypto.js';
|
|
3
|
+
import type { InstallRow, InstallUpsert, CursorRow, CursorWrite, SyncRunRow, InboundEventRow } from './types.js';
|
|
4
|
+
/** Installs (one row per installation). COALESCE upsert: a null field does not overwrite. */
|
|
5
|
+
export declare class InstallRepo {
|
|
6
|
+
private readonly db;
|
|
7
|
+
constructor(db: DatabaseT.Database);
|
|
8
|
+
upsert(u: InstallUpsert): void;
|
|
9
|
+
setStatus(installationId: string, status: InstallRow['status'], stamps?: {
|
|
10
|
+
activated_at?: string | null;
|
|
11
|
+
deactivated_at?: string | null;
|
|
12
|
+
uninstalled_at?: string | null;
|
|
13
|
+
}): void;
|
|
14
|
+
/** Sets the INTEGRATOR account associated with the installation (the correlation bridge). */
|
|
15
|
+
setExternalAccount(installationId: string, ref: string | null, name?: string | null): void;
|
|
16
|
+
find(installationId: string): InstallRow | undefined;
|
|
17
|
+
listActive(): InstallRow[];
|
|
18
|
+
}
|
|
19
|
+
/** Webhook log. The `payload_json` MUST already be redacted by the runtime. */
|
|
20
|
+
export declare class WebhookLogRepo {
|
|
21
|
+
private readonly db;
|
|
22
|
+
constructor(db: DatabaseT.Database);
|
|
23
|
+
log(entry: {
|
|
24
|
+
event: string | null;
|
|
25
|
+
installation_id?: string | null;
|
|
26
|
+
signature_ok: boolean;
|
|
27
|
+
payload_json: string;
|
|
28
|
+
}): void;
|
|
29
|
+
/** Retention: deletes log rows older than `days` days. Returns the number of rows removed. */
|
|
30
|
+
purgeOlderThan(days: number): number;
|
|
31
|
+
}
|
|
32
|
+
/** Replay protection for lifecycle webhooks (key derived from the signature). */
|
|
33
|
+
export declare class WebhookSeenRepo {
|
|
34
|
+
private readonly db;
|
|
35
|
+
constructor(db: DatabaseT.Database);
|
|
36
|
+
/** Atomically claims processing; `false` if the signature was already seen (replay). */
|
|
37
|
+
claim(installationId: string, dedupKey: string): boolean;
|
|
38
|
+
/** Releases a claim (failed processing) -> an identical resend can retry. */
|
|
39
|
+
release(installationId: string, dedupKey: string): void;
|
|
40
|
+
/** Retention: deletes dedup rows older than `days` days. Returns the number of rows removed. */
|
|
41
|
+
purgeOlderThan(days: number): number;
|
|
42
|
+
}
|
|
43
|
+
/** Sync cursors, scoped by (installation, entity, source_key). */
|
|
44
|
+
export declare class CursorRepo {
|
|
45
|
+
private readonly db;
|
|
46
|
+
constructor(db: DatabaseT.Database);
|
|
47
|
+
get(installationId: string, entity: string, sourceKey?: string): CursorRow | undefined;
|
|
48
|
+
set(installationId: string, entity: string, sourceKey: string, w: CursorWrite): void;
|
|
49
|
+
}
|
|
50
|
+
/** History of sync runs. */
|
|
51
|
+
export declare class RunRepo {
|
|
52
|
+
private readonly db;
|
|
53
|
+
constructor(db: DatabaseT.Database);
|
|
54
|
+
start(installationId: string): number;
|
|
55
|
+
finish(runId: number, status: 'ok' | 'partial' | 'failed', summary: unknown): void;
|
|
56
|
+
recent(installationId: string, limit?: number): SyncRunRow[];
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Private integration state (KV per installation). Sensitive values are encrypted
|
|
60
|
+
* at rest via the kit's `SecretCipher`.
|
|
61
|
+
*/
|
|
62
|
+
export declare class IntegrationStateRepo {
|
|
63
|
+
private readonly db;
|
|
64
|
+
private readonly cipher;
|
|
65
|
+
constructor(db: DatabaseT.Database, cipher: SecretCipher);
|
|
66
|
+
private write;
|
|
67
|
+
set(installationId: string, key: string, value: string): void;
|
|
68
|
+
setSecret(installationId: string, key: string, value: string): void;
|
|
69
|
+
get(installationId: string, key: string): string | null;
|
|
70
|
+
delete(installationId: string, key: string): void;
|
|
71
|
+
}
|
|
72
|
+
/** Log of inbound calls (middleware idempotency + audit). */
|
|
73
|
+
export declare class InboundEventRepo {
|
|
74
|
+
private readonly db;
|
|
75
|
+
constructor(db: DatabaseT.Database);
|
|
76
|
+
find(installationId: string, idempotencyKey: string): InboundEventRow | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* ATOMICALLY claims the processing of an inbound call (anti-TOCTOU). The INSERT
|
|
79
|
+
* `ON CONFLICT DO NOTHING` relies on the UNIQUE index (installation_id, idempotency_key):
|
|
80
|
+
* - `fresh: true` => row created by THIS call -> it is responsible for processing;
|
|
81
|
+
* - `fresh: false` => a row already existed -> `status` tells where it stands
|
|
82
|
+
* ('received' = in progress/already claimed, 'done' = processed, 'failed' = retryable).
|
|
83
|
+
*/
|
|
84
|
+
claim(installationId: string, idempotencyKey: string, action: string | null): {
|
|
85
|
+
rowId: number;
|
|
86
|
+
fresh: boolean;
|
|
87
|
+
status: InboundEventRow['status'];
|
|
88
|
+
};
|
|
89
|
+
finish(id: number, status: 'done' | 'failed', error?: string | null): void;
|
|
90
|
+
/** Retention: deletes inbound rows older than `days` days. Returns the number of rows removed. */
|
|
91
|
+
purgeOlderThan(days: number): number;
|
|
92
|
+
}
|
|
93
|
+
export interface Repositories {
|
|
94
|
+
installs: InstallRepo;
|
|
95
|
+
webhookLog: WebhookLogRepo;
|
|
96
|
+
webhookSeen: WebhookSeenRepo;
|
|
97
|
+
cursors: CursorRepo;
|
|
98
|
+
runs: RunRepo;
|
|
99
|
+
state: IntegrationStateRepo;
|
|
100
|
+
inboundEvents: InboundEventRepo;
|
|
101
|
+
}
|
|
102
|
+
export declare function createRepositories(db: DatabaseT.Database, cipher: SecretCipher): Repositories;
|