@intenque/network-sdk 0.0.0-stage → 2.2.0-beta.2

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.
@@ -0,0 +1,350 @@
1
+ export { a as SDK_VERSION } from '../constants-C_6Zxu_k.cjs';
2
+
3
+ type SignInput = {
4
+ secret: string;
5
+ siteKey: string;
6
+ keyId: string;
7
+ method: string;
8
+ /** Path only (`/v1/conversions`, `/v1/conversions/ping`, `/v1/referrals/<clickId>`, `/v1/landings`). */
9
+ path: string;
10
+ /** The exact bytes sent (`''` for an empty body). */
11
+ body: string | Uint8Array;
12
+ /** Unix seconds; defaults to now. */
13
+ timestamp?: number;
14
+ /** uuid v4; defaults to a fresh one. Every attempt re-signs with a fresh nonce (§6.5). */
15
+ nonce?: string;
16
+ };
17
+ type SignedHeaders = {
18
+ 'Content-Type': 'application/json';
19
+ 'X-INR-Site': string;
20
+ 'X-INR-Key-Id': string;
21
+ 'X-INR-Timestamp': string;
22
+ 'X-INR-Nonce': string;
23
+ 'X-INR-Signature': string;
24
+ };
25
+ declare function sha256Hex(data: string | Uint8Array): string;
26
+ /** The v1 HMAC base string: the identifiers are bound, so a signature never verifies under another site or key. */
27
+ declare function hmacBase(timestamp: number, nonce: string, siteKey: string, keyId: string, method: string, path: string, body: string | Uint8Array): string;
28
+ declare function signRequest(input: SignInput): {
29
+ headers: SignedHeaders;
30
+ timestamp: number;
31
+ nonce: string;
32
+ signature: string;
33
+ };
34
+ /** Constant-time check of a `v1=<hex>` signature (what the hub does; here for tests and ports). */
35
+ declare function verifySignature(secret: string, input: {
36
+ timestamp: number;
37
+ nonce: string;
38
+ siteKey: string;
39
+ keyId: string;
40
+ method: string;
41
+ path: string;
42
+ body: string | Uint8Array;
43
+ }, signature: string): boolean;
44
+
45
+ type ReferralRef = {
46
+ clickId: string;
47
+ firstClickId: string | null;
48
+ tokenScope: 'session' | 'persistent';
49
+ };
50
+ type ReqLike = {
51
+ body?: unknown;
52
+ headers?: Record<string, unknown> | undefined;
53
+ get?: (name: string) => string | undefined;
54
+ };
55
+ declare function referralFromRequest(req: ReqLike): ReferralRef | null;
56
+ /**
57
+ * Express middleware alternative: sets `req.inrRef` (`ReferralRef | null`), then removes an own `inrRef` from any
58
+ * object body (valid or not; plain objects, arrays and class instances alike), so `new Model(req.body)` and strict
59
+ * validators never see it (2.2 changelog). A body that cannot be changed (frozen, a throwing proxy) keeps it and never
60
+ * throws.
61
+ */
62
+ declare function referralMiddleware(): (req: ReqLike & {
63
+ inrRef?: ReferralRef | null;
64
+ }, _res: unknown, next: () => void) => void;
65
+
66
+ /**
67
+ * Mongo outbox for conversion reports — spec §6.5: collection `inrConversionOutbox` (TTL 14 d, matching the hub's
68
+ * `occurredAt` lower bound), one row per `(eventType, eventId)`, storing the UNSIGNED body (every attempt re-signs
69
+ * with a fresh timestamp and nonce). `nextAttemptAt` is the lease (addendum "Claim and lease"); `unauthorizedSince`
70
+ * carries the 24 h rule of 401, missing config and misrouted answers (403, 404, a 3xx). The site passes its own `mongoose` (7 or 8; the model API used is identical on both); the
71
+ * SDK has no runtime dependency on it. The schema declares no index and has `autoIndex: false`: `ensureOutboxIndexes`
72
+ * creates the three helper indexes itself (addendum "Outbox row v2 and indexes"). The bounded in-memory spill holds
73
+ * reports whose enqueue failed or timed out (addendum "`report()`").
74
+ */
75
+ declare const OUTBOX_DEFAULT_COLLECTION = "inrConversionOutbox";
76
+ declare const OUTBOX_TTL_SECONDS: number;
77
+ /** The last failed attempt: HTTP status (0 for transport failures and missing config) and the hub's error code. */
78
+ type OutboxError = {
79
+ status: number;
80
+ code: string | null;
81
+ };
82
+ type OutboxEntry = {
83
+ eventType: string;
84
+ eventId: string;
85
+ body: Record<string, unknown>;
86
+ attempts: number;
87
+ nextAttemptAt: Date;
88
+ lastStatus: number | null;
89
+ lastError: OutboxError | null;
90
+ /** Set by the first 401, missing config or misrouted answer (403, 404, a 3xx); cleared by any other. */
91
+ unauthorizedSince: Date | null;
92
+ createdAt: Date;
93
+ };
94
+ type OutboxKey = Pick<OutboxEntry, 'eventType' | 'eventId'>;
95
+ /** What a retried attempt writes back; `attempts` is the number of attempts this sender made. */
96
+ type ReschedulePatch = {
97
+ nextAttemptAt: Date;
98
+ lastStatus: number;
99
+ lastError: OutboxError;
100
+ attempts: number;
101
+ /** The 401 / missing-config clock: kept at its earliest value; `null` clears it. */
102
+ unauthorizedAt: Date | null;
103
+ };
104
+ /** The subset of mongoose the outbox touches (so tests can pass a stub and the SDK stays dependency-free). */
105
+ type MongooseLike = {
106
+ Schema: new (definition: Record<string, unknown>, options?: Record<string, unknown>) => unknown;
107
+ models: Record<string, unknown>;
108
+ model: (name: string, schema?: unknown, collection?: string) => unknown;
109
+ };
110
+ type Thenable<T> = Promise<T> | {
111
+ exec: () => Promise<T>;
112
+ };
113
+ type Filter = Record<string, unknown>;
114
+ /** The model methods used (addendum "Mongoose 7 and 8"). */
115
+ type OutboxModelLike = {
116
+ updateOne: (filter: Filter, update: Filter, options?: Filter) => Thenable<unknown>;
117
+ findOneAndUpdate?: (filter: Filter, update: Filter, options?: Filter) => Thenable<unknown>;
118
+ deleteOne?: (filter: Filter) => Thenable<unknown>;
119
+ deleteMany?: (filter: Filter) => Thenable<unknown>;
120
+ countDocuments: (filter?: Filter) => Thenable<number>;
121
+ collection?: {
122
+ createIndex: (keys: Filter, options?: Filter) => Promise<unknown>;
123
+ };
124
+ };
125
+ declare function outboxSchemaDefinition(): Record<string, unknown>;
126
+ /** The three helper indexes (default names), created by `ensureOutboxIndexes`, never by `autoIndex`. */
127
+ declare const OUTBOX_INDEXES: readonly [Filter, Filter][];
128
+ /** Builds (or reuses) the outbox model on the given mongoose instance. */
129
+ declare function outboxModel(mongoose: MongooseLike, collection?: string): OutboxModelLike;
130
+ /**
131
+ * Creates the helper indexes (`collection.createIndex`, so `autoIndex: false` sites get them too): each one on its
132
+ * own, so a conflict on one leaves the others created. Called at construction and at every drain tick until all three
133
+ * succeed (a reporter built before `mongoose.connect()` times out its first try). Failures are one warn
134
+ * `INR_OUTBOX_INDEX_FAILED` per process and model; reporting goes on without them.
135
+ */
136
+ declare function ensureOutboxIndexes(model: OutboxModelLike, warn: (message: string) => void): Promise<void>;
137
+ type Outbox = {
138
+ /**
139
+ * Idempotent on `(eventType, eventId)`: inserts the entry's whole state once; a repeat leaves the existing row
140
+ * alone. Resolves `true` when this call inserted the row.
141
+ */
142
+ enqueue: (entry: OutboxEntry) => Promise<boolean>;
143
+ /** Success or a drop. */
144
+ remove: (key: OutboxKey) => Promise<void>;
145
+ /** A retried attempt. Never creates a row. */
146
+ reschedule: (key: OutboxKey, patch: ReschedulePatch) => Promise<void>;
147
+ /** An in-process chain renews its row's lease before each retry; false when no such row exists (purged). */
148
+ renew: (key: OutboxKey, until: Date) => Promise<boolean>;
149
+ /** The drain claim: the most overdue due row, its lease moved to `until` and `attempts + 1`; null when none. */
150
+ claim: (now: Date, until: Date) => Promise<OutboxEntry | null>;
151
+ /** `forget()`: deletes every row whose body names the click id (as `clickId` or `firstClickId`). */
152
+ purge: (clickId: string) => Promise<void>;
153
+ pending: () => Promise<number>;
154
+ };
155
+ declare function createOutbox(model: OutboxModelLike): Outbox;
156
+ /** In-memory outbox for sites without Mongo (tests, `enabled: false`): lost on restart, by design. */
157
+ declare function createMemoryOutbox(): Outbox & {
158
+ entries: OutboxEntry[];
159
+ };
160
+ /** Reports whose enqueue failed or timed out, oldest first, at most `max`. */
161
+ type Spill = {
162
+ /** Adds the entry unless its key is already spilled; returns the oldest entry it evicted, if any. */
163
+ add: (entry: OutboxEntry) => OutboxEntry | null;
164
+ has: (key: OutboxKey) => boolean;
165
+ get: (key: OutboxKey) => OutboxEntry | undefined;
166
+ delete: (key: OutboxKey) => void;
167
+ entries: () => OutboxEntry[];
168
+ readonly size: number;
169
+ };
170
+
171
+ type Logger = {
172
+ warn: (message: string, meta?: Record<string, unknown>) => void;
173
+ info?: (message: string, meta?: Record<string, unknown>) => void;
174
+ };
175
+ type Classification = 'success' | 'terminal' | 'retryable' | 'skew' | 'unauthorized' | 'misrouted';
176
+ /**
177
+ * The §6.5 matrix (status first, then code; a missing body is classified by status). Status 0 is a transport failure
178
+ * (network error, timeout). 403, 404 and any 3xx that reaches it are `misrouted` (a proxy, WAF or maintenance page in
179
+ * the way, a missing route prefix): retried in process, then every 10 min, dropped 24 h after the first like a 401.
180
+ * A real redirect never gets here: fetch with `redirect: 'error'` fails 301/302/303/307/308 (with or without
181
+ * `Location`) as a transport error (status 0, retryable), so only 300, 304 and the like are classified.
182
+ */
183
+ declare function classifyResponse(status: number, code: string | null): Classification;
184
+
185
+ /**
186
+ * The conversion wire contract as the SDK mirrors it — Phase 3 addendum "ConversionBodySchema home and SDK mirror",
187
+ * "EventId", "Link tables", "Per-type body rules", "`fee_charged`", "Site secret format", "`keyId`". packages/shared
188
+ * is the source; the server entry never imports it (zero runtime deps), and test/conversions-contract.test.ts fails
189
+ * when either side changes alone.
190
+ */
191
+ /** §5.5 canonical conversion types plus `fee_charged` (R2(c)): 12 entries, in the hub's order. */
192
+ declare const CONVERSION_TYPES: readonly ["booking_created", "booking_paid", "contact_submitted", "quote_requested", "call_clicked", "signup", "purchase", "booking_cancelled", "booking_refunded", "booking_no_show", "chargeback", "fee_charged"];
193
+ type ConversionType = (typeof CONVERSION_TYPES)[number];
194
+ /** Every type that carries `reverses`; a fee names the no-show or cancel it belongs to (it is not a reversal). */
195
+ declare const LINK_TARGETS: {
196
+ readonly fee_charged: readonly ["booking_no_show", "booking_cancelled"];
197
+ readonly booking_cancelled: readonly ["booking_created"];
198
+ readonly booking_no_show: readonly ["booking_created"];
199
+ readonly booking_refunded: readonly ["booking_paid"];
200
+ readonly chargeback: readonly ["booking_paid", "purchase"];
201
+ };
202
+ declare const REVERSAL_TYPES: readonly ("booking_cancelled" | "booking_refunded" | "booking_no_show" | "chargeback")[];
203
+
204
+ /**
205
+ * The outbox drainer — Phase 3 addendum "Drainer", "Claim and lease", "Drop logging". Each tick moves the spill into
206
+ * the outbox, then claims (lease now + 150 s, `attempts + 1`) and sends up to 50 due rows, one at a time; a 429 or 503
207
+ * ends the tick. Every attempt goes through `Delivery.attempt`, so it re-reads the config (a rotated `INR_KEY_ID` /
208
+ * `INR_SITE_SECRET` in `process.env` applies at the next row) and re-signs with a fresh timestamp and nonce. Ticks
209
+ * never overlap: the first at 10 s + U(0, 10 s), then 60 s ± 10 s after the previous one ends, unref'd.
210
+ */
211
+
212
+ type DrainResult = {
213
+ sent: number;
214
+ retried: number;
215
+ dropped: number;
216
+ };
217
+
218
+ type RelayRequest = {
219
+ body?: unknown;
220
+ ip?: string | undefined;
221
+ headers?: Record<string, unknown> | undefined;
222
+ app?: {
223
+ get: (name: string) => unknown;
224
+ } | undefined;
225
+ readable?: boolean;
226
+ on?: (event: string, listener: (chunk?: unknown) => void) => unknown;
227
+ };
228
+ type RelayResponse = {
229
+ status: (code: number) => {
230
+ end: () => void;
231
+ };
232
+ };
233
+ type RelayOptions = {
234
+ /** The visitor's address when `req.ip` is not it (another proxy setup); Express `trust proxy` is still required. */
235
+ visitorIp?: ((req: RelayRequest) => string | null | undefined) | undefined;
236
+ };
237
+
238
+ /** `report()` resolves after at most this, whatever the outbox does. */
239
+ declare const ENQUEUE_TIMEOUT_MS = 2000;
240
+ type ConversionEvent = {
241
+ eventType: ConversionType;
242
+ eventId: string;
243
+ clickId: string;
244
+ firstClickId?: string | null | undefined;
245
+ tokenScope: 'session' | 'persistent';
246
+ occurredAt: Date | string;
247
+ valueCents?: number | null | undefined;
248
+ feeCents?: number | null | undefined;
249
+ currency?: string | null | undefined;
250
+ reverses?: {
251
+ eventType: ConversionType;
252
+ eventId: string;
253
+ } | null | undefined;
254
+ };
255
+ /** §5.4 v1 body (unknown keys → 400 on the hub, so nothing else is ever added here). */
256
+ type ConversionBody = {
257
+ v: 1;
258
+ eventType: ConversionType;
259
+ eventId: string;
260
+ clickId: string;
261
+ firstClickId: string | null;
262
+ tokenScope: 'session' | 'persistent';
263
+ occurredAt: string;
264
+ valueCents: number | null;
265
+ feeCents: number | null;
266
+ currency: string | null;
267
+ reverses: {
268
+ eventType: ConversionType;
269
+ eventId: string;
270
+ } | null;
271
+ sdk: string;
272
+ };
273
+ type ReporterOptions = {
274
+ hubUrl?: string | undefined;
275
+ siteKey?: string | undefined;
276
+ /** `INR_KEY_ID` / `INR_SITE_SECRET` in `process.env` win at every attempt, so a redeploy after rotation applies. */
277
+ keyId?: string | undefined;
278
+ secret?: string | undefined;
279
+ /** `INR_ENABLED !== 'false'`; when false `report()` validates and drops. */
280
+ enabled?: boolean | undefined;
281
+ /** Per attempt; default `INR_TIMEOUT_MS` or 3000, clamped to [500, 30 000]. */
282
+ timeoutMs?: number | undefined;
283
+ /** `collection` defaults to `INR_OUTBOX_COLLECTION`, else `inrConversionOutbox` (the name `doctor` checks). */
284
+ outbox?: {
285
+ mongoose?: MongooseLike;
286
+ collection?: string;
287
+ } | undefined;
288
+ logger?: Logger | undefined;
289
+ };
290
+ type ReportResult =
291
+ /** Durable in the outbox. */
292
+ {
293
+ status: 'queued';
294
+ body: ConversionBody;
295
+ }
296
+ /** The outbox write failed or timed out: held in memory (≤ 1000) until the next drain; attempts still run. */
297
+ | {
298
+ status: 'spilled';
299
+ body: ConversionBody;
300
+ } | {
301
+ status: 'disabled';
302
+ body: ConversionBody;
303
+ } | {
304
+ status: 'invalid';
305
+ error: string;
306
+ };
307
+ type ConversionReporter = {
308
+ /** Validates, builds the body, queues it and starts the in-process attempts. Never throws, never rejects. */
309
+ report: (event: ConversionEvent) => Promise<ReportResult>;
310
+ /**
311
+ * §5.9 erasure: purges queued reports naming the id, then the signed `DELETE /v1/referrals/:clickId` (up to 4
312
+ * attempts). True once the hub answered 2xx; keep the local referral until then. Ignores `enabled`.
313
+ */
314
+ forget: (clickId: string) => Promise<boolean>;
315
+ /** §5.3 first-party landing relay: an Express handler that always answers 204 (relay.ts). */
316
+ landingRelay: (opts?: RelayOptions) => (req: RelayRequest, res: RelayResponse) => Promise<void>;
317
+ /** The unsigned body an event produces (exposed for tests and for ports). */
318
+ bodyFor: (event: ConversionEvent) => ConversionBody | {
319
+ error: string;
320
+ };
321
+ /** One drain tick now (joins a running one). */
322
+ drainNow: () => Promise<DrainResult>;
323
+ /** Stops the drainer and waits ≤ 5 s for a running tick and the send chains (graceful shutdown). */
324
+ close: () => Promise<void>;
325
+ outbox: Outbox;
326
+ spill: Spill;
327
+ /** Resolves once every in-process attempt chain started so far has settled (tests, graceful shutdown). */
328
+ idle: () => Promise<void>;
329
+ };
330
+ /**
331
+ * The C.4 body, or the first rule it breaks (mirrors `ConversionBodySchema`): eventId per EVENT_ID_RE, clickId and
332
+ * tokenScope non-null, the per-type amount rules, currency required with any amount, `reverses` required on the
333
+ * linked types with `eventType ∈ LINK_TARGETS[type]` and null otherwise. Currency is upper-cased; a valid `Date` or an
334
+ * ISO datetime string with `Z` or an offset is sent as ISO UTC with milliseconds.
335
+ */
336
+ declare function buildConversionBody(event: ConversionEvent): ConversionBody | {
337
+ error: string;
338
+ };
339
+ /**
340
+ * §6.5 `verifyEnv()`: returns the missing or invalid names (`INR_KEY_ID (invalid)`); never prints a value. Throws
341
+ * when `NODE_ENV !== 'production'` or `INR_STRICT_ENV === 'true'` (JCM staging runs NODE_ENV=production), warns
342
+ * otherwise so a misconfigured reporter never takes a production backend down. `opts.production` overrides NODE_ENV.
343
+ */
344
+ declare function verifyEnv(env?: Record<string, string | undefined>, opts?: {
345
+ production?: boolean;
346
+ logger?: Logger;
347
+ }): string[];
348
+ declare function createConversionReporter(opts?: ReporterOptions): ConversionReporter;
349
+
350
+ export { CONVERSION_TYPES, type Classification, type ConversionBody, type ConversionEvent, type ConversionReporter, type ConversionType, type DrainResult, ENQUEUE_TIMEOUT_MS, LINK_TARGETS, type Logger, type MongooseLike, OUTBOX_DEFAULT_COLLECTION, OUTBOX_INDEXES, OUTBOX_TTL_SECONDS, type Outbox, type OutboxEntry, type OutboxError, REVERSAL_TYPES, type ReferralRef, type RelayOptions, type ReportResult, type ReporterOptions, type SignInput, type SignedHeaders, type Spill, buildConversionBody, classifyResponse, createConversionReporter, createMemoryOutbox, createOutbox, ensureOutboxIndexes, hmacBase, outboxModel, outboxSchemaDefinition, referralFromRequest, referralMiddleware, sha256Hex, signRequest, verifyEnv, verifySignature };