@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.
- package/CHANGELOG.md +145 -0
- package/README.md +254 -2
- package/dist/capture/index.cjs +1 -0
- package/dist/capture/index.d.cts +57 -0
- package/dist/capture/index.d.ts +57 -0
- package/dist/capture/index.js +1 -0
- package/dist/cli/index.js +1745 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/constants-C_6Zxu_k.d.cts +20 -0
- package/dist/constants-C_6Zxu_k.d.ts +20 -0
- package/dist/react/index.cjs +3 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +421 -0
- package/dist/react/index.d.ts +421 -0
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -0
- package/dist/server/index.cjs +2 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +350 -0
- package/dist/server/index.d.ts +350 -0
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -0
- package/dist/styles.css +141 -0
- package/package.json +111 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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.2
|
|
7
|
+
|
|
8
|
+
Security fix round (hub Phase 3 WP19). No API change; one new drop reason.
|
|
9
|
+
|
|
10
|
+
### Server entry (`@intenque/network-sdk/server`)
|
|
11
|
+
|
|
12
|
+
- **Misrouted answers are retried, not dropped.** `403`, `404` and any 3xx the classifier sees (`300`, `304`, or a
|
|
13
|
+
`fetch` that hands redirects back) were terminal after one attempt, so a missing route prefix, a WAF or a
|
|
14
|
+
maintenance page in front of the hub lost every report in that window. They are now `misrouted`
|
|
15
|
+
(`classifyResponse` returns the new kind): retried in process after 2, 8 and 30 s, then every 10 min from the
|
|
16
|
+
outbox, and dropped only 24 h after the first 401 / missing-config / misrouted answer with reason `misrouted_24h`
|
|
17
|
+
(the clock is the existing `unauthorizedSince` field, shared with the 401 rule; no outbox migration). A real
|
|
18
|
+
redirect (`301`/`302`/`303`/`307`/`308`, with or without `Location`) is still a transport error under
|
|
19
|
+
`redirect: 'error'` (status 0, retryable), as before. `400`, other `409`s, `402`, `413`, `422` and the rest of 4xx
|
|
20
|
+
stay terminal.
|
|
21
|
+
- **The responder's error code is bounded.** A code is kept (logged in `inr_outbox_dropped_total` /
|
|
22
|
+
`INR_UNAUTHORIZED`, stored in `lastError.code`) only when it matches `^[A-Z][A-Z0-9_]{0,63}$`; anything else
|
|
23
|
+
(a captive portal, a misrouted proxy, a 5000-character string) is `null`.
|
|
24
|
+
- **`referralMiddleware()` strips `inrRef` from any object body** (arrays and class instances too, not only plain
|
|
25
|
+
objects) with `Reflect.deleteProperty`, and never throws: a frozen body or a proxy that refuses keeps the field,
|
|
26
|
+
a `null` or primitive body is left alone. `req.inrRef` is set as before.
|
|
27
|
+
- **`forget()` stops a running send chain.** Before each in-process retry the chain checks that its outbox row (or
|
|
28
|
+
spill entry) still exists; once `forget(clickId)` purged it, no further attempt is sent. An outbox that errors or
|
|
29
|
+
times out on that check keeps the chain sending (as before).
|
|
30
|
+
- **`forget()` during a drain tick's spill move.** The drainer moved a snapshot of the in-memory spill, so a spilled
|
|
31
|
+
report that `forget(clickId)` removed while an earlier entry was being written was inserted into the outbox after
|
|
32
|
+
the purge (and `forget()` had returned `true`); the next tick sent the erased click id again. The move now skips an
|
|
33
|
+
entry no longer spilled, and removes the row again when the entry left the spill while its own write was in flight
|
|
34
|
+
(forget, or a send that finished). A failed write still leaves the entry spilled for the next tick.
|
|
35
|
+
|
|
36
|
+
### Verifier (`@intenque/network-sdk/react`, the manifest verifier)
|
|
37
|
+
|
|
38
|
+
- **Destination URLs: RFC 3986 characters only after the authority** (unreserved, sub-delims, `:`, `@`, `/`, `?`,
|
|
39
|
+
`%XX`, one `#`). The hub now stores business URLs with `^`, `|`, a backtick, `{`, `}`, `[`, `]` and non-ASCII
|
|
40
|
+
percent-encoded and refuses
|
|
41
|
+
to sign anything else, so the SDK and the hub agree on every URL in every engine (a raw `^` or `|` used to pass
|
|
42
|
+
the hub and fail the SDK in browsers, taking the whole manifest down).
|
|
43
|
+
- **Signer dates must be their own `toISOString()`** (`2027-02-30`, `T24:00`, second 60 are `malformed`), the same
|
|
44
|
+
rule as the hub's verifier: engines disagree on those dates.
|
|
45
|
+
- **A root key that is not exactly `crv: 'P-256'` never verifies** (Firefox's WebCrypto accepts `p-256`).
|
|
46
|
+
- **A root key must have the shape the hub requires of its own roots** (`PublicJwkSchema`: `kty: 'EC'`,
|
|
47
|
+
`crv: 'P-256'`, `x` / `y` canonical base64url of 32 bytes), else `root_sig`; other members are ignored, as
|
|
48
|
+
before. The SDK handed WebCrypto `kty: 'EC'` whatever the site's `manifestRootKeys` said, so `kty: 'ec'`, `'RSA'`
|
|
49
|
+
or none verified in every engine while the hub refuses such a key, and a padded `x` verified in WebKit only.
|
|
50
|
+
- **Hosts with right-to-left text** (hub side, no SDK change): the hub now refuses a business URL whose host breaks
|
|
51
|
+
the RFC 5893 bidi rule (e.g. `24.בדיקה.co.il`, `1shop.عرب.com`, `aא.com`, `٣.com`), which Firefox's URL parser
|
|
52
|
+
refuses, so a signed manifest can no longer be `malformed` in Firefox alone. The SDK schema does not repeat that
|
|
53
|
+
check (bundle size; the hub never signs such a host).
|
|
54
|
+
|
|
55
|
+
## 2.2.0-beta.1
|
|
56
|
+
|
|
57
|
+
### Server entry (`@intenque/network-sdk/server`)
|
|
58
|
+
|
|
59
|
+
- **Signing binds the identifiers.** The v1 HMAC base is `ts.nonce.siteKey.keyId.METHOD.path.sha256(body)`;
|
|
60
|
+
`hmacBase`, `signRequest` and `verifySignature` take `siteKey` and `keyId`. The key is the UTF-8 bytes of the
|
|
61
|
+
`INR_SITE_SECRET` string. The hub's committed vectors pass byte for byte. (2.0 and 2.1 never sent a request, so no
|
|
62
|
+
signature in the old form exists.)
|
|
63
|
+
- **Conversions are sent.** `report()` used to queue only. It now sends with global `fetch` (`redirect: 'error'`,
|
|
64
|
+
`User-Agent: intenque-network-sdk/<version>`, per-attempt timeout `INR_TIMEOUT_MS`, default 3000 ms, clamped to
|
|
65
|
+
500–30 000). Answers: any 2xx and `409 NONCE_REUSED` count as success; 408, 425, 429, 5xx and network errors are
|
|
66
|
+
retried after 2, 8 and 30 s, honouring `Retry-After` (above 30 s the row waits in the outbox, capped at 1 h);
|
|
67
|
+
`400 TIMESTAMP_SKEW` re-signs once with a process-wide clock offset from the hub's `Date`, else `INR_CLOCK_SKEW`;
|
|
68
|
+
401 and missing or invalid config are retried every 10 min for 24 h (`unauthorizedSince`), then
|
|
69
|
+
`INR_UNAUTHORIZED`; other 4xx (including `CURRENCY_NOT_ALLOWED`) are dropped with an `inr_outbox_dropped_total`
|
|
70
|
+
warn line. `classifyResponse(status, code)` is exported.
|
|
71
|
+
- **`report()` is bounded.** It resolves after the outbox write or 2 s, whichever comes first, and never rejects or
|
|
72
|
+
waits for the network. A new result `spilled`: the outbox write failed or timed out, so the report is held in
|
|
73
|
+
memory (≤ 1000, oldest dropped as `memory_full`; `INR_OUTBOX_UNAVAILABLE` at most once a minute) and still sent.
|
|
74
|
+
New rows carry a 150 s lease in `nextAttemptAt`. Outbox rows gain `lastStatus`, `lastError { status, code }` and
|
|
75
|
+
`unauthorizedSince`. `idle()` resolves once the running send chains have settled.
|
|
76
|
+
- **The body rules match the hub.** `CONVERSION_TYPES` has 12 entries (new `fee_charged`: `feeCents ≥ 1`,
|
|
77
|
+
`valueCents: null`, a currency, `reverses` → the `booking_no_show` / `booking_cancelled` it belongs to).
|
|
78
|
+
`eventId` and `reverses.eventId` follow the hub's pattern: a lower-case prefix, `:`, then letters, digits, `_` or
|
|
79
|
+
`-`, at most 64 characters in all (the 200-character cap is gone), never `erased:`. Each type has the hub's
|
|
80
|
+
amount rules. Any amount needs a currency. `reverses` is required on the linked types and must name one of
|
|
81
|
+
`LINK_TARGETS[type]`; on every other type it must be null. A failing event resolves
|
|
82
|
+
`{ status: 'invalid', error: '<field>' }` and is never sent. `occurredAt` is a valid `Date` or an ISO datetime
|
|
83
|
+
string with `Z` or an offset (sent as ISO UTC); other strings (`'1'`, a space for the `T`, no zone) are `invalid`,
|
|
84
|
+
no longer parsed leniently in local time. `LINK_TARGETS` and `REVERSAL_TYPES` are exported.
|
|
85
|
+
- **`verifyEnv()`** also reports malformed values (`INR_HUB_URL (invalid)`: not an origin; `INR_SITE_KEY (invalid)`:
|
|
86
|
+
not a site slug; `INR_KEY_ID (invalid)`; `INR_SITE_SECRET (invalid)`), never their content, and throws in
|
|
87
|
+
production when `INR_STRICT_ENV=true`.
|
|
88
|
+
- A throwing site `logger` is ignored (it never rejects `report()` or a send chain). A blank `INR_TIMEOUT_MS` means
|
|
89
|
+
the 3000 ms default.
|
|
90
|
+
- **Outbox v2.** The schema declares no index and sets `autoIndex: false`; the reporter creates the three helper
|
|
91
|
+
indexes itself (`createdAt` TTL 1 209 600 s, `nextAttemptAt`, unique `(eventType, eventId)`, default names), so
|
|
92
|
+
they exist on sites that turn `autoIndex` off. Each index is tried on its own and a failed one is retried at the
|
|
93
|
+
next drain tick; failures are one `INR_OUTBOX_INDEX_FAILED` warn. The collection is `outbox.collection`, else
|
|
94
|
+
`INR_OUTBOX_COLLECTION`, else `inrConversionOutbox`. A 2.0 / 2.1 row's string `lastError` is read as
|
|
95
|
+
`{ status: 0, code }`. Mongoose 7.8.6 and 8 are both tested.
|
|
96
|
+
- **Drainer.** First tick 10–20 s after start, then every 60 s ± 10 s (unref'd, never overlapping): the spill moves
|
|
97
|
+
into Mongo, then up to 50 due rows are claimed (`findOneAndUpdate`: lease `nextAttemptAt = now + 150 s`,
|
|
98
|
+
`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
|
|
99
|
+
number of instances; an in-process send chain renews its lease before each retry. Backoff
|
|
100
|
+
`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
|
|
101
|
+
older than 14 d − 1 h are dropped as `expired`. Every drained attempt re-reads `INR_KEY_ID` / `INR_SITE_SECRET`.
|
|
102
|
+
New `drainNow()` → `{ sent, retried, dropped }` and `close()` (stops the timer, waits ≤ 5 s).
|
|
103
|
+
- **`forget(clickId)`** is implemented: invalid id → `false`; otherwise queued outbox and spill rows naming the id
|
|
104
|
+
(`clickId` or `firstClickId`) are deleted, then the signed `DELETE /v1/referrals/:clickId` (empty body) is sent,
|
|
105
|
+
with up to 4 attempts for retryable answers (Retry-After ≤ 30 s honoured) and one skew re-sign. `true` on 2xx
|
|
106
|
+
only; 401, other 4xx (a 409 `NONCE_REUSED` included), exhausted retries, missing credentials or an outbox that
|
|
107
|
+
cannot be purged → `false` and `INR_FORGET_FAILED { status, code }`. Never queued; ignores `enabled`.
|
|
108
|
+
- **`landingRelay(opts?)`** is implemented: `{ c, scope, ph, d }` (JSON or text/plain, ≤ 1 KB, parsed by the site or
|
|
109
|
+
read from the request) → one signed `POST /v1/landings` with the 2.2 relay body (`visitorIp`, `visitorUa`,
|
|
110
|
+
`visitorOrigin`; `visitorIp` is `opts.visitorIp?.(req) ?? req.ip`). Without Express `trust proxy` nothing is
|
|
111
|
+
forwarded (with or without `opts.visitorIp`) and `INR_RELAY_TRUST_PROXY` is logged once. Limits: 10 / min per
|
|
112
|
+
visitor IP, 300 / min per process, shared by every handler. The browser always gets an empty 204 (it was 202).
|
|
113
|
+
- **`referralMiddleware()`** removes `inrRef` from a plain-object `req.body` after setting `req.inrRef`.
|
|
114
|
+
`referralFromRequest` is unchanged (pure).
|
|
115
|
+
- **Capture: `attachReferral(api, { routes })`** (and `attachReferralHeader`): with `routes: (string | RegExp)[]`, only
|
|
116
|
+
mutating requests whose path (a relative `url` appended to `baseURL`, as axios does) equals a string or matches a
|
|
117
|
+
RegExp (`lastIndex` ignored) carry the referral. Capture measured 1 499 B gzip with it (budget 1 500 B unchanged).
|
|
118
|
+
- **CLI: `doctor --backend [--mongo-uri <uri>]`** for split repos: root `--root` or the cwd, package.json optional
|
|
119
|
+
(`file:` / `link:` SDK dependency warns), no `src/network.config.js` needed, frontend checks skipped. Fails on Node
|
|
120
|
+
< 18, a missing `createConversionReporter`, missing `referralFromRequest` and `referralMiddleware`, or any missing
|
|
121
|
+
`INR_*` value (process env, else `<root>/.env`; printed as set/unset only). Signs `POST /v1/conversions/ping` `{}`
|
|
122
|
+
(5 s): 204 ok; 401, `TIMESTAMP_SKEW` (with the offset), 404 and network errors fail; 429 / 503 warn. With
|
|
123
|
+
`--mongo-uri` it checks the three outbox indexes through the site's own `mongodb` (`createRequire(root)`), else
|
|
124
|
+
prints `skip`. No env value, URI or host name is printed. Plain `doctor` also signs the ping when the backend env
|
|
125
|
+
is found.
|
|
126
|
+
- The `server` size budget is 8 100 B gzip: 5.98 KB before this step, 8 051 B measured with the drainer, `forget()`
|
|
127
|
+
and `landingRelay()`, rounded up to 50 B (addendum "Size budgets" row).
|
|
128
|
+
|
|
129
|
+
### Packaging (WP18)
|
|
130
|
+
|
|
131
|
+
- Published by `.github/workflows/sdk-publish.yml` on a `network-sdk-v<version>` tag (the tag must equal
|
|
132
|
+
`package.json`'s version or the run fails), GitHub environment `npm-publish`, npm trusted publishing (OIDC, no
|
|
133
|
+
token); a pre-release goes to the dist-tag `beta`, a release to `latest`. `publishConfig.provenance` is `false`
|
|
134
|
+
(the source repository is private; addendum deviation row). `repository` points at
|
|
135
|
+
`IntenqueSecurity/intenque-network-hub`, directory `packages/network-sdk`. The tarball is `dist`, `README.md`,
|
|
136
|
+
`CHANGELOG.md` and `package.json`. Owner steps: `docs/runbooks/sdk-publish.md`.
|
|
137
|
+
|
|
138
|
+
## 2.1.0-beta.1
|
|
139
|
+
|
|
140
|
+
Impression, click and landing signals (spec §5.1); `doctor` sends a test impression.
|
|
141
|
+
|
|
142
|
+
## 2.0.0-beta.1
|
|
143
|
+
|
|
144
|
+
Manifest schema 2: the "Discover Other Businesses" section, the signed-manifest verifier with the committed
|
|
145
|
+
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
|
-
#
|
|
1
|
+
# @intenque/network-sdk
|
|
2
2
|
|
|
3
|
-
|
|
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.2";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.2";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};
|