@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.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 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 };
@@ -0,0 +1,2 @@
1
+ import {createHash,randomUUID,createHmac,timingSafeEqual}from'crypto';import {isIP}from'net';function me(e){return createHash("sha256").update(e).digest("hex")}function $(e,t,n,r,o,s,u){return `${e}.${t}.${n}.${r}.${o.toUpperCase()}.${s}.${me(u)}`}function Y(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 Ye(e,t,n){if(!/^v1=[0-9a-f]{64}$/.test(n))return false;let{timestamp:r,nonce:o,siteKey:s,keyId:u,method:c,path:l,body:y}=t,m=createHmac("sha256",e).update($(r,o,s,u,c,l,y),"utf8").digest("hex");return timingSafeEqual(Buffer.from(m,"hex"),Buffer.from(n.slice(3),"hex"))}var k="2.2.0-beta.2";var A=/^[A-Za-z0-9_-]{32}$/;var K=e=>typeof e=="string"&&A.test(e)?e:null,ge=e=>e==="session"||e==="persistent"?e:null;function z(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 _e(e){try{let t=e.body,n=t&&typeof t=="object"?t.inrRef:void 0;if(n&&typeof n=="object"){let s=n,u=K(s.clickId),c=ge(s.tokenScope);if(u&&c){let l=s.firstClickId;return {clickId:u,firstClickId:l==null?null:K(l),tokenScope:c}}}let r=K(z(e,"X-INR-Ref")),o=ge(z(e,"X-INR-Ref-Scope"));if(r&&o){let s=z(e,"X-INR-Ref-First");return {clickId:r,firstClickId:s==null?null:K(s),tokenScope:o}}return null}catch{return null}}function ze(){return (e,t,n)=>{e.inrRef=_e(e);try{Reflect.deleteProperty(e.body,"inrRef");}catch{}n();}}var B="inrConversionOutbox",qe=1209600;function xe(){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 Se=[[{createdAt:1},{expireAfterSeconds:1209600}],[{nextAttemptAt:1},{}],[{eventType:1,eventId:1},{unique:true}]];function H(e,t=B){let n=`InrOutbox_${t}`,r=e.models[n];if(r)return r;let o=new e.Schema(xe(),{versionKey:false,autoIndex:false});return e.model(n,o,t)}var be=new WeakSet,Re=new WeakSet,q=new WeakMap;function j(e,t){if(be.has(e))return Promise.resolve();let n=q.get(e);return n||(n=(async()=>{let r=false;for(let[o,s]of Se)await Promise.resolve().then(()=>e.collection.createIndex(o,s)).catch(()=>r=true);r?Re.has(e)||(Re.add(e),t("INR_OUTBOX_INDEX_FAILED")):be.add(e);})().finally(()=>q.delete(e)),q.set(e,n)),n}var w=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,c=await w(e.updateOne({eventType:n,eventId:r},{$setOnInsert:u},{upsert:true}));return !(Number(c?.matchedCount)>0)},async remove(t){await w(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 w(e.updateOne(V(t),r));},async renew(t,n){return (await w(e.updateOne(V(t),{$set:{nextAttemptAt:n}})))?.matchedCount!==0},async claim(t,n){let r=await w(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 w(e.deleteMany({$or:[{"body.clickId":t},{"body.firstClickId":t}]}));},pending:()=>w(e.countDocuments({}))}}var W=(e,t)=>e.body.clickId===t||e.body.firstClickId===t,Ee=(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=>Ee(r,n));return {entries:e,async enqueue(n){return t(n)?false:(e.push({...n}),true)},async remove(n){let r=e.findIndex(o=>Ee(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);return o&&(o.nextAttemptAt=r),!!o},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 Ie(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 Oe(e,t){let n=0;for(let r of e.entries())if(e.get(r)===r){let o=await t.enqueue(r);e.get(r)===r?e.delete(r):o&&await t.remove(r),n++;}return n}var Te=["booking_created","booking_paid","contact_submitted","quote_requested","call_clicked","signup","purchase","booking_cancelled","booking_refunded","booking_no_show","chargeback","fee_charged"],ve={booking_cancelled:["booking_created"],booking_no_show:["booking_created"],booking_refunded:["booking_paid"],chargeback:["booking_paid","purchase"]},F={...ve,fee_charged:["booking_no_show","booking_cancelled"]},He=Object.freeze(Object.keys(ve)),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"}},je=1e9,ee=/^(?=.{1,64}$)(?!erased:)[a-z][a-z0-9_]{0,31}:[A-Za-z0-9_-]{1,62}$/,he=/^[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 M={conversion:"/v1/conversions",landing:"/v1/landings",forget:"/v1/referrals/"},Ae=e=>Object.hasOwn(F,e);function oe(e,t){return t===null?e==="null"||e==="optional":Number.isInteger(t)&&t>=(e==="positive"?1:0)&&t<=je?e!=="null":false}var R=1e3,P=[2*R,8*R,30*R],ie=30*R,ue=3600*R,ke=60*R,Ge=600*R,We=24*3600*R,L=150*R,Ze=3e3,Je=500,Qe=3e4,et=8192,ae=2e3,tt=24*3600*R;function le(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 Me(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 Ne(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":e<405&&e!==402?"misrouted":"terminal"}function nt(e,t){if(!e)return null;let n=e.trim();if(/^\d+$/.test(n))return Number(n)*R;let r=Date.parse(n);return Number.isNaN(r)?null:Math.max(0,r-t)}var se=0;function rt(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)<=tt?o:null}async function ot(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:c}=await n.read();if(u)break;o+=r.decode(c.subarray(0,t-s),{stream:true}),s+=c.byteLength;}return n.cancel().catch(()=>{}),o}function st(e){try{let t=JSON.parse(e),n=t?.error?.code??t?.code;return typeof n=="string"&&/^[A-Z][A-Z0-9_]{0,63}$/.test(n)?n:null}catch{return null}}async function we(e,t,n){let r=Math.floor((Date.now()+se)/1e3),{headers:o}=Y({...e,...t,timestamp:r}),s=new AbortController,u=setTimeout(()=>s.abort(),n);u.unref?.();let c=Date.now();try{let l=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(),m=await ot(l,et).catch(()=>"");return {status:l.status,code:st(m),retryAfterMs:nt(l.headers.get("retry-after"),y),date:l.headers.get("date"),tSend:c,tRecv:y}}catch{return {status:0,code:s.signal.aborted?"TIMEOUT":"NETWORK",retryAfterMs:null,date:null,tSend:c,tRecv:Date.now()}}finally{clearTimeout(u);}}function ce(e){let t=Math.min(60*R*2**Math.max(0,e-4),1800*R);return Math.round(t*(.8+Math.random()*.4))}var U=e=>new Promise(t=>{setTimeout(t,e).unref?.();});async function N(e){let t;try{return await Promise.race([Promise.resolve().then(e).catch(()=>{}),new Promise(n=>{t=setTimeout(()=>n(void 0),ae);})])}finally{clearTimeout(t);}}function De(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 a=Ne(i.status,i.code);return a==="skew"?{kind:a,status:400,code:"TIMESTAMP_SKEW",offsetMs:rt(i.date,i.tSend,i.tRecv)}:{kind:a,status:i.status,code:i.code,retryAfterMs:i.retryAfterMs}};async function c(i){let a=e.config();if(!a)return {kind:"unauthorized",status:0,code:"INR_ENV_MISSING",retryAfterMs:null};if(typeof globalThis.fetch!="function")return s("INR_NO_FETCH"),{kind:"nofetch"};let p=u(await we(a,i,e.timeoutMs));return p.kind==="skew"&&p.offsetMs!==null&&(se=p.offsetMs,p=u(await we(a,i,e.timeoutMs)),p.kind==="skew"&&p.offsetMs!==null&&(se=p.offsetMs)),p}let l=i=>c({method:"POST",path:M.conversion,body:JSON.stringify(i.body)}),y=async i=>{n.delete(i),await N(()=>t.remove(i));},m=async(i,a,p)=>(await y(i),r.warn("inr_outbox_dropped_total",{eventType:i.eventType,eventId:i.eventId,status:a.status,code:a.code,reason:p}),"dropped");async function E(i,a,p,x=ke){if(a.kind==="nofetch")return null;let T=Date.now(),S={status:a.status,code:a.code},v=async(h,f=null)=>{let d={nextAttemptAt:new Date(T+h),lastStatus:a.status,lastError:S,attempts:p,unauthorizedAt:f},b=n.get(i);return b&&Z(b,d),await N(()=>t.reschedule(i,d)),"retried"};switch(a.kind){case "success":return await y(i),"sent";case "terminal":return m(i,S,"terminal");case "unauthorized":case "misrouted":{let h=i.unauthorizedSince?.getTime();return h!==void 0&&T-h>=We?(a.kind==="unauthorized"&&r.warn("INR_UNAUTHORIZED",{eventType:i.eventType,eventId:i.eventId,...S}),m(i,S,`${a.kind}_24h`)):v(Ge,new Date(T))}case "skew":return r.warn("INR_CLOCK_SKEW",{offsetMs:a.offsetMs}),v(ce(i.attempts+p));case "retryable":return v(x)}}async function I(i){for(let a=0;;a++){let p=await l(i);if(p.kind!=="retryable"&&p.kind!=="misrouted")return E(i,p,a+1);let x=p.retryAfterMs===null?null:Math.min(p.retryAfterMs,ue);if(a===P.length)return E(i,p,a+1,Math.max(ke,x??0));let T=Math.max(P[a],x??0);if(T>ie)return E(i,p,a+1,x);await U(T);let S=new Date(Date.now()+L),v=n.get(i);v&&(v.nextAttemptAt=S);let h;if(await N(async()=>h=await t.renew(i,S)),h===false&&!v)return null}}return {request:c,attempt:l,settle:E,drop:m,chain:I}}var D=1e3,it=10*D,ut=60*D,Ce=10*D,at=50,lt=14*86400*D-3600*D,ct=5*D;function Pe(e){let{delivery:t,outbox:n,spill:r}=e,o,s=null,u=false;async function c(){let m={sent:0,retried:0,dropped:0};try{e.onTick?.(),await Oe(r,n);for(let E=0;E<at;E++){let I=Date.now(),i=await n.claim(new Date(I),new Date(I+L));if(!i)break;if(I-new Date(i.createdAt).getTime()>=lt){await t.drop(i,i.lastError??{status:0,code:null},"expired"),m.dropped++;continue}let a=await t.attempt(i),p="retryAfterMs"in a?a.retryAfterMs??0:0,x=await t.settle(i,a,0,Math.max(ce(i.attempts),Math.min(p,ue)));if(x&&m[x]++,!x||"status"in a&&(a.status===429||a.status===503))break}}catch{e.unavailable();}return m}function l(){return s??=c().finally(()=>{s=null;}),s}function y(m){u||(o=setTimeout(()=>{l().then(()=>y(ut+(Math.random()*2-1)*Ce));},m),o.unref?.());}return {start:()=>y(it+Math.random()*Ce),drainNow:l,async close(m){u=true,clearTimeout(o),await Promise.race([Promise.allSettled([s,m()]),U(ct)]);}}}var pt=6e4,X=1024,ft=10,yt=300,mt=30*864e5,gt=512,_t=/^https?:\/\/[^/\s]{1,253}$/;function bt(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 Rt(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 bt(e);try{let r=n===null?null:JSON.parse(n);return r&&typeof r=="object"?r:null}catch{return null}}var Le=(e,t)=>{let n=e.headers?.[t];return typeof n=="string"?n:null};function Et(e,t,n){let{c:r,scope:o,ph:s,d:u}=e;if(typeof r!="string"||!A.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<=mt))return null;let c=Le(n,"origin");return {v:1,clickId:r,scope:o,pathHash:s,d:u,visitorIp:t,visitorUa:(Le(n,"user-agent")??"").slice(0,gt),visitorOrigin:c!==null&&_t.test(c)?c:null,sdk:`server/${k}`}}var Ue=-1,de=0,pe=new Map;function xt(e){let t=Math.floor(Date.now()/pt);t!==Ue&&(Ue=t,de=0,pe.clear());let n=pe.get(e)??0;return de>=yt||n>=ft?false:(pe.set(e,n+1),de++,true)}function Ke(e){let t=false;return (n={})=>async(r,o)=>{let s=null;try{let u=await Rt(r),c=!!r.app?.get("trust proxy");!c&&!t&&(t=!0,e.warn("INR_RELAY_TRUST_PROXY"));let l=c?n.visitorIp?.(r)??r.ip:void 0;e.enabled&&u&&typeof l=="string"&&isIP(l)&&(s=Et(u,l,r),s&&!xt(l)&&(s=null));}catch{s=null;}try{o.status(204).end();}catch{}s&&e.forward({method:"POST",path:M.landing,body:JSON.stringify(s)});}}var St=/^\d{4}-\d\d-\d\dT\d\d:\d\d(:\d\d(\.\d+)?)?(Z|[+-]\d\d:\d\d)$/,It=ae,Ot=6e4;function fe(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"||!A.test(e.clickId))return {error:"clickId"};let n=e.firstClickId??null;if(n!==null&&!(typeof n=="string"&&A.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"&&St.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,c=Q[t];if(!oe(c.value,s))return {error:"valueCents"};if(!oe(c.fee,u))return {error:"feeCents"};let l=null;if(e.currency!=null&&(l=typeof e.currency=="string"?e.currency.toUpperCase():"",!he.test(l)))return {error:"currency"};if(l===null&&(s!==null||u!==null))return {error:"currency"};let y=e.reverses??null;if(Ae(t)){let m=F[t];if(y===null||typeof y!="object"||!m.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:l,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"],Be={INR_HUB_URL:e=>le(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?Be[s]&&!Be[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:(f,d)=>console.warn(f,d??"")},n={warn:(f,d)=>{try{t.warn(f,d);}catch{}}},r=e.enabled!==false,o=()=>typeof process>"u"?{}:process.env,s=e.outbox?.mongoose?H(e.outbox.mongoose,e.outbox.collection??(o().INR_OUTBOX_COLLECTION||B)):null,u=()=>{s&&r&&j(s,n.warn);};u();let c=s?G(s):J(),l=Ie(),y=false,E=De({config:()=>{let f=o(),d=e.hubUrl??f.INR_HUB_URL,b=le(d);d&&!b&&!y&&(y=true,n.warn("INR_HUB_URL_INVALID"));let g=e.siteKey??f.INR_SITE_KEY??"",_=f.INR_KEY_ID||e.keyId||"",O=f.INR_SITE_SECRET||e.secret||"";return !b||!re.test(g)||!ne.test(_)||!te.test(O)?null:{hubUrl:b,siteKey:g,keyId:_,secret:O}},timeoutMs:Me(e.timeoutMs,o().INR_TIMEOUT_MS),outbox:c,spill:l,logger:n}),I=new Set,i=f=>{let d=f.catch(b=>{n.warn("inr.report attempt failed",{error:b instanceof Error?b.message:String(b)});}).finally(()=>I.delete(d));I.add(d);},a=f=>i(E.chain(f)),p=async()=>{for(;I.size>0;)await Promise.allSettled([...I]);},x=-1/0,T=()=>{Date.now()-x<Ot||(x=Date.now(),n.warn("INR_OUTBOX_UNAVAILABLE"));},S=Pe({delivery:E,outbox:c,spill:l,unavailable:T,onTick:u});r&&S.start();async function v(f){if(typeof f!="string"||!A.test(f))return false;let d=(_,O)=>(n.warn("INR_FORGET_FAILED",{status:_,code:O}),false);for(let _ of l.entries())W(_,f)&&l.delete(_);if(!await N(()=>c.purge(f).then(()=>true)))return d(0,"INR_OUTBOX_UNAVAILABLE");let b={method:"DELETE",path:M.forget+f,body:""},g;for(let _=0;;_++){if(g=await E.request(b),"status"in g&&g.status>=200&&g.status<300)return true;if(g.kind!=="retryable"||_===P.length)break;let O=g.retryAfterMs??0;if(O>ie)break;await U(Math.max(P[_],O));}return g.kind==="nofetch"?d(0,"INR_NO_FETCH"):d(g.status,g.code)}let h=async f=>{let d=await N(()=>c.enqueue(f));return d===void 0?"failed":d?"inserted":"exists"};return {outbox:c,spill:l,bodyFor:fe,async report(f){try{let d=fe(f);if("error"in d)return n.warn("inr.report dropped: invalid event",{error:d.error}),{status:"invalid",error:d.error};if(!r)return {status:"disabled",body:d};let b=Date.now(),g={eventType:d.eventType,eventId:d.eventId,body:d,attempts:0,nextAttemptAt:new Date(b+L),lastStatus:null,lastError:null,unauthorizedSince:null,createdAt:new Date(b)},_=await h(g),O=_==="failed"?"spilled":"queued";if(_==="failed"&&T(),_==="exists"||l.has(g))return {status:O,body:d};if(_==="failed"){let C=l.add(g);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 a(g),{status:O,body:d}}catch(d){return n.warn("inr.report failed",{error:d instanceof Error?d.message:String(d)}),{status:"invalid",error:"internal"}}},idle:p,forget:v,drainNow:S.drainNow,close:()=>S.close(p),landingRelay:Ke({enabled:r,forward:f=>i(E.request(f)),warn:n.warn})}}export{Te as CONVERSION_TYPES,It as ENQUEUE_TIMEOUT_MS,F as LINK_TARGETS,B as OUTBOX_DEFAULT_COLLECTION,Se as OUTBOX_INDEXES,qe as OUTBOX_TTL_SECONDS,He as REVERSAL_TYPES,k as SDK_VERSION,fe as buildConversionBody,Ne as classifyResponse,ht as createConversionReporter,J as createMemoryOutbox,G as createOutbox,j as ensureOutboxIndexes,$ as hmacBase,H as outboxModel,xe as outboxSchemaDefinition,_e as referralFromRequest,ze as referralMiddleware,me as sha256Hex,Y as signRequest,vt as verifyEnv,Ye as verifySignature};//# sourceMappingURL=index.js.map
2
+ //# sourceMappingURL=index.js.map