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

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,345 @@
1
+ export { a as SDK_VERSION } from '../constants-LVhc0PNb.js';
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 `inrRef` from a plain-object
58
+ * body (valid or not), so `new Model(req.body)` and strict validators never see it (2.2 changelog).
59
+ */
60
+ declare function referralMiddleware(): (req: ReqLike & {
61
+ inrRef?: ReferralRef | null;
62
+ }, _res: unknown, next: () => void) => void;
63
+
64
+ /**
65
+ * Mongo outbox for conversion reports — spec §6.5: collection `inrConversionOutbox` (TTL 14 d, matching the hub's
66
+ * `occurredAt` lower bound), one row per `(eventType, eventId)`, storing the UNSIGNED body (every attempt re-signs
67
+ * with a fresh timestamp and nonce). `nextAttemptAt` is the lease (addendum "Claim and lease"); `unauthorizedSince`
68
+ * carries the 24 h 401 rule. The site passes its own `mongoose` (7 or 8; the model API used is identical on both); the
69
+ * SDK has no runtime dependency on it. The schema declares no index and has `autoIndex: false`: `ensureOutboxIndexes`
70
+ * creates the three helper indexes itself (addendum "Outbox row v2 and indexes"). The bounded in-memory spill holds
71
+ * reports whose enqueue failed or timed out (addendum "`report()`").
72
+ */
73
+ declare const OUTBOX_DEFAULT_COLLECTION = "inrConversionOutbox";
74
+ declare const OUTBOX_TTL_SECONDS: number;
75
+ /** The last failed attempt: HTTP status (0 for transport failures and missing config) and the hub's error code. */
76
+ type OutboxError = {
77
+ status: number;
78
+ code: string | null;
79
+ };
80
+ type OutboxEntry = {
81
+ eventType: string;
82
+ eventId: string;
83
+ body: Record<string, unknown>;
84
+ attempts: number;
85
+ nextAttemptAt: Date;
86
+ lastStatus: number | null;
87
+ lastError: OutboxError | null;
88
+ /** Set by the first 401 (or missing config), cleared by any other outcome. */
89
+ unauthorizedSince: Date | null;
90
+ createdAt: Date;
91
+ };
92
+ type OutboxKey = Pick<OutboxEntry, 'eventType' | 'eventId'>;
93
+ /** What a retried attempt writes back; `attempts` is the number of attempts this sender made. */
94
+ type ReschedulePatch = {
95
+ nextAttemptAt: Date;
96
+ lastStatus: number;
97
+ lastError: OutboxError;
98
+ attempts: number;
99
+ /** The 401 / missing-config clock: kept at its earliest value; `null` clears it. */
100
+ unauthorizedAt: Date | null;
101
+ };
102
+ /** The subset of mongoose the outbox touches (so tests can pass a stub and the SDK stays dependency-free). */
103
+ type MongooseLike = {
104
+ Schema: new (definition: Record<string, unknown>, options?: Record<string, unknown>) => unknown;
105
+ models: Record<string, unknown>;
106
+ model: (name: string, schema?: unknown, collection?: string) => unknown;
107
+ };
108
+ type Thenable<T> = Promise<T> | {
109
+ exec: () => Promise<T>;
110
+ };
111
+ type Filter = Record<string, unknown>;
112
+ /** The model methods used (addendum "Mongoose 7 and 8"). */
113
+ type OutboxModelLike = {
114
+ updateOne: (filter: Filter, update: Filter, options?: Filter) => Thenable<unknown>;
115
+ findOneAndUpdate?: (filter: Filter, update: Filter, options?: Filter) => Thenable<unknown>;
116
+ deleteOne?: (filter: Filter) => Thenable<unknown>;
117
+ deleteMany?: (filter: Filter) => Thenable<unknown>;
118
+ countDocuments: (filter?: Filter) => Thenable<number>;
119
+ collection?: {
120
+ createIndex: (keys: Filter, options?: Filter) => Promise<unknown>;
121
+ };
122
+ };
123
+ declare function outboxSchemaDefinition(): Record<string, unknown>;
124
+ /** The three helper indexes (default names), created by `ensureOutboxIndexes`, never by `autoIndex`. */
125
+ declare const OUTBOX_INDEXES: readonly [Filter, Filter][];
126
+ /** Builds (or reuses) the outbox model on the given mongoose instance. */
127
+ declare function outboxModel(mongoose: MongooseLike, collection?: string): OutboxModelLike;
128
+ /**
129
+ * Creates the helper indexes (`collection.createIndex`, so `autoIndex: false` sites get them too): each one on its
130
+ * own, so a conflict on one leaves the others created. Called at construction and at every drain tick until all three
131
+ * succeed (a reporter built before `mongoose.connect()` times out its first try). Failures are one warn
132
+ * `INR_OUTBOX_INDEX_FAILED` per process and model; reporting goes on without them.
133
+ */
134
+ declare function ensureOutboxIndexes(model: OutboxModelLike, warn: (message: string) => void): Promise<void>;
135
+ type Outbox = {
136
+ /**
137
+ * Idempotent on `(eventType, eventId)`: inserts the entry's whole state once; a repeat leaves the existing row
138
+ * alone. Resolves `true` when this call inserted the row.
139
+ */
140
+ enqueue: (entry: OutboxEntry) => Promise<boolean>;
141
+ /** Success or a drop. */
142
+ remove: (key: OutboxKey) => Promise<void>;
143
+ /** A retried attempt. Never creates a row. */
144
+ reschedule: (key: OutboxKey, patch: ReschedulePatch) => Promise<void>;
145
+ /** An in-process chain renews its row's lease before each retry. */
146
+ renew: (key: OutboxKey, until: Date) => Promise<void>;
147
+ /** The drain claim: the most overdue due row, its lease moved to `until` and `attempts + 1`; null when none. */
148
+ claim: (now: Date, until: Date) => Promise<OutboxEntry | null>;
149
+ /** `forget()`: deletes every row whose body names the click id (as `clickId` or `firstClickId`). */
150
+ purge: (clickId: string) => Promise<void>;
151
+ pending: () => Promise<number>;
152
+ };
153
+ declare function createOutbox(model: OutboxModelLike): Outbox;
154
+ /** In-memory outbox for sites without Mongo (tests, `enabled: false`): lost on restart, by design. */
155
+ declare function createMemoryOutbox(): Outbox & {
156
+ entries: OutboxEntry[];
157
+ };
158
+ /** Reports whose enqueue failed or timed out, oldest first, at most `max`. */
159
+ type Spill = {
160
+ /** Adds the entry unless its key is already spilled; returns the oldest entry it evicted, if any. */
161
+ add: (entry: OutboxEntry) => OutboxEntry | null;
162
+ has: (key: OutboxKey) => boolean;
163
+ get: (key: OutboxKey) => OutboxEntry | undefined;
164
+ delete: (key: OutboxKey) => void;
165
+ entries: () => OutboxEntry[];
166
+ readonly size: number;
167
+ };
168
+
169
+ type Logger = {
170
+ warn: (message: string, meta?: Record<string, unknown>) => void;
171
+ info?: (message: string, meta?: Record<string, unknown>) => void;
172
+ };
173
+ type Classification = 'success' | 'terminal' | 'retryable' | 'skew' | 'unauthorized';
174
+ /**
175
+ * The §6.5 matrix (status first, then code; a missing body is classified by status). Status 0 is a transport failure
176
+ * (network error, timeout).
177
+ */
178
+ declare function classifyResponse(status: number, code: string | null): Classification;
179
+
180
+ /**
181
+ * The conversion wire contract as the SDK mirrors it — Phase 3 addendum "ConversionBodySchema home and SDK mirror",
182
+ * "EventId", "Link tables", "Per-type body rules", "`fee_charged`", "Site secret format", "`keyId`". packages/shared
183
+ * is the source; the server entry never imports it (zero runtime deps), and test/conversions-contract.test.ts fails
184
+ * when either side changes alone.
185
+ */
186
+ /** §5.5 canonical conversion types plus `fee_charged` (R2(c)): 12 entries, in the hub's order. */
187
+ 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"];
188
+ type ConversionType = (typeof CONVERSION_TYPES)[number];
189
+ /** Every type that carries `reverses`; a fee names the no-show or cancel it belongs to (it is not a reversal). */
190
+ declare const LINK_TARGETS: {
191
+ readonly fee_charged: readonly ["booking_no_show", "booking_cancelled"];
192
+ readonly booking_cancelled: readonly ["booking_created"];
193
+ readonly booking_no_show: readonly ["booking_created"];
194
+ readonly booking_refunded: readonly ["booking_paid"];
195
+ readonly chargeback: readonly ["booking_paid", "purchase"];
196
+ };
197
+ declare const REVERSAL_TYPES: readonly ("booking_cancelled" | "booking_refunded" | "booking_no_show" | "chargeback")[];
198
+
199
+ /**
200
+ * The outbox drainer — Phase 3 addendum "Drainer", "Claim and lease", "Drop logging". Each tick moves the spill into
201
+ * 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
202
+ * ends the tick. Every attempt goes through `Delivery.attempt`, so it re-reads the config (a rotated `INR_KEY_ID` /
203
+ * `INR_SITE_SECRET` in `process.env` applies at the next row) and re-signs with a fresh timestamp and nonce. Ticks
204
+ * never overlap: the first at 10 s + U(0, 10 s), then 60 s ± 10 s after the previous one ends, unref'd.
205
+ */
206
+
207
+ type DrainResult = {
208
+ sent: number;
209
+ retried: number;
210
+ dropped: number;
211
+ };
212
+
213
+ type RelayRequest = {
214
+ body?: unknown;
215
+ ip?: string | undefined;
216
+ headers?: Record<string, unknown> | undefined;
217
+ app?: {
218
+ get: (name: string) => unknown;
219
+ } | undefined;
220
+ readable?: boolean;
221
+ on?: (event: string, listener: (chunk?: unknown) => void) => unknown;
222
+ };
223
+ type RelayResponse = {
224
+ status: (code: number) => {
225
+ end: () => void;
226
+ };
227
+ };
228
+ type RelayOptions = {
229
+ /** The visitor's address when `req.ip` is not it (another proxy setup); Express `trust proxy` is still required. */
230
+ visitorIp?: ((req: RelayRequest) => string | null | undefined) | undefined;
231
+ };
232
+
233
+ /** `report()` resolves after at most this, whatever the outbox does. */
234
+ declare const ENQUEUE_TIMEOUT_MS = 2000;
235
+ type ConversionEvent = {
236
+ eventType: ConversionType;
237
+ eventId: string;
238
+ clickId: string;
239
+ firstClickId?: string | null | undefined;
240
+ tokenScope: 'session' | 'persistent';
241
+ occurredAt: Date | string;
242
+ valueCents?: number | null | undefined;
243
+ feeCents?: number | null | undefined;
244
+ currency?: string | null | undefined;
245
+ reverses?: {
246
+ eventType: ConversionType;
247
+ eventId: string;
248
+ } | null | undefined;
249
+ };
250
+ /** §5.4 v1 body (unknown keys → 400 on the hub, so nothing else is ever added here). */
251
+ type ConversionBody = {
252
+ v: 1;
253
+ eventType: ConversionType;
254
+ eventId: string;
255
+ clickId: string;
256
+ firstClickId: string | null;
257
+ tokenScope: 'session' | 'persistent';
258
+ occurredAt: string;
259
+ valueCents: number | null;
260
+ feeCents: number | null;
261
+ currency: string | null;
262
+ reverses: {
263
+ eventType: ConversionType;
264
+ eventId: string;
265
+ } | null;
266
+ sdk: string;
267
+ };
268
+ type ReporterOptions = {
269
+ hubUrl?: string | undefined;
270
+ siteKey?: string | undefined;
271
+ /** `INR_KEY_ID` / `INR_SITE_SECRET` in `process.env` win at every attempt, so a redeploy after rotation applies. */
272
+ keyId?: string | undefined;
273
+ secret?: string | undefined;
274
+ /** `INR_ENABLED !== 'false'`; when false `report()` validates and drops. */
275
+ enabled?: boolean | undefined;
276
+ /** Per attempt; default `INR_TIMEOUT_MS` or 3000, clamped to [500, 30 000]. */
277
+ timeoutMs?: number | undefined;
278
+ /** `collection` defaults to `INR_OUTBOX_COLLECTION`, else `inrConversionOutbox` (the name `doctor` checks). */
279
+ outbox?: {
280
+ mongoose?: MongooseLike;
281
+ collection?: string;
282
+ } | undefined;
283
+ logger?: Logger | undefined;
284
+ };
285
+ type ReportResult =
286
+ /** Durable in the outbox. */
287
+ {
288
+ status: 'queued';
289
+ body: ConversionBody;
290
+ }
291
+ /** The outbox write failed or timed out: held in memory (≤ 1000) until the next drain; attempts still run. */
292
+ | {
293
+ status: 'spilled';
294
+ body: ConversionBody;
295
+ } | {
296
+ status: 'disabled';
297
+ body: ConversionBody;
298
+ } | {
299
+ status: 'invalid';
300
+ error: string;
301
+ };
302
+ type ConversionReporter = {
303
+ /** Validates, builds the body, queues it and starts the in-process attempts. Never throws, never rejects. */
304
+ report: (event: ConversionEvent) => Promise<ReportResult>;
305
+ /**
306
+ * §5.9 erasure: purges queued reports naming the id, then the signed `DELETE /v1/referrals/:clickId` (up to 4
307
+ * attempts). True once the hub answered 2xx; keep the local referral until then. Ignores `enabled`.
308
+ */
309
+ forget: (clickId: string) => Promise<boolean>;
310
+ /** §5.3 first-party landing relay: an Express handler that always answers 204 (relay.ts). */
311
+ landingRelay: (opts?: RelayOptions) => (req: RelayRequest, res: RelayResponse) => Promise<void>;
312
+ /** The unsigned body an event produces (exposed for tests and for ports). */
313
+ bodyFor: (event: ConversionEvent) => ConversionBody | {
314
+ error: string;
315
+ };
316
+ /** One drain tick now (joins a running one). */
317
+ drainNow: () => Promise<DrainResult>;
318
+ /** Stops the drainer and waits ≤ 5 s for a running tick and the send chains (graceful shutdown). */
319
+ close: () => Promise<void>;
320
+ outbox: Outbox;
321
+ spill: Spill;
322
+ /** Resolves once every in-process attempt chain started so far has settled (tests, graceful shutdown). */
323
+ idle: () => Promise<void>;
324
+ };
325
+ /**
326
+ * The C.4 body, or the first rule it breaks (mirrors `ConversionBodySchema`): eventId per EVENT_ID_RE, clickId and
327
+ * tokenScope non-null, the per-type amount rules, currency required with any amount, `reverses` required on the
328
+ * linked types with `eventType ∈ LINK_TARGETS[type]` and null otherwise. Currency is upper-cased; a valid `Date` or an
329
+ * ISO datetime string with `Z` or an offset is sent as ISO UTC with milliseconds.
330
+ */
331
+ declare function buildConversionBody(event: ConversionEvent): ConversionBody | {
332
+ error: string;
333
+ };
334
+ /**
335
+ * §6.5 `verifyEnv()`: returns the missing or invalid names (`INR_KEY_ID (invalid)`); never prints a value. Throws
336
+ * when `NODE_ENV !== 'production'` or `INR_STRICT_ENV === 'true'` (JCM staging runs NODE_ENV=production), warns
337
+ * otherwise so a misconfigured reporter never takes a production backend down. `opts.production` overrides NODE_ENV.
338
+ */
339
+ declare function verifyEnv(env?: Record<string, string | undefined>, opts?: {
340
+ production?: boolean;
341
+ logger?: Logger;
342
+ }): string[];
343
+ declare function createConversionReporter(opts?: ReporterOptions): ConversionReporter;
344
+
345
+ 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 };
@@ -0,0 +1,2 @@
1
+ import {createHash,randomUUID,createHmac,timingSafeEqual}from'crypto';import {isIP}from'net';function ye(e){return createHash("sha256").update(e).digest("hex")}function $(e,t,n,r,o,s,u){return `${e}.${t}.${n}.${r}.${o.toUpperCase()}.${s}.${ye(u)}`}function z(e){if(!e.secret)throw new Error("signRequest: secret is required");let t=e.timestamp??Math.floor(Date.now()/1e3),n=e.nonce??randomUUID(),o=`v1=${createHmac("sha256",e.secret).update($(t,n,e.siteKey,e.keyId,e.method,e.path,e.body),"utf8").digest("hex")}`;return {headers:{"Content-Type":"application/json","X-INR-Site":e.siteKey,"X-INR-Key-Id":e.keyId,"X-INR-Timestamp":String(t),"X-INR-Nonce":n,"X-INR-Signature":o},timestamp:t,nonce:n,signature:o}}function ze(e,t,n){if(!/^v1=[0-9a-f]{64}$/.test(n))return false;let{timestamp:r,nonce:o,siteKey:s,keyId:u,method:d,path:a,body:y}=t,g=createHmac("sha256",e).update($(r,o,s,u,d,a,y),"utf8").digest("hex");return timingSafeEqual(Buffer.from(g,"hex"),Buffer.from(n.slice(3),"hex"))}var k="2.2.0-beta.1";var w=/^[A-Za-z0-9_-]{32}$/;var U=e=>typeof e=="string"&&w.test(e)?e:null,me=e=>e==="session"||e==="persistent"?e:null;function Y(e,t){if(typeof e.get=="function")return e.get(t);let n=e.headers;if(!n)return;let r=n[t]??n[t.toLowerCase()];return Array.isArray(r)?r[0]:r}function ge(e){try{let t=e.body,n=t&&typeof t=="object"?t.inrRef:void 0;if(n&&typeof n=="object"){let s=n,u=U(s.clickId),d=me(s.tokenScope);if(u&&d){let a=s.firstClickId;return {clickId:u,firstClickId:a==null?null:U(a),tokenScope:d}}}let r=U(Y(e,"X-INR-Ref")),o=me(Y(e,"X-INR-Ref-Scope"));if(r&&o){let s=Y(e,"X-INR-Ref-First");return {clickId:r,firstClickId:s==null?null:U(s),tokenScope:o}}return null}catch{return null}}function Ye(){return (e,t,n)=>{e.inrRef=ge(e);let r=e.body;if(r&&typeof r=="object"){let o=Object.getPrototypeOf(r);(o===Object.prototype||o===null)&&Object.hasOwn(r,"inrRef")&&delete r.inrRef;}n();}}var K="inrConversionOutbox",qe=1209600;function Ee(){return {eventType:{type:String,required:true},eventId:{type:String,required:true},body:{type:Object,required:true},attempts:{type:Number,default:0},nextAttemptAt:{type:Date,default:Date.now},lastStatus:{type:Number,default:null},lastError:{type:Object,default:null},unauthorizedSince:{type:Date},createdAt:{type:Date,default:Date.now}}}var xe=[[{createdAt:1},{expireAfterSeconds:1209600}],[{nextAttemptAt:1},{}],[{eventType:1,eventId:1},{unique:true}]];function j(e,t=K){let n=`InrOutbox_${t}`,r=e.models[n];if(r)return r;let o=new e.Schema(Ee(),{versionKey:false,autoIndex:false});return e.model(n,o,t)}var _e=new WeakSet,be=new WeakSet,q=new WeakMap;function H(e,t){if(_e.has(e))return Promise.resolve();let n=q.get(e);return n||(n=(async()=>{let r=false;for(let[o,s]of xe)await Promise.resolve().then(()=>e.collection.createIndex(o,s)).catch(()=>r=true);r?be.has(e)||(be.add(e),t("INR_OUTBOX_INDEX_FAILED")):_e.add(e);})().finally(()=>q.delete(e)),q.set(e,n)),n}var M=e=>"exec"in e&&typeof e.exec=="function"?e.exec():e,V=e=>({eventType:e.eventType,eventId:e.eventId});function Ve(e){let t=e.lastError;return {...e,lastError:typeof t=="string"?{status:0,code:t}:t??null,unauthorizedSince:e.unauthorizedSince??null}}function G(e){return {async enqueue(t){let{eventType:n,eventId:r,unauthorizedSince:o,...s}=t,u=o?{...s,unauthorizedSince:o}:s,d=await M(e.updateOne({eventType:n,eventId:r},{$setOnInsert:u},{upsert:true}));return !(Number(d?.matchedCount)>0)},async remove(t){await M(e.deleteOne(V(t)));},async reschedule(t,n){let r={$set:{nextAttemptAt:n.nextAttemptAt,lastStatus:n.lastStatus,lastError:n.lastError},...n.attempts>0?{$inc:{attempts:n.attempts}}:{},...n.unauthorizedAt?{$min:{unauthorizedSince:n.unauthorizedAt}}:{$unset:{unauthorizedSince:1}}};await M(e.updateOne(V(t),r));},async renew(t,n){await M(e.updateOne(V(t),{$set:{nextAttemptAt:n}}));},async claim(t,n){let r=await M(e.findOneAndUpdate({nextAttemptAt:{$lte:t}},{$set:{nextAttemptAt:n},$inc:{attempts:1}},{sort:{nextAttemptAt:1},new:true,lean:true}));return r?Ve(r):null},async purge(t){await M(e.deleteMany({$or:[{"body.clickId":t},{"body.firstClickId":t}]}));},pending:()=>M(e.countDocuments({}))}}var W=(e,t)=>e.body.clickId===t||e.body.firstClickId===t,Re=(e,t)=>e.eventType===t.eventType&&e.eventId===t.eventId;function Z(e,t){e.nextAttemptAt=t.nextAttemptAt,e.lastStatus=t.lastStatus,e.lastError=t.lastError,e.attempts+=t.attempts,e.unauthorizedSince=t.unauthorizedAt?new Date(Math.min(e.unauthorizedSince?.getTime()??1/0,t.unauthorizedAt.getTime())):null;}function J(){let e=[],t=n=>e.find(r=>Re(r,n));return {entries:e,async enqueue(n){return t(n)?false:(e.push({...n}),true)},async remove(n){let r=e.findIndex(o=>Re(o,n));r>=0&&e.splice(r,1);},async reschedule(n,r){let o=t(n);o&&Z(o,r);},async renew(n,r){let o=t(n);o&&(o.nextAttemptAt=r);},async claim(n,r){let o;for(let s of e)s.nextAttemptAt<=n&&(!o||s.nextAttemptAt<o.nextAttemptAt)&&(o=s);return o?(o.nextAttemptAt=r,o.attempts+=1,{...o}):null},async purge(n){for(let r=e.length-1;r>=0;r--)W(e[r],n)&&e.splice(r,1);},pending:async()=>e.length}}function Se(e=1e3){let t=new Map,n=r=>`${r.eventType}\0${r.eventId}`;return {add(r){if(t.has(n(r))||(t.set(n(r),r),t.size<=e))return null;let[o,s]=t.entries().next().value;return t.delete(o),s},has:r=>t.has(n(r)),get:r=>t.get(n(r)),delete:r=>{t.delete(n(r));},entries:()=>[...t.values()],get size(){return t.size}}}async function Ie(e,t){let n=0;for(let r of e.entries())await t.enqueue(r),e.delete(r),n++;return n}var Oe=["booking_created","booking_paid","contact_submitted","quote_requested","call_clicked","signup","purchase","booking_cancelled","booking_refunded","booking_no_show","chargeback","fee_charged"],Te={booking_cancelled:["booking_created"],booking_no_show:["booking_created"],booking_refunded:["booking_paid"],chargeback:["booking_paid","purchase"]},B={...Te,fee_charged:["booking_no_show","booking_cancelled"]},je=Object.freeze(Object.keys(Te)),Q={booking_created:{value:"optional",fee:"null"},booking_paid:{value:"required",fee:"null"},contact_submitted:{value:"null",fee:"null"},quote_requested:{value:"optional",fee:"null"},call_clicked:{value:"null",fee:"null"},signup:{value:"optional",fee:"null"},purchase:{value:"optional",fee:"null"},booking_cancelled:{value:"null",fee:"optional"},booking_refunded:{value:"positive",fee:"null"},booking_no_show:{value:"null",fee:"optional"},chargeback:{value:"positive",fee:"null"},fee_charged:{value:"null",fee:"positive"}},He=1e9,ee=/^(?=.{1,64}$)(?!erased:)[a-z][a-z0-9_]{0,31}:[A-Za-z0-9_-]{1,62}$/,ve=/^[A-Z]{3}$/,te=/^inrs1_[A-Za-z0-9_-]{43}$/,ne=/^[0-9a-f]{8}$/,re=/^(?=.{1,48}$)[a-z0-9]+(-[a-z0-9]+)*$/;var N={conversion:"/v1/conversions",landing:"/v1/landings",forget:"/v1/referrals/"},he=e=>Object.hasOwn(B,e);function oe(e,t){return t===null?e==="null"||e==="optional":Number.isInteger(t)&&t>=(e==="positive"?1:0)&&t<=He?e!=="null":false}var E=1e3,F=[2*E,8*E,30*E],ie=30*E,ue=3600*E,Ae=60*E,Ge=600*E,We=24*3600*E,P=150*E,Ze=3e3,Je=500,Qe=3e4,et=8192,tt=2e3,nt=24*3600*E;function ae(e){if(!e)return null;let t=e.endsWith("/")?e.slice(0,-1):e;if(!/^[a-z][a-z0-9+.-]*:\/\/[^/?#]+$/i.test(t))return null;let n;try{n=new URL(t);}catch{return null}return n.pathname!=="/"||n.username||n.password?null:n.protocol==="https:"||n.protocol==="http:"&&["localhost","127.0.0.1","[::1]"].includes(n.hostname)?n.origin:null}function ke(e,t){let n=t?.trim(),r=e??(n?Number(n):void 0),o=r===void 0||!Number.isFinite(r)?Ze:r;return Math.min(Qe,Math.max(Je,o))}function Me(e,t){return e>=200&&e<300?"success":e===409?t==="NONCE_REUSED"?"success":"terminal":e===400?t==="TIMESTAMP_SKEW"?"skew":"terminal":e===401?"unauthorized":e===0||e===408||e===425||e===429||e>=500&&e<600?"retryable":"terminal"}function rt(e,t){if(!e)return null;let n=e.trim();if(/^\d+$/.test(n))return Number(n)*E;let r=Date.parse(n);return Number.isNaN(r)?null:Math.max(0,r-t)}var se=0;function ot(e,t,n){let r=e?Date.parse(e):NaN;if(Number.isNaN(r))return null;let o=Math.round(r+500-(t+n)/2);return Math.abs(o)<=nt?o:null}async function st(e,t){let n=e.body?.getReader();if(!n)return "";let r=new TextDecoder,o="";for(let s=0;s<t;){let{done:u,value:d}=await n.read();if(u)break;o+=r.decode(d.subarray(0,t-s),{stream:true}),s+=d.byteLength;}return n.cancel().catch(()=>{}),o}function it(e){try{let t=JSON.parse(e),n=t?.error?.code??t?.code??null;return typeof n=="string"?n:null}catch{return null}}async function we(e,t,n){let r=Math.floor((Date.now()+se)/1e3),{headers:o}=z({...e,...t,timestamp:r}),s=new AbortController,u=setTimeout(()=>s.abort(),n);u.unref?.();let d=Date.now();try{let a=await fetch(e.hubUrl+t.path,{method:t.method,headers:{...o,"User-Agent":`intenque-network-sdk/${k}`},body:t.body,redirect:"error",signal:s.signal}),y=Date.now(),g=await st(a,et).catch(()=>"");return {status:a.status,code:it(g),retryAfterMs:rt(a.headers.get("retry-after"),y),date:a.headers.get("date"),tSend:d,tRecv:y}}catch{let a=Date.now();return {status:0,code:s.signal.aborted?"TIMEOUT":"NETWORK",retryAfterMs:null,date:null,tSend:d,tRecv:a}}finally{clearTimeout(u);}}function le(e){let t=Math.min(60*E*2**Math.max(0,e-4),1800*E);return Math.round(t*(.8+Math.random()*.4))}var L=e=>new Promise(t=>{setTimeout(t,e).unref?.();});function Ne(e){let{outbox:t,spill:n,logger:r}=e,o=new Set,s=i=>{o.has(i)||(o.add(i),r.warn(i));},u=i=>{let l=Me(i.status,i.code);return l==="skew"?{kind:l,status:400,code:"TIMESTAMP_SKEW",offsetMs:ot(i.date,i.tSend,i.tRecv)}:{kind:l,status:i.status,code:i.code,retryAfterMs:i.retryAfterMs}};async function d(i){let l=e.config();if(!l)return {kind:"unauthorized",status:0,code:"INR_ENV_MISSING",retryAfterMs:null};if(typeof globalThis.fetch!="function")return s("INR_NO_FETCH"),{kind:"nofetch"};let f=u(await we(l,i,e.timeoutMs));return f.kind==="skew"&&f.offsetMs!==null&&(se=f.offsetMs,f=u(await we(l,i,e.timeoutMs)),f.kind==="skew"&&f.offsetMs!==null&&(se=f.offsetMs)),f}let a=i=>d({method:"POST",path:N.conversion,body:JSON.stringify(i.body)}),y=i=>{let l;return Promise.race([Promise.resolve().then(i),new Promise(f=>{l=setTimeout(f,tt);})]).then(()=>clearTimeout(l),()=>clearTimeout(l))},g=async i=>{n.delete(i),await y(()=>t.remove(i));},I=async(i,l,f)=>(await g(i),r.warn("inr_outbox_dropped_total",{eventType:i.eventType,eventId:i.eventId,status:l.status,code:l.code,reason:f}),"dropped"),T=async(i,l)=>{let f=n.get(i);f&&Z(f,l),await y(()=>t.reschedule(i,l));};async function b(i,l,f,x=Ae){if(l.kind==="nofetch")return null;let S=Date.now(),O={status:l.status,code:l.code},A=async c=>(await T(i,c),"retried");switch(l.kind){case "success":return await g(i),"sent";case "terminal":return I(i,O,"terminal");case "unauthorized":{let c=i.unauthorizedSince?.getTime();return c!==void 0&&S-c>=We?(r.warn("INR_UNAUTHORIZED",{eventType:i.eventType,eventId:i.eventId,...O}),I(i,O,"unauthorized_24h")):A({nextAttemptAt:new Date(S+Ge),lastStatus:l.status,lastError:O,attempts:f,unauthorizedAt:new Date(S)})}case "skew":return r.warn("INR_CLOCK_SKEW",{offsetMs:l.offsetMs}),A({nextAttemptAt:new Date(S+le(i.attempts+f)),lastStatus:400,lastError:O,attempts:f,unauthorizedAt:null});case "retryable":return A({nextAttemptAt:new Date(S+x),lastStatus:l.status,lastError:O,attempts:f,unauthorizedAt:null})}}async function v(i){let l=F;for(let f=0;;f++){let x=await a(i);if(x.kind!=="retryable")return b(i,x,f+1);let S=x.retryAfterMs===null?null:Math.min(x.retryAfterMs,ue);if(f===l.length)return b(i,x,f+1,Math.max(Ae,S??0));let O=Math.max(l[f],S??0);if(O>ie)return b(i,x,f+1,S);await L(O);let A=new Date(Date.now()+P),c=n.get(i);c&&(c.nextAttemptAt=A),await y(()=>t.renew(i,A));}}return {request:d,attempt:a,settle:b,drop:I,chain:v}}var D=1e3,ut=10*D,at=60*D,De=10*D,lt=50,ct=14*86400*D-3600*D,dt=5*D;function Ce(e){let{delivery:t,outbox:n,spill:r}=e,o,s=null,u=false;async function d(){let g={sent:0,retried:0,dropped:0};try{e.onTick?.(),await Ie(r,n);for(let I=0;I<lt;I++){let T=Date.now(),b=await n.claim(new Date(T),new Date(T+P));if(!b)break;if(T-new Date(b.createdAt).getTime()>=ct){await t.drop(b,b.lastError??{status:0,code:null},"expired"),g.dropped++;continue}let v=await t.attempt(b),i="retryAfterMs"in v?v.retryAfterMs??0:0,l=await t.settle(b,v,0,Math.max(le(b.attempts),Math.min(i,ue)));if(l&&g[l]++,!l||"status"in v&&(v.status===429||v.status===503))break}}catch{e.unavailable();}return g}function a(){return s??=d().finally(()=>{s=null;}),s}function y(g){u||(o=setTimeout(()=>{a().then(()=>y(at+(Math.random()*2-1)*De));},g),o.unref?.());}return {start:()=>y(ut+Math.random()*De),drainNow:a,async close(g){u=true,clearTimeout(o),await Promise.race([Promise.allSettled([s,g()]),L(dt)]);}}}var ft=6e4,X=1024,yt=10,mt=300,gt=30*864e5,_t=512,bt=/^https?:\/\/[^/\s]{1,253}$/;function Rt(e){let t=e.body;if(typeof t=="string"||t instanceof Uint8Array){let n=Buffer.from(t);return Promise.resolve(n.length>X?null:n.toString("utf8"))}return !e.readable||typeof e.on!="function"?Promise.resolve(null):new Promise(n=>{let r=[],o=0;e.on("data",s=>{o+=s.length,o<=X&&r.push(s);}),e.on("end",()=>n(o>X?null:Buffer.concat(r).toString("utf8"))),e.on("error",()=>n(null));})}async function Et(e){let t=e.body;if(t&&typeof t=="object"&&!(t instanceof Uint8Array)&&Object.keys(t).length>0)return JSON.stringify(t).length>X?null:t;let n=await Rt(e);try{let r=n===null?null:JSON.parse(n);return r&&typeof r=="object"?r:null}catch{return null}}var Pe=(e,t)=>{let n=e.headers?.[t];return typeof n=="string"?n:null};function xt(e,t,n){let{c:r,scope:o,ph:s,d:u}=e;if(typeof r!="string"||!w.test(r)||o!=="session"&&o!=="persistent"||s!==void 0&&!(typeof s=="string"&&/^[0-9a-f]{16}$/.test(s))||u!==void 0&&!(Number.isInteger(u)&&u>=0&&u<=gt))return null;let d=Pe(n,"origin");return {v:1,clickId:r,scope:o,pathHash:s,d:u,visitorIp:t,visitorUa:(Pe(n,"user-agent")??"").slice(0,_t),visitorOrigin:d!==null&&bt.test(d)?d:null,sdk:`server/${k}`}}var Le=-1,ce=0,de=new Map;function St(e){let t=Math.floor(Date.now()/ft);t!==Le&&(Le=t,ce=0,de.clear());let n=de.get(e)??0;return ce>=mt||n>=yt?false:(de.set(e,n+1),ce++,true)}function Ue(e){let t=false;return (n={})=>async(r,o)=>{let s=null;try{let u=await Et(r),d=!!r.app?.get("trust proxy");!d&&!t&&(t=!0,e.warn("INR_RELAY_TRUST_PROXY"));let a=d?n.visitorIp?.(r)??r.ip:void 0;e.enabled&&u&&typeof a=="string"&&isIP(a)&&(s=xt(u,a,r),s&&!St(a)&&(s=null));}catch{s=null;}try{o.status(204).end();}catch{}s&&e.forward({method:"POST",path:N.landing,body:JSON.stringify(s)});}}var It=/^\d{4}-\d\d-\d\dT\d\d:\d\d(:\d\d(\.\d+)?)?(Z|[+-]\d\d:\d\d)$/,Be=2e3,Ot=6e4;function pe(e){if(!e||typeof e!="object")return {error:"event"};let t=e.eventType;if(typeof t!="string"||!Object.hasOwn(Q,t))return {error:"eventType"};if(typeof e.eventId!="string"||!ee.test(e.eventId))return {error:"eventId"};if(typeof e.clickId!="string"||!w.test(e.clickId))return {error:"clickId"};let n=e.firstClickId??null;if(n!==null&&!(typeof n=="string"&&w.test(n)))return {error:"firstClickId"};if(e.tokenScope!=="session"&&e.tokenScope!=="persistent")return {error:"tokenScope"};let r=e.occurredAt instanceof Date?e.occurredAt:typeof e.occurredAt=="string"&&It.test(e.occurredAt)?new Date(e.occurredAt):new Date(NaN),o=Number.isNaN(r.getTime())?"":r.toISOString();if(!/^\d{4}-/.test(o))return {error:"occurredAt"};let s=e.valueCents??null,u=e.feeCents??null,d=Q[t];if(!oe(d.value,s))return {error:"valueCents"};if(!oe(d.fee,u))return {error:"feeCents"};let a=null;if(e.currency!=null&&(a=typeof e.currency=="string"?e.currency.toUpperCase():"",!ve.test(a)))return {error:"currency"};if(a===null&&(s!==null||u!==null))return {error:"currency"};let y=e.reverses??null;if(he(t)){let g=B[t];if(y===null||typeof y!="object"||!g.includes(y.eventType)||typeof y.eventId!="string"||!ee.test(y.eventId))return {error:"reverses"}}else if(y!==null)return {error:"reverses"};return {v:1,eventType:t,eventId:e.eventId,clickId:e.clickId,firstClickId:n===e.clickId?null:n,tokenScope:e.tokenScope,occurredAt:o,valueCents:s,feeCents:u,currency:a,reverses:y?{eventType:y.eventType,eventId:y.eventId}:null,sdk:`server/${k}`}}var Tt=["INR_HUB_URL","INR_SITE_KEY","INR_KEY_ID","INR_SITE_SECRET"],Ke={INR_HUB_URL:e=>ae(e)!==null,INR_SITE_KEY:e=>re.test(e),INR_KEY_ID:e=>ne.test(e),INR_SITE_SECRET:e=>te.test(e)};function vt(e=process.env,t={}){let n=Tt.flatMap(s=>{let u=e[s];return u?Ke[s]&&!Ke[s](u)?[`${s} (invalid)`]:[]:[s]});if(n.length===0)return [];let r=t.production??e.NODE_ENV==="production",o=`@intenque/network-sdk/server: missing or invalid env ${n.join(", ")}`;if(!r||e.INR_STRICT_ENV==="true")throw new Error(o);return (t.logger??console).warn(o),n}function ht(e={}){let t=e.logger??{warn:(c,p)=>console.warn(c,p??"")},n={warn:(c,p)=>{try{t.warn(c,p);}catch{}}},r=e.enabled!==false,o=()=>typeof process>"u"?{}:process.env,s=e.outbox?.mongoose?j(e.outbox.mongoose,e.outbox.collection??(o().INR_OUTBOX_COLLECTION||K)):null,u=()=>{s&&r&&H(s,n.warn);};u();let d=s?G(s):J(),a=Se(),y=false,I=Ne({config:()=>{let c=o(),p=e.hubUrl??c.INR_HUB_URL,R=ae(p);p&&!R&&!y&&(y=true,n.warn("INR_HUB_URL_INVALID"));let m=e.siteKey??c.INR_SITE_KEY??"",_=c.INR_KEY_ID||e.keyId||"",h=c.INR_SITE_SECRET||e.secret||"";return !R||!re.test(m)||!ne.test(_)||!te.test(h)?null:{hubUrl:R,siteKey:m,keyId:_,secret:h}},timeoutMs:ke(e.timeoutMs,o().INR_TIMEOUT_MS),outbox:d,spill:a,logger:n}),T=new Set,b=c=>{let p=c.catch(R=>{n.warn("inr.report attempt failed",{error:R instanceof Error?R.message:String(R)});}).finally(()=>T.delete(p));T.add(p);},v=c=>b(I.chain(c)),i=async()=>{for(;T.size>0;)await Promise.allSettled([...T]);},l=-1/0,f=()=>{Date.now()-l<Ot||(l=Date.now(),n.warn("INR_OUTBOX_UNAVAILABLE"));},x=Ce({delivery:I,outbox:d,spill:a,unavailable:f,onTick:u});r&&x.start();let S=async c=>{let p,R=new Promise(m=>{p=setTimeout(()=>m(void 0),Be);});try{return await Promise.race([Promise.resolve().then(c).catch(()=>{}),R])}finally{clearTimeout(p);}};async function O(c){if(typeof c!="string"||!w.test(c))return false;let p=(_,h)=>(n.warn("INR_FORGET_FAILED",{status:_,code:h}),false);for(let _ of a.entries())W(_,c)&&a.delete(_);if(!await S(()=>d.purge(c).then(()=>true)))return p(0,"INR_OUTBOX_UNAVAILABLE");let R={method:"DELETE",path:N.forget+c,body:""},m;for(let _=0;;_++){if(m=await I.request(R),"status"in m&&m.status>=200&&m.status<300)return true;if(m.kind!=="retryable"||_===F.length)break;let h=m.retryAfterMs??0;if(h>ie)break;await L(Math.max(F[_],h));}return m.kind==="nofetch"?p(0,"INR_NO_FETCH"):p(m.status,m.code)}let A=async c=>{let p=await S(()=>d.enqueue(c));return p===void 0?"failed":p?"inserted":"exists"};return {outbox:d,spill:a,bodyFor:pe,async report(c){try{let p=pe(c);if("error"in p)return n.warn("inr.report dropped: invalid event",{error:p.error}),{status:"invalid",error:p.error};if(!r)return {status:"disabled",body:p};let R=Date.now(),m={eventType:p.eventType,eventId:p.eventId,body:p,attempts:0,nextAttemptAt:new Date(R+P),lastStatus:null,lastError:null,unauthorizedSince:null,createdAt:new Date(R)},_=await A(m),h=_==="failed"?"spilled":"queued";if(_==="failed"&&f(),_==="exists"||a.has(m))return {status:h,body:p};if(_==="failed"){let C=a.add(m);C&&n.warn("inr_outbox_dropped_total",{eventType:C.eventType,eventId:C.eventId,status:C.lastError?.status??null,code:C.lastError?.code??null,reason:"memory_full"});}return v(m),{status:h,body:p}}catch(p){return n.warn("inr.report failed",{error:p instanceof Error?p.message:String(p)}),{status:"invalid",error:"internal"}}},idle:i,forget:O,drainNow:x.drainNow,close:()=>x.close(i),landingRelay:Ue({enabled:r,forward:c=>b(I.request(c)),warn:n.warn})}}export{Oe as CONVERSION_TYPES,Be as ENQUEUE_TIMEOUT_MS,B as LINK_TARGETS,K as OUTBOX_DEFAULT_COLLECTION,xe as OUTBOX_INDEXES,qe as OUTBOX_TTL_SECONDS,je as REVERSAL_TYPES,k as SDK_VERSION,pe as buildConversionBody,Me as classifyResponse,ht as createConversionReporter,J as createMemoryOutbox,G as createOutbox,H as ensureOutboxIndexes,$ as hmacBase,j as outboxModel,Ee as outboxSchemaDefinition,ge as referralFromRequest,Ye as referralMiddleware,ye as sha256Hex,z as signRequest,vt as verifyEnv,ze as verifySignature};//# sourceMappingURL=index.js.map
2
+ //# sourceMappingURL=index.js.map