@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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,96 @@
1
+ # Changelog
2
+
3
+ `@intenque/network-sdk`. The SDK major is the manifest schema major (spec §6.1). Design decisions are in the hub
4
+ repository: `docs/Network_CrossPromo_Plan.md` and `docs/spec-addendum-phase0-auth.md` ("# Phase 3: conversions").
5
+
6
+ ## 2.2.0-beta.1 (in progress)
7
+
8
+ ### Server entry (`@intenque/network-sdk/server`)
9
+
10
+ - **Signing binds the identifiers.** The v1 HMAC base is `ts.nonce.siteKey.keyId.METHOD.path.sha256(body)`;
11
+ `hmacBase`, `signRequest` and `verifySignature` take `siteKey` and `keyId`. The key is the UTF-8 bytes of the
12
+ `INR_SITE_SECRET` string. The hub's committed vectors pass byte for byte. (2.0 and 2.1 never sent a request, so no
13
+ signature in the old form exists.)
14
+ - **Conversions are sent.** `report()` used to queue only. It now sends with global `fetch` (`redirect: 'error'`,
15
+ `User-Agent: intenque-network-sdk/<version>`, per-attempt timeout `INR_TIMEOUT_MS`, default 3000 ms, clamped to
16
+ 500–30 000). Answers: any 2xx and `409 NONCE_REUSED` count as success; 408, 425, 429, 5xx and network errors are
17
+ retried after 2, 8 and 30 s, honouring `Retry-After` (above 30 s the row waits in the outbox, capped at 1 h);
18
+ `400 TIMESTAMP_SKEW` re-signs once with a process-wide clock offset from the hub's `Date`, else `INR_CLOCK_SKEW`;
19
+ 401 and missing or invalid config are retried every 10 min for 24 h (`unauthorizedSince`), then
20
+ `INR_UNAUTHORIZED`; other 4xx (including `CURRENCY_NOT_ALLOWED`) are dropped with an `inr_outbox_dropped_total`
21
+ warn line. `classifyResponse(status, code)` is exported.
22
+ - **`report()` is bounded.** It resolves after the outbox write or 2 s, whichever comes first, and never rejects or
23
+ waits for the network. A new result `spilled`: the outbox write failed or timed out, so the report is held in
24
+ memory (≤ 1000, oldest dropped as `memory_full`; `INR_OUTBOX_UNAVAILABLE` at most once a minute) and still sent.
25
+ New rows carry a 150 s lease in `nextAttemptAt`. Outbox rows gain `lastStatus`, `lastError { status, code }` and
26
+ `unauthorizedSince`. `idle()` resolves once the running send chains have settled.
27
+ - **The body rules match the hub.** `CONVERSION_TYPES` has 12 entries (new `fee_charged`: `feeCents ≥ 1`,
28
+ `valueCents: null`, a currency, `reverses` → the `booking_no_show` / `booking_cancelled` it belongs to).
29
+ `eventId` and `reverses.eventId` follow the hub's pattern: a lower-case prefix, `:`, then letters, digits, `_` or
30
+ `-`, at most 64 characters in all (the 200-character cap is gone), never `erased:`. Each type has the hub's
31
+ amount rules. Any amount needs a currency. `reverses` is required on the linked types and must name one of
32
+ `LINK_TARGETS[type]`; on every other type it must be null. A failing event resolves
33
+ `{ status: 'invalid', error: '<field>' }` and is never sent. `occurredAt` is a valid `Date` or an ISO datetime
34
+ string with `Z` or an offset (sent as ISO UTC); other strings (`'1'`, a space for the `T`, no zone) are `invalid`,
35
+ no longer parsed leniently in local time. `LINK_TARGETS` and `REVERSAL_TYPES` are exported.
36
+ - **`verifyEnv()`** also reports malformed values (`INR_HUB_URL (invalid)`: not an origin; `INR_SITE_KEY (invalid)`:
37
+ not a site slug; `INR_KEY_ID (invalid)`; `INR_SITE_SECRET (invalid)`), never their content, and throws in
38
+ production when `INR_STRICT_ENV=true`.
39
+ - A throwing site `logger` is ignored (it never rejects `report()` or a send chain). A blank `INR_TIMEOUT_MS` means
40
+ the 3000 ms default.
41
+ - **Outbox v2.** The schema declares no index and sets `autoIndex: false`; the reporter creates the three helper
42
+ indexes itself (`createdAt` TTL 1 209 600 s, `nextAttemptAt`, unique `(eventType, eventId)`, default names), so
43
+ they exist on sites that turn `autoIndex` off. Each index is tried on its own and a failed one is retried at the
44
+ next drain tick; failures are one `INR_OUTBOX_INDEX_FAILED` warn. The collection is `outbox.collection`, else
45
+ `INR_OUTBOX_COLLECTION`, else `inrConversionOutbox`. A 2.0 / 2.1 row's string `lastError` is read as
46
+ `{ status: 0, code }`. Mongoose 7.8.6 and 8 are both tested.
47
+ - **Drainer.** First tick 10–20 s after start, then every 60 s ± 10 s (unref'd, never overlapping): the spill moves
48
+ into Mongo, then up to 50 due rows are claimed (`findOneAndUpdate`: lease `nextAttemptAt = now + 150 s`,
49
+ `attempts + 1`) and sent one at a time; a 429 or 503 ends the tick. A claimed row is owned by one sender on any
50
+ number of instances; an in-process send chain renews its lease before each retry. Backoff
51
+ `min(60 s × 2^max(0, attempts − 4), 30 min) × U(0.8, 1.2)`, raised to `Retry-After` (≤ 1 h), no attempt cap. Rows
52
+ older than 14 d − 1 h are dropped as `expired`. Every drained attempt re-reads `INR_KEY_ID` / `INR_SITE_SECRET`.
53
+ New `drainNow()` → `{ sent, retried, dropped }` and `close()` (stops the timer, waits ≤ 5 s).
54
+ - **`forget(clickId)`** is implemented: invalid id → `false`; otherwise queued outbox and spill rows naming the id
55
+ (`clickId` or `firstClickId`) are deleted, then the signed `DELETE /v1/referrals/:clickId` (empty body) is sent,
56
+ with up to 4 attempts for retryable answers (Retry-After ≤ 30 s honoured) and one skew re-sign. `true` on 2xx
57
+ only; 401, other 4xx (a 409 `NONCE_REUSED` included), exhausted retries, missing credentials or an outbox that
58
+ cannot be purged → `false` and `INR_FORGET_FAILED { status, code }`. Never queued; ignores `enabled`.
59
+ - **`landingRelay(opts?)`** is implemented: `{ c, scope, ph, d }` (JSON or text/plain, ≤ 1 KB, parsed by the site or
60
+ read from the request) → one signed `POST /v1/landings` with the 2.2 relay body (`visitorIp`, `visitorUa`,
61
+ `visitorOrigin`; `visitorIp` is `opts.visitorIp?.(req) ?? req.ip`). Without Express `trust proxy` nothing is
62
+ forwarded (with or without `opts.visitorIp`) and `INR_RELAY_TRUST_PROXY` is logged once. Limits: 10 / min per
63
+ visitor IP, 300 / min per process, shared by every handler. The browser always gets an empty 204 (it was 202).
64
+ - **`referralMiddleware()`** removes `inrRef` from a plain-object `req.body` after setting `req.inrRef`.
65
+ `referralFromRequest` is unchanged (pure).
66
+ - **Capture: `attachReferral(api, { routes })`** (and `attachReferralHeader`): with `routes: (string | RegExp)[]`, only
67
+ mutating requests whose path (a relative `url` appended to `baseURL`, as axios does) equals a string or matches a
68
+ RegExp (`lastIndex` ignored) carry the referral. Capture measured 1 499 B gzip with it (budget 1 500 B unchanged).
69
+ - **CLI: `doctor --backend [--mongo-uri <uri>]`** for split repos: root `--root` or the cwd, package.json optional
70
+ (`file:` / `link:` SDK dependency warns), no `src/network.config.js` needed, frontend checks skipped. Fails on Node
71
+ < 18, a missing `createConversionReporter`, missing `referralFromRequest` and `referralMiddleware`, or any missing
72
+ `INR_*` value (process env, else `<root>/.env`; printed as set/unset only). Signs `POST /v1/conversions/ping` `{}`
73
+ (5 s): 204 ok; 401, `TIMESTAMP_SKEW` (with the offset), 404 and network errors fail; 429 / 503 warn. With
74
+ `--mongo-uri` it checks the three outbox indexes through the site's own `mongodb` (`createRequire(root)`), else
75
+ prints `skip`. No env value, URI or host name is printed. Plain `doctor` also signs the ping when the backend env
76
+ is found.
77
+ - The `server` size budget is 8 100 B gzip: 5.98 KB before this step, 8 051 B measured with the drainer, `forget()`
78
+ and `landingRelay()`, rounded up to 50 B (addendum "Size budgets" row).
79
+
80
+ ### Packaging (WP18)
81
+
82
+ - Published by `.github/workflows/sdk-publish.yml` on a `network-sdk-v<version>` tag (the tag must equal
83
+ `package.json`'s version or the run fails), GitHub environment `npm-publish`, npm trusted publishing (OIDC, no
84
+ token); a pre-release goes to the dist-tag `beta`, a release to `latest`. `publishConfig.provenance` is `false`
85
+ (the source repository is private; addendum deviation row). `repository` points at
86
+ `IntenqueSecurity/intenque-network-hub`, directory `packages/network-sdk`. The tarball is `dist`, `README.md`,
87
+ `CHANGELOG.md` and `package.json`. Owner steps: `docs/runbooks/sdk-publish.md`.
88
+
89
+ ## 2.1.0-beta.1
90
+
91
+ Impression, click and landing signals (spec §5.1); `doctor` sends a test impression.
92
+
93
+ ## 2.0.0-beta.1
94
+
95
+ Manifest schema 2: the "Discover Other Businesses" section, the signed-manifest verifier with the committed
96
+ fallback, referral capture, the CLI (`init`, `sync-fallback`, `doctor`, `print-config`) and the server API surface.
package/README.md CHANGED
@@ -1,3 +1,255 @@
1
- # Temporary Holding Version
1
+ # @intenque/network-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Adds the **"Discover Other Businesses"** section, referral capture and conversion reporting to an Intenque
4
+ network site. Spec: `docs/Network_CrossPromo_Plan.md` §6 in the hub repository; the timed per-site checklist is
5
+ `docs/install-guide.md`.
6
+
7
+ Version `2.2.0-beta.1` reads manifest **schema 2** (SDK major = manifest schema major). Zero runtime dependencies;
8
+ `react` / `react-dom` ^18 || ^19 are optional peers (only the `react` entry needs them).
9
+
10
+ | Entry | Exports |
11
+ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
12
+ | `@intenque/network-sdk/react` | `NetworkProvider`, `DiscoverBusinesses`, `useNetworkDirectory`, `useNetworkLink`, `defineNetworkConfig`, `DEFAULT_CDN_BASE`, `rootPublicKeys`, verifier |
13
+ | `@intenque/network-sdk/capture` | `captureReferral`, `getReferral`, `clearReferral`, `setConsent`, `attachReferral`, `attachReferralHeader` (≤ 1.5 KB gzip, no imports) |
14
+ | `@intenque/network-sdk/server` | `createConversionReporter`, `referralFromRequest`, `referralMiddleware`, `signRequest`, `verifyEnv`, `CONVERSION_TYPES` (Node ≥ 18) |
15
+ | `@intenque/network-sdk/styles.css` | optional default styles on `--inr-*` variables for sites without Tailwind |
16
+ | `npx network-sdk` | `init`, `sync-fallback`, `doctor`, `print-config` (Node ≥ 18; WebCrypto is polyfilled from `node:crypto` on 18, else the CLI exits 1 with a clear message) |
17
+
18
+ ## Install (frontend, ≤ 30 minutes)
19
+
20
+ ```sh
21
+ cd frontend
22
+ npm i @intenque/network-sdk
23
+ npx network-sdk init --site-key <site-key> # writes src/network.config.js + src/data/network.fallback.json,
24
+ # adds "network:sync" / "prebuild" and inlines sync-fallback into "build"
25
+ ```
26
+
27
+ `init` fails with `site <key> has no published manifest; Publish in the admin first` when the hub has not published
28
+ the site yet (step 1 of the checklist). While no production root key ships in the SDK, pass the hub's root and CDN
29
+ explicitly: `--url <cdn-base> --root-keys '<MANIFEST_ROOT_PUBLIC_KEYS json>'` (development only; the flags are
30
+ inlined into the generated scripts and removed by re-running `init` once production is live).
31
+
32
+ Then paste what `init` prints:
33
+
34
+ ```jsx
35
+ // main.jsx — before ReactDOM.createRoot, before any tracker or GA snippet
36
+ import { captureReferral } from '@intenque/network-sdk/capture'
37
+ import { NetworkProvider } from '@intenque/network-sdk/react'
38
+ import networkConfig from './network.config'
39
+ captureReferral(networkConfig.capture)
40
+ ReactDOM.createRoot(el).render(
41
+ <NetworkProvider config={networkConfig}>
42
+ <App />
43
+ </NetworkProvider>,
44
+ )
45
+
46
+ // Home.jsx — after <ReviewCarousel />
47
+ import { DiscoverBusinesses } from '@intenque/network-sdk/react'
48
+ ;<DiscoverBusinesses />
49
+
50
+ // ConsentBanner.jsx — next to grantConsent() / denyConsent()
51
+ import { setConsent } from '@intenque/network-sdk/capture'
52
+ setConsent('granted') / setConsent('denied')
53
+
54
+ // api/axios.js — body mode, no CORS change
55
+ import { attachReferral } from '@intenque/network-sdk/capture'
56
+ attachReferral(api)
57
+ ```
58
+
59
+ App Platform: the frontend component's **Build Command must be `npm run build`** so `sync-fallback` refreshes the
60
+ bundled fallback on every deploy. `npx network-sdk doctor` checks all of the above (import-based, any file layout).
61
+
62
+ ## Install (backend, ≤ 10 minutes)
63
+
64
+ The timed steps 7–8 of `docs/install-guide.md`, as made on JCM. Node ≥ 18, ESM or CJS, Mongoose 7 or 8.
65
+
66
+ ```sh
67
+ cd backend
68
+ npm i @intenque/network-sdk@2.2.0-beta.1
69
+ ```
70
+
71
+ Env, as `SECRET` values on the backend component (names only in `.env.example`): `INR_ENABLED=false` (only the
72
+ literal `true` turns reporting and referral storage on), `INR_HUB_URL`, `INR_SITE_KEY`, `INR_KEY_ID`,
73
+ `INR_SITE_SECRET` (Admin → Sites → Keys, shown once); `INR_STRICT_ENV=true` on staging only.
74
+
75
+ ```js
76
+ // config/inr.js — imported only from server.js, controllers and scripts, after dotenv has run
77
+ import mongoose from 'mongoose'
78
+ const enabled = process.env.INR_ENABLED === 'true' // default OFF
79
+ const sdk = await import('@intenque/network-sdk/server').catch((err) => {
80
+ if (err?.code === 'ERR_MODULE_NOT_FOUND' || err?.code === 'MODULE_NOT_FOUND') return null
81
+ throw err
82
+ })
83
+ if (!sdk && enabled) throw new Error('INR_ENABLED=true but @intenque/network-sdk is not installed')
84
+ // !sdk && !enabled: export a passthrough middleware (req.inrRef = null, inrRef stripped) and a no-op reporter
85
+ if (enabled) sdk.verifyEnv()
86
+ export const inr = sdk.createConversionReporter({
87
+ hubUrl: process.env.INR_HUB_URL,
88
+ siteKey: process.env.INR_SITE_KEY,
89
+ keyId: process.env.INR_KEY_ID,
90
+ secret: process.env.INR_SITE_SECRET,
91
+ enabled,
92
+ outbox: { mongoose },
93
+ })
94
+ export const referralMiddleware = sdk.referralMiddleware
95
+ export const reportAll = (events) => Promise.all(events.map((e) => inr.report(e))).catch(() => {})
96
+
97
+ // server.js — right after express.json()
98
+ app.use('/api', referralMiddleware())
99
+
100
+ // booking: store the referral, report nothing yet
101
+ referral: enabled ? req.inrRef : undefined // { clickId, firstClickId, tokenScope, capturedAt }
102
+
103
+ // report points: one line after the commit or update, never inside a transaction, never changes the response
104
+ await reportAll(paidReports({ appt, session, eventCreated })) // Stripe webhook: await, before the 200
105
+ void reportAll(cancelReport({ appt, at: new Date() })) // request handler: void
106
+ ```
107
+
108
+ The mapping functions (`paidReports`, `cancelReport`, `noShowReport`, `feeChargedReport`, `refundReports`) are the
109
+ site's own pure module returning `ConversionEvent[]` (`[]` without a referral), so each report point stays one line.
110
+ Then `npx network-sdk doctor --backend --root backend [--mongo-uri <uri>]` (signed ping, env names, indexes), the
111
+ §5.9 privacy text, and only then `INR_ENABLED=true`.
112
+
113
+ ## How it renders
114
+
115
+ `DiscoverBusinesses` fetches `<cdnBase>/sites/<siteKey>/manifest.json` after mount (4 s timeout, `cache: 'default'`),
116
+ verifies it in the browser with WebCrypto (root certificate → signer window → ES256 P1363 low-S signature → revocation
117
+ list from the **bundled** fallback → identity `site`/`channel`/`utmSource` → rollback floor with one `cache: 'reload'`
118
+ retry), and otherwise renders the committed `network.fallback.json` (verified the same way, time window skipped). It
119
+ renders `null` while loading or when empty, never throws and never toasts. Markup: `<section aria-labelledby>` →
120
+ heading with one accent word → optional subtitle → 1/2/3-column grid of cards (lazy `640×360` image with `srcSet`,
121
+ `h3`, `category · location`, 3-line description, "Visit Website" link with UTMs from `manifest.links`, `target=_blank`,
122
+ `rel="noopener noreferrer"`, `aria-label="Visit {name} website (opens in a new tab)"`).
123
+
124
+ Styling is headless: pass Tailwind classes per slot in `theme` (`unstyled: true` drops the `inr-*` class names), or
125
+ import `styles.css` and set `--inr-*` variables. Slots: `section container heading accentWord subheading grid card
126
+ imageBox image placeholder body name meta description button buttonIcon`.
127
+
128
+ ## Signals (2.1, spec §5.1)
129
+
130
+ Everything is switched by the signed manifest, so turning analytics on or off, or changing the sample rate, is a
131
+ publish in the hub — never a site deploy.
132
+
133
+ - **Nothing** leaves the section unless `analytics.enabled` is true. Click ids and click signals additionally need
134
+ `analytics.clicks` (with clicks off the link is never rewritten); impressions need `analytics.impressions` and a
135
+ sampled-in page view.
136
+ - **Page view.** One per mounted section: a random `pv` (kept in memory only), the mount time `m`, and a sampling roll
137
+ made once (`Math.random() * 1000 < analytics.samplePermille`; `samplePermille: 0` = impressions off, `sp: 0, smp: 0`).
138
+ - **Impression.** One `IntersectionObserver` (threshold 0.5) watches the section and every card. A card is `seen` after
139
+ ≥ 1 s continuously ≥ 50 % visible; `ms` counts only while the tab is visible; `fv` is the first visible time after
140
+ mount. The section counts while it, or any of its cards, is ≥ 50 % visible (a one-column phone cannot show half of
141
+ a tall section). The impression is sent 10 s after the section first appeared, or earlier at the first card click,
142
+ `visibilitychange → hidden`, `pagehide` or an SPA unmount; later `hidden` / `pagehide` re-send only when something
143
+ changed, and a click re-sends first when what the hub scores it on changed (the section became seen, a card was
144
+ first seen) (≤ 10 sends; the hub merges them). Nothing is sent for a page view in which nothing was ever on screen.
145
+ Prerendered pages attach nothing until `prerenderingchange`; a back/forward-cache restore keeps the page view. The
146
+ page view never depends on prop identity: an inline `onEvent={() => …}`, `theme={{…}}` or `config` on a parent
147
+ that re-renders keeps one page view and one manifest fetch.
148
+ - **Click.** `pointerdown` mints the self-describing click id (site index, business index, minute, 16 random bytes)
149
+ and rewrites the link to `…&inr=<id>` (the href at rest has UTMs only, and goes back to it 2 s after the gesture
150
+ ends, or at once on blur). A destination whose `instrumentation` is `none` never gets `inr` (it has no
151
+ `captureReferral` to strip it); its click is still signalled. `click` / middle-button `auxclick` flush the
152
+ impression first, then send the click; `contextmenu` and `dragstart` send an indirect click (`btn: 2`) because "Open
153
+ in new tab" and drag-to-tab-bar navigate without a click event. Each id is sent once. Nothing awaits or calls
154
+ `preventDefault`; navigation is never blocked.
155
+ - **Consent and DEV.** On a `require-consent` site the device bits and the viewport are sent as `hd: 0`, `vp: 0×0`
156
+ until consent is granted (GPC counts as a denial). A DEV build or a localhost page of a production-channel site
157
+ sends nothing unless its signal endpoint is a local hub (preview-channel sites always send).
158
+ - **Transport.** `navigator.sendBeacon(url, text)`; when it is missing or returns `false`, `fetch(url, { method:
159
+ 'POST', mode: 'no-cors', keepalive: true, credentials: 'omit' })`; when that rejects (it shares the browser's 64 KB
160
+ keepalive quota) and the page is still visible, one retry without `keepalive`. Nothing is logged; the `signal`
161
+ event reports `sent: false` when every path failed. An `http:` endpoint is never used from an `https:` page (a dev
162
+ fallback shipped by mistake sends nothing).
163
+ - **Directory flags** (`sig_fail`, `rev_rollback` with the rejected revision `rr`, `stale_cache`, `fallback_invalid`,
164
+ `fallback_key_expired`, `sitekey_from_fallback`, `sdk_outdated`) travel on the impression as `fl`.
165
+ - **`onEvent`** mirrors `directory`, `impression` (rendered ids), `click` (with the href **without** the click id, so
166
+ the site's own tracker never stores it) and `signal` (`{ kind, sent, why? }`).
167
+ - **Custom cards** (`renderCard`): spread `linkProps` on the card's `<a>` (handlers included; a handler you set after
168
+ the spread replaces ours), pass `observe` as the card element's `ref` for per-card viewability, and call
169
+ `trackClick(via)` from a control that is not an anchor — it returns the href to open. `useNetworkLink(business)`
170
+ takes the section's hooks from context; a 2.0-style second argument `onClick(business, href, via)` still works as a
171
+ deprecated mirror (`trackClick` returns its string) and never throws.
172
+ - **Landing** (`capture`): `captureReferral` sends `{ v: 2, k, c, scope, ph, sdk, t, d }` to `<endpoint>/landing`
173
+ once per click id — at capture, or after `setConsent('granted')` in `require-consent` mode (`d` = the wait; under
174
+ GPC the buffered referral is dropped instead: GPC is a denial at all times). It is
175
+ governed by the destination's own `analytics.landings`, enforced by the hub against the live switch (so a publish,
176
+ not a rebuild, turns it on or off); `capture.endpoint` is the bundled fallback's endpoint either way, and `null` only
177
+ for a DEV build of a production site with a non-local hub. Not `analytics.enabled`. `ph` (sha256 of the path, 16
178
+ hex) is omitted without `crypto.subtle`.
179
+ - **`doctor`** sends one test impression (`src: 'doctor'`, the live manifest's token) to the hub; `--no-impression`
180
+ skips it, and a failure is a warning.
181
+
182
+ ## Conversion reporting (2.2, spec §6.5)
183
+
184
+ Backend only (`@intenque/network-sdk/server`, Node ≥ 18, global `fetch`). Four env values: `INR_HUB_URL` (an origin:
185
+ `https://…`, or `http://localhost` / `127.0.0.1` / `[::1]`), `INR_SITE_KEY`, `INR_KEY_ID` (8 hex) and
186
+ `INR_SITE_SECRET` (`inrs1_…`). Call `verifyEnv()` at boot: it names missing or malformed values, throws outside production, or anywhere
187
+ with `INR_STRICT_ENV=true` (set it on staging, which runs `NODE_ENV=production`), and never prints a value.
188
+
189
+ - **`report(event)`** checks the event against the hub's rules (the same `eventId` shape, per-type amounts, currency
190
+ with any amount, `reverses` on the linked types; a bad event resolves `invalid` with the failing field), writes the
191
+ unsigned body to the Mongo outbox and starts sending. It resolves within 2 s whatever Mongo does, never rejects
192
+ and never waits for the network: `queued`, `spilled` (outbox unavailable; held in memory, ≤ 1000), `disabled` or
193
+ `invalid`. Request handlers may `void inr.report(…)`; **Stripe webhook handlers `await` it** before answering 200.
194
+ - **Fees** are reported only as `fee_charged` (`feeCents`, `reverses` → the no-show or cancel), once the charge
195
+ succeeds. Fee refunds are never reported.
196
+ - **Delivery.** Every attempt re-signs `JSON.stringify(body)` with a fresh timestamp and nonce and posts it with
197
+ `redirect: 'error'` (timeout `INR_TIMEOUT_MS`, default 3000 ms). Any 2xx (202 accepted, 200 duplicate) and
198
+ 409 `NONCE_REUSED` delete the row. 408 / 425 / 429 / 5xx / network errors are retried in process after 2, 8 and
199
+ 30 s (a longer `Retry-After` wins; above 30 s, e.g. `503 BREAKER_OPEN` with `Retry-After: 60`, the row waits in the
200
+ outbox). `400 TIMESTAMP_SKEW` corrects the process clock from the hub's `Date` and re-signs once. A 401 (or missing
201
+ config) is retried every 10 min for 24 h, re-reading `INR_KEY_ID` / `INR_SITE_SECRET`, so a redeploy after a key
202
+ rotation loses nothing. Other 400s (`CURRENCY_NOT_ALLOWED`, `TYPE_NOT_ALLOWED`, `UNKNOWN_FIELD`, …) are dropped
203
+ with a `warn` `inr_outbox_dropped_total { eventType, eventId, status, code, reason }` line (never a click id).
204
+ - **Outbox and drainer.** The reporter creates the outbox's three indexes itself (`createdAt` TTL 14 d,
205
+ `nextAttemptAt`, unique `(eventType, eventId)`), so `autoIndex: false` sites get them too; each failed index is
206
+ retried at the next drain tick, with one `INR_OUTBOX_INDEX_FAILED` warning. The collection is
207
+ `outbox.collection`, else `INR_OUTBOX_COLLECTION`, else `inrConversionOutbox`. Mongoose 7 and 8 both work. Every
208
+ 60 s (± 10 s; the first tick 10–20 s after start) a drain moves the in-memory spill into Mongo and sends up to 50 due rows. `nextAttemptAt` is a lease: a row
209
+ is claimed atomically for 150 s, so several backend instances never send it twice and a crashed sender's row goes
210
+ out again 150 s later with a fresh signature. Retries back off from 1 to 30 min (or the hub's `Retry-After`, up to
211
+ 1 h) with no attempt limit; a 429 or 503 ends the tick; rows older than 14 d − 1 h are dropped as `expired`.
212
+ `drainNow()` runs a tick now; call `close()` on shutdown (stops the timer, waits ≤ 5 s).
213
+ - **`forget(clickId)`** (DSAR, §5.9): deletes queued reports naming the id, then sends the signed
214
+ `DELETE /v1/referrals/:clickId`, retrying 503 / 429 / 5xx up to 4 times (2, 8, 30 s). It resolves `true` once the
215
+ hub answered 2xx and `false` otherwise (401, other 4xx, missing env, Mongo down; warn `INR_FORGET_FAILED`). Keep
216
+ the booking's referral until it returns `true`. It works with `enabled: false`.
217
+ - **`referralMiddleware()`** sets `req.inrRef` and removes `inrRef` from a plain-object body, so `new Model(req.body)`
218
+ never sees it. `referralFromRequest(req)` leaves the body alone.
219
+ - **`landingRelay(opts?)`** (optional; JCM does not mount it): `app.post('/inr/landing', inr.landingRelay())` takes
220
+ `{ c, scope, ph, d }` (JSON or text, ≤ 1 KB) from the site's own frontend and forwards a signed `POST /v1/landings`
221
+ with the visitor's IP, user agent and `Origin` (the hub hashes and discards them). Express needs `trust proxy` set,
222
+ even with `visitorIp: (req) => …` (otherwise nothing is forwarded and `INR_RELAY_TRUST_PROXY` is logged once). At
223
+ most 10 per minute per visitor IP and 300 per process (all handlers together) are forwarded. The browser always
224
+ gets 204.
225
+ - **Capture `routes`.** `attachReferral(api, { routes: ['/api/appointments/book'] })` adds the referral only to
226
+ mutating requests whose path equals a string or matches a RegExp entry. The path is the one axios requests: a
227
+ relative `url` is appended to `baseURL` (`baseURL: '/api'` + `'/appointments/book'` → `/api/appointments/book`).
228
+ - **`doctor --backend [--root <dir>] [--mongo-uri <uri>]`** for a split backend (no `src/`, no `network.config.js`
229
+ needed): Node ≥ 18, the reporter and referral imports, the SDK dependency, the four env names (from the process
230
+ env or `<root>/.env`, printed as set/unset only), a signed `POST /v1/conversions/ping` (204 ok; 401 "key id or
231
+ secret rejected"; 404 "wrong INR_HUB_URL?"), and with `--mongo-uri` the outbox indexes through the site's own
232
+ `mongodb` driver. No value and no URI is printed.
233
+
234
+ ## Phase status
235
+
236
+ - Phase 1: rendering, verification, fallback/floor, `capture` (consent transitions, body-mode `attachReferral`), CLI,
237
+ `server` API surface with `signRequest` and the Mongo outbox model.
238
+ - Phase 2: impression, click and landing signals; `doctor`'s test impression.
239
+ - Phase 3 (this release): conversion reporting (above): signed delivery, the outbox drainer, `forget()`,
240
+ `landingRelay()`, `referralMiddleware` stripping `inrRef`, the capture `routes` allowlist and `doctor --backend`.
241
+
242
+ ## Development
243
+
244
+ ```sh
245
+ npm run build # tsup → dist (esm + cjs + d.ts, styles.css, cli)
246
+ npm test # build + size-limit budgets + vitest: the SDK suite (the hub's shared vectors must pass
247
+ # byte-for-byte) and an e2e project that posts SDK-made bodies to the real hub ingest
248
+ # (apps/api test app on mongodb-memory-server)
249
+ npm run size # react ≤ 12 KB gz (incl. verifier + signals), capture ≤ 1.5 KB, server ≤ 8 100 B, styles ≤ 3 KB
250
+ npm run version:bump -- minor # bumps package.json + SDK_VERSION together; then tag network-sdk-v<version> and push:
251
+ # .github/workflows/sdk-publish.yml publishes via npm trusted publishing (OIDC),
252
+ # environment npm-publish, --tag beta for pre-releases (docs/runbooks/sdk-publish.md)
253
+ ```
254
+
255
+ Production root keys are added in `src/rootPublicKeys.ts` (see the comment there and `docs/install-guide.md`).
@@ -0,0 +1 @@
1
+ "use strict";var u=Object.defineProperty;var m=Object.getOwnPropertyDescriptor;var y=Object.getOwnPropertyNames;var k=Object.prototype.hasOwnProperty;var C=(n,e)=>{for(var t in e)u(n,t,{get:e[t],enumerable:!0})},M=(n,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of y(e))!k.call(n,r)&&r!==t&&u(n,r,{get:()=>e[r],enumerable:!(o=m(e,r))||o.enumerable});return n};var L=n=>M(u({},"__esModule",{value:!0}),n);var w={};C(w,{attachReferral:()=>A,attachReferralHeader:()=>O,captureReferral:()=>b,clearReferral:()=>N,getReferral:()=>I,onConsentChange:()=>T,setConsent:()=>E});module.exports=L(w);var _="2.2.0-beta.1";function S(n,e,t){let o=s=>{try{t?.(s)}catch{}return s};try{if(location.protocol=="https:"&&/^http:/i.test(n))return o(!1)}catch{}try{if(navigator.sendBeacon(n,e))return o(!0)}catch{}let r=s=>fetch(n,{method:"POST",mode:"no-cors",keepalive:s,credentials:"omit",body:e});try{return r(!0).then(()=>o(!0),()=>{try{if(document.visibilityState=="visible")return void r(!1).then(()=>o(!0),()=>o(!1))}catch{}o(!1)}),!0}catch{return o(!1)}}var f="inr_ref",h=2592e6,a={},l=null,c=(n,e)=>{try{let t=n?localStorage:sessionStorage;if(e===void 0)return JSON.parse(t.getItem(f)||"null");e?t.setItem(f,JSON.stringify(e)):t.removeItem(f)}catch{}return null},R=()=>!!navigator.globalPrivacyControl,p=()=>a.consent?.get()=="granted"&&!R(),g="",x=n=>{let e=a.endpoint,t=n.last.c;if(!e||!a.siteKey||g==t)return;g=t;let o=r=>{let s=Date.now();S(e.replace(/\/+$/,"")+"/landing",JSON.stringify({v:2,k:a.siteKey,c:t,scope:"session",ph:r&&[...new Uint8Array(r,0,8)].map(i=>(i+256).toString(16).slice(1)).join(""),sdk:_,t:s,d:Math.min(h,Math.max(0,s-n.last.t))}))};try{crypto.subtle.digest("SHA-256",new TextEncoder().encode(location.pathname)).then(o,()=>o())}catch{o()}};function b(n={}){a=n;try{let e=new URL(location.href),t=e.searchParams.get("inr");if(!t||(e.searchParams.delete("inr"),history.replaceState(history.state,"",e.href),!/^[A-Za-z0-9_-]{32}$/.test(t)))return;let o={c:t,t:Date.now(),from:e.searchParams.get("utm_source")},r=c(0),s={last:o,first:r?.first||o};if(n.consentMode=="require-consent"&&!p()){l=s;return}c(0,s),p()&&c(1,s),x(s)}catch{}}function E(n){try{if(n=="granted"){let e=l;if(l=null,e&&R())return;let t=e||c(0);t&&(c(0,t),p()&&c(1,t),e&&x(e))}else l=null,c(1,null)}catch{}}var T=E;function N(){l=null,c(0,null),c(1,null)}function I(){try{let n=c(0),e=c(1),t=n&&(!e||n.last.t>=e.last.t)?n:e;return!t||Date.now()-t.last.t>h?null:{clickId:t.last.c,firstClickId:t.first.c==t.last.c?null:t.first.c,capturedAt:t.last.t,tokenScope:n&&n.last.c==t.last.c?"session":"persistent",consented:!!p()}}catch{return null}}function A(n,e={}){n.interceptors.request.use(t=>{try{if(/^(get|head|options)?$/i.test(t.method||""))return t;let o=new URL((t.baseURL||n.defaults?.baseURL||"").replace(/\/*$/,"/"),location.origin),r=new URL((t.url||"").replace(/^\/(?!\/)/,""),o),s=r.pathname;if(r.origin!=o.origin&&!e.origins?.includes(r.origin)||e.routes&&!e.routes.some(d=>d.test?~s.search(d):d==s))return t;let i=I();if(!i)return t;e.mode=="header"?(t.headers=t.headers||{},t.headers["X-INR-Ref"]=i.clickId,t.headers["X-INR-Ref-Scope"]=i.tokenScope,i.firstClickId&&(t.headers["X-INR-Ref-First"]=i.firstClickId)):(t.data==null||Object.getPrototypeOf(t.data)==Object.prototype)&&(t.data={...t.data,inrRef:{clickId:i.clickId,firstClickId:i.firstClickId,tokenScope:i.tokenScope}})}catch{}return t})}var O=(n,e={})=>A(n,{...e,mode:"header"});
@@ -0,0 +1,57 @@
1
+ type Consent = 'granted' | 'denied' | null;
2
+ type CaptureOptions = {
3
+ siteKey?: string;
4
+ endpoint?: string | null;
5
+ consent?: {
6
+ get: () => Consent;
7
+ } | undefined;
8
+ consentMode?: 'session-default' | 'require-consent' | undefined;
9
+ };
10
+ type Referral = {
11
+ clickId: string;
12
+ firstClickId: string | null;
13
+ capturedAt: number;
14
+ tokenScope: 'session' | 'persistent';
15
+ consented: boolean;
16
+ };
17
+ declare function captureReferral(o?: CaptureOptions): void;
18
+ declare function setConsent(state: 'granted' | 'denied'): void;
19
+ /** @deprecated use setConsent */
20
+ declare const onConsentChange: typeof setConsent;
21
+ declare function clearReferral(): void;
22
+ declare function getReferral(): Referral | null;
23
+ type Req = {
24
+ url?: string;
25
+ baseURL?: string;
26
+ method?: string;
27
+ data?: unknown;
28
+ headers?: Record<string, unknown>;
29
+ };
30
+ type Axios = {
31
+ defaults?: {
32
+ baseURL?: string | undefined;
33
+ };
34
+ interceptors: {
35
+ request: {
36
+ use: (f: (r: Req) => Req) => unknown;
37
+ };
38
+ };
39
+ };
40
+ /**
41
+ * `routes` (2.2 data minimisation): when given, only mutating requests whose path equals a string entry or matches a
42
+ * RegExp entry carry the referral. The path is the one axios requests: a relative `url` (leading `/` or not) is
43
+ * appended to `baseURL` (axios `combineURLs`), never URL-resolved against it (only an empty `url` differs: it reads as
44
+ * `baseURL/`). A RegExp's `g` / `y` `lastIndex` is ignored (`search`).
45
+ */
46
+ declare function attachReferral(api: Axios, o?: {
47
+ mode?: 'body' | 'header';
48
+ origins?: string[];
49
+ routes?: (string | RegExp)[];
50
+ }): void;
51
+ /** Alias for `attachReferral(api, { mode: 'header' })`. */
52
+ declare const attachReferralHeader: (api: Axios, o?: {
53
+ origins?: string[];
54
+ routes?: (string | RegExp)[];
55
+ }) => void;
56
+
57
+ export { type CaptureOptions, type Referral, attachReferral, attachReferralHeader, captureReferral, clearReferral, getReferral, onConsentChange, setConsent };
@@ -0,0 +1,57 @@
1
+ type Consent = 'granted' | 'denied' | null;
2
+ type CaptureOptions = {
3
+ siteKey?: string;
4
+ endpoint?: string | null;
5
+ consent?: {
6
+ get: () => Consent;
7
+ } | undefined;
8
+ consentMode?: 'session-default' | 'require-consent' | undefined;
9
+ };
10
+ type Referral = {
11
+ clickId: string;
12
+ firstClickId: string | null;
13
+ capturedAt: number;
14
+ tokenScope: 'session' | 'persistent';
15
+ consented: boolean;
16
+ };
17
+ declare function captureReferral(o?: CaptureOptions): void;
18
+ declare function setConsent(state: 'granted' | 'denied'): void;
19
+ /** @deprecated use setConsent */
20
+ declare const onConsentChange: typeof setConsent;
21
+ declare function clearReferral(): void;
22
+ declare function getReferral(): Referral | null;
23
+ type Req = {
24
+ url?: string;
25
+ baseURL?: string;
26
+ method?: string;
27
+ data?: unknown;
28
+ headers?: Record<string, unknown>;
29
+ };
30
+ type Axios = {
31
+ defaults?: {
32
+ baseURL?: string | undefined;
33
+ };
34
+ interceptors: {
35
+ request: {
36
+ use: (f: (r: Req) => Req) => unknown;
37
+ };
38
+ };
39
+ };
40
+ /**
41
+ * `routes` (2.2 data minimisation): when given, only mutating requests whose path equals a string entry or matches a
42
+ * RegExp entry carry the referral. The path is the one axios requests: a relative `url` (leading `/` or not) is
43
+ * appended to `baseURL` (axios `combineURLs`), never URL-resolved against it (only an empty `url` differs: it reads as
44
+ * `baseURL/`). A RegExp's `g` / `y` `lastIndex` is ignored (`search`).
45
+ */
46
+ declare function attachReferral(api: Axios, o?: {
47
+ mode?: 'body' | 'header';
48
+ origins?: string[];
49
+ routes?: (string | RegExp)[];
50
+ }): void;
51
+ /** Alias for `attachReferral(api, { mode: 'header' })`. */
52
+ declare const attachReferralHeader: (api: Axios, o?: {
53
+ origins?: string[];
54
+ routes?: (string | RegExp)[];
55
+ }) => void;
56
+
57
+ export { type CaptureOptions, type Referral, attachReferral, attachReferralHeader, captureReferral, clearReferral, getReferral, onConsentChange, setConsent };
@@ -0,0 +1 @@
1
+ var f="2.2.0-beta.1";function _(n,e,t){let o=r=>{try{t?.(r)}catch{}return r};try{if(location.protocol=="https:"&&/^http:/i.test(n))return o(!1)}catch{}try{if(navigator.sendBeacon(n,e))return o(!0)}catch{}let s=r=>fetch(n,{method:"POST",mode:"no-cors",keepalive:r,credentials:"omit",body:e});try{return s(!0).then(()=>o(!0),()=>{try{if(document.visibilityState=="visible")return void s(!1).then(()=>o(!0),()=>o(!1))}catch{}o(!1)}),!0}catch{return o(!1)}}var u="inr_ref",g=2592e6,a={},l=null,c=(n,e)=>{try{let t=n?localStorage:sessionStorage;if(e===void 0)return JSON.parse(t.getItem(u)||"null");e?t.setItem(u,JSON.stringify(e)):t.removeItem(u)}catch{}return null},h=()=>!!navigator.globalPrivacyControl,p=()=>a.consent?.get()=="granted"&&!h(),S="",R=n=>{let e=a.endpoint,t=n.last.c;if(!e||!a.siteKey||S==t)return;S=t;let o=s=>{let r=Date.now();_(e.replace(/\/+$/,"")+"/landing",JSON.stringify({v:2,k:a.siteKey,c:t,scope:"session",ph:s&&[...new Uint8Array(s,0,8)].map(i=>(i+256).toString(16).slice(1)).join(""),sdk:f,t:r,d:Math.min(g,Math.max(0,r-n.last.t))}))};try{crypto.subtle.digest("SHA-256",new TextEncoder().encode(location.pathname)).then(o,()=>o())}catch{o()}};function C(n={}){a=n;try{let e=new URL(location.href),t=e.searchParams.get("inr");if(!t||(e.searchParams.delete("inr"),history.replaceState(history.state,"",e.href),!/^[A-Za-z0-9_-]{32}$/.test(t)))return;let o={c:t,t:Date.now(),from:e.searchParams.get("utm_source")},s=c(0),r={last:o,first:s?.first||o};if(n.consentMode=="require-consent"&&!p()){l=r;return}c(0,r),p()&&c(1,r),R(r)}catch{}}function x(n){try{if(n=="granted"){let e=l;if(l=null,e&&h())return;let t=e||c(0);t&&(c(0,t),p()&&c(1,t),e&&R(e))}else l=null,c(1,null)}catch{}}var M=x;function L(){l=null,c(0,null),c(1,null)}function E(){try{let n=c(0),e=c(1),t=n&&(!e||n.last.t>=e.last.t)?n:e;return!t||Date.now()-t.last.t>g?null:{clickId:t.last.c,firstClickId:t.first.c==t.last.c?null:t.first.c,capturedAt:t.last.t,tokenScope:n&&n.last.c==t.last.c?"session":"persistent",consented:!!p()}}catch{return null}}function I(n,e={}){n.interceptors.request.use(t=>{try{if(/^(get|head|options)?$/i.test(t.method||""))return t;let o=new URL((t.baseURL||n.defaults?.baseURL||"").replace(/\/*$/,"/"),location.origin),s=new URL((t.url||"").replace(/^\/(?!\/)/,""),o),r=s.pathname;if(s.origin!=o.origin&&!e.origins?.includes(s.origin)||e.routes&&!e.routes.some(d=>d.test?~r.search(d):d==r))return t;let i=E();if(!i)return t;e.mode=="header"?(t.headers=t.headers||{},t.headers["X-INR-Ref"]=i.clickId,t.headers["X-INR-Ref-Scope"]=i.tokenScope,i.firstClickId&&(t.headers["X-INR-Ref-First"]=i.firstClickId)):(t.data==null||Object.getPrototypeOf(t.data)==Object.prototype)&&(t.data={...t.data,inrRef:{clickId:i.clickId,firstClickId:i.firstClickId,tokenScope:i.tokenScope}})}catch{}return t})}var b=(n,e={})=>I(n,{...e,mode:"header"});export{I as attachReferral,b as attachReferralHeader,C as captureReferral,L as clearReferral,E as getReferral,M as onConsentChange,x as setConsent};