@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,1745 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { webcrypto } from "crypto";
5
+
6
+ // src/constants.ts
7
+ var SDK_VERSION = "2.2.0-beta.2";
8
+ var MANIFEST_SCHEMA_MAJOR = 2;
9
+ var DEFAULT_CDN_BASE = "https://inr-network-public.nyc3.cdn.digitaloceanspaces.com/network/v1";
10
+ var MANIFEST_MAX_BYTES = 24 * 1024;
11
+ var SIGNER_GRACE_MS = 7 * 864e5;
12
+ var REFERRAL_TTL_MS = 30 * 864e5;
13
+ var FALLBACK_PATH = "src/data/network.fallback.json";
14
+ var CONFIG_PATH = "src/network.config.js";
15
+ function manifestUrlFor(cdnBase, siteKey, channel) {
16
+ return `${cdnBase.replace(/\/+$/, "")}/${channel === "preview" ? "preview/" : ""}sites/${siteKey}/manifest.json`;
17
+ }
18
+
19
+ // src/cli/doctor.ts
20
+ import { randomUUID as randomUUID2 } from "crypto";
21
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3, statSync } from "fs";
22
+ import { join as join3, relative, resolve as resolve2 } from "path";
23
+
24
+ // src/cli/doctorBackend.ts
25
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
26
+ import { createRequire } from "module";
27
+ import { join as join2 } from "path";
28
+
29
+ // src/server/outbox.ts
30
+ var OUTBOX_DEFAULT_COLLECTION = "inrConversionOutbox";
31
+ var OUTBOX_TTL_SECONDS = 14 * 86400;
32
+ var OUTBOX_INDEXES = [
33
+ [{ createdAt: 1 }, { expireAfterSeconds: OUTBOX_TTL_SECONDS }],
34
+ [{ nextAttemptAt: 1 }, {}],
35
+ [{ eventType: 1, eventId: 1 }, { unique: true }]
36
+ ];
37
+
38
+ // src/server/sign.ts
39
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from "crypto";
40
+ function sha256Hex(data) {
41
+ return createHash("sha256").update(data).digest("hex");
42
+ }
43
+ function hmacBase(timestamp, nonce, siteKey, keyId, method, path, body) {
44
+ return `${timestamp}.${nonce}.${siteKey}.${keyId}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`;
45
+ }
46
+ function signRequest(input) {
47
+ if (!input.secret) throw new Error("signRequest: secret is required");
48
+ const timestamp = input.timestamp ?? Math.floor(Date.now() / 1e3);
49
+ const nonce = input.nonce ?? randomUUID();
50
+ const mac = createHmac("sha256", input.secret).update(
51
+ hmacBase(timestamp, nonce, input.siteKey, input.keyId, input.method, input.path, input.body),
52
+ "utf8"
53
+ ).digest("hex");
54
+ const signature = `v1=${mac}`;
55
+ return {
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ "X-INR-Site": input.siteKey,
59
+ "X-INR-Key-Id": input.keyId,
60
+ "X-INR-Timestamp": String(timestamp),
61
+ "X-INR-Nonce": nonce,
62
+ "X-INR-Signature": signature
63
+ },
64
+ timestamp,
65
+ nonce,
66
+ signature
67
+ };
68
+ }
69
+
70
+ // src/server/wire.ts
71
+ var REVERSAL_TARGETS = {
72
+ booking_cancelled: ["booking_created"],
73
+ booking_no_show: ["booking_created"],
74
+ booking_refunded: ["booking_paid"],
75
+ chargeback: ["booking_paid", "purchase"]
76
+ };
77
+ var LINK_TARGETS = {
78
+ ...REVERSAL_TARGETS,
79
+ fee_charged: ["booking_no_show", "booking_cancelled"]
80
+ };
81
+ var REVERSAL_TYPES = Object.freeze(
82
+ Object.keys(REVERSAL_TARGETS)
83
+ );
84
+ var SITE_SECRET_RE = /^inrs1_[A-Za-z0-9_-]{43}$/;
85
+ var KEY_ID_RE = /^[0-9a-f]{8}$/;
86
+ var SITE_KEY_RE = /^(?=.{1,48}$)[a-z0-9]+(-[a-z0-9]+)*$/;
87
+ var SIGNED_PATHS = {
88
+ conversion: "/v1/conversions",
89
+ ping: "/v1/conversions/ping",
90
+ landing: "/v1/landings",
91
+ forget: "/v1/referrals/"
92
+ };
93
+
94
+ // src/server/delivery.ts
95
+ var SEC = 1e3;
96
+ var RETRY_DELAYS_MS = [2 * SEC, 8 * SEC, 30 * SEC];
97
+ var IN_PROCESS_MAX_DELAY_MS = 30 * SEC;
98
+ var RETRY_AFTER_CAP_MS = 3600 * SEC;
99
+ var AFTER_RETRIES_MS = 60 * SEC;
100
+ var UNAUTHORIZED_RETRY_MS = 10 * 60 * SEC;
101
+ var UNAUTHORIZED_MAX_MS = 24 * 3600 * SEC;
102
+ var LEASE_MS = 150 * SEC;
103
+ var SKEW_MAX_OFFSET_MS = 24 * 3600 * SEC;
104
+ function hubOrigin(raw) {
105
+ if (!raw) return null;
106
+ const s = raw.endsWith("/") ? raw.slice(0, -1) : raw;
107
+ if (!/^[a-z][a-z0-9+.-]*:\/\/[^/?#]+$/i.test(s)) return null;
108
+ let u;
109
+ try {
110
+ u = new URL(s);
111
+ } catch {
112
+ return null;
113
+ }
114
+ if (u.pathname !== "/" || u.username || u.password) return null;
115
+ if (u.protocol === "https:") return u.origin;
116
+ if (u.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname))
117
+ return u.origin;
118
+ return null;
119
+ }
120
+ function estimateOffsetMs(dateHeader, tSend, tRecv) {
121
+ const hub = dateHeader ? Date.parse(dateHeader) : NaN;
122
+ if (Number.isNaN(hub)) return null;
123
+ const offset = Math.round(hub + 500 - (tSend + tRecv) / 2);
124
+ return Math.abs(offset) <= SKEW_MAX_OFFSET_MS ? offset : null;
125
+ }
126
+
127
+ // src/cli/shared.ts
128
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
129
+ import { dirname, join, resolve } from "path";
130
+
131
+ // src/rootPublicKeys.ts
132
+ var rootPublicKeys = [];
133
+
134
+ // src/verify/schema.ts
135
+ var SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
136
+ var SLUG_MAX = 48;
137
+ var MAX_ITEMS_CEILING = 24;
138
+ var KID_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
139
+ var P1363_RE = /^[A-Za-z0-9_-]{85}[AQgw]$/;
140
+ var COORD_RE = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
141
+ var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
142
+ var TOKEN_RE = /^[a-z0-9-]+\.\d+\.\d{8}\.[0-9a-f]{12}$/;
143
+ var HOST_RE = /^[a-z0-9.-]+$/;
144
+ var URL_TAIL_RE = /^([\w.~!$&'()*+,;=:@/?-]|%[\da-f]{2})*(#([\w.~!$&'()*+,;=:@/?-]|%[\da-f]{2})*)?$/i;
145
+ var Invalid = class extends Error {
146
+ };
147
+ var fail = () => {
148
+ throw new Invalid();
149
+ };
150
+ function obj(v, keys) {
151
+ if (v === null || typeof v !== "object" || Array.isArray(v)) fail();
152
+ const o = v;
153
+ for (const k of Object.keys(o)) if (!keys.includes(k)) fail();
154
+ for (const k of keys) if (!(k in o)) fail();
155
+ return o;
156
+ }
157
+ function codePoints(s) {
158
+ let n = 0;
159
+ for (const _ of s) n++;
160
+ return n;
161
+ }
162
+ function str(v, min, max) {
163
+ if (typeof v !== "string") fail();
164
+ const n = codePoints(v);
165
+ if (n < min || n > max) fail();
166
+ return v;
167
+ }
168
+ function re(v, r, max = 1024) {
169
+ const s = str(v, 0, max);
170
+ if (!r.test(s)) fail();
171
+ return s;
172
+ }
173
+ function int(v, min, max) {
174
+ if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max) fail();
175
+ return v;
176
+ }
177
+ function bool(v) {
178
+ if (typeof v !== "boolean") fail();
179
+ return v;
180
+ }
181
+ function nullable(v, f) {
182
+ return v === null ? null : f(v);
183
+ }
184
+ function literal(v, lit) {
185
+ if (v !== lit) fail();
186
+ return lit;
187
+ }
188
+ function oneOf(v, list) {
189
+ if (typeof v !== "string" || !list.includes(v)) fail();
190
+ return v;
191
+ }
192
+ function baseUrlProblem(v) {
193
+ let u;
194
+ try {
195
+ u = new URL(v);
196
+ } catch {
197
+ return "must be an absolute URL";
198
+ }
199
+ const local = u.hostname === "localhost" || u.hostname === "127.0.0.1";
200
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && local)) return "must be https";
201
+ if (u.username || u.password || u.search || u.hash) return "no userinfo, query or fragment";
202
+ return null;
203
+ }
204
+ function baseUrl(v) {
205
+ const s = str(v, 1, 1024);
206
+ if (baseUrlProblem(s) !== null) fail();
207
+ return s;
208
+ }
209
+ function destinationUrlProblem(v) {
210
+ if (v.length > 200) return "must be at most 200 characters";
211
+ let u;
212
+ try {
213
+ u = new URL(v);
214
+ } catch {
215
+ return "must be an absolute URL";
216
+ }
217
+ if (u.protocol !== "https:") return "must use https";
218
+ if (u.username || u.password) return "must not contain credentials";
219
+ if (u.port !== "" && u.port !== "443") return "port must be 443";
220
+ const host = u.hostname;
221
+ if (host.startsWith("[") || /^\d+(\.\d+){3}$/.test(host)) return "IP addresses are not allowed";
222
+ if (!HOST_RE.test(host) || !host.includes(".") || host.startsWith(".") || host.endsWith("."))
223
+ return "invalid hostname";
224
+ if (u.toString() !== v || !URL_TAIL_RE.test(v.slice(u.origin.length))) return "must be normalised";
225
+ return null;
226
+ }
227
+ function destinationUrl(v) {
228
+ const s = str(v, 1, 200);
229
+ if (destinationUrlProblem(s) !== null) fail();
230
+ return s;
231
+ }
232
+ function parsePublicJwk(v) {
233
+ const o = obj(v, ["kty", "crv", "x", "y"]);
234
+ return {
235
+ kty: literal(o["kty"], "EC"),
236
+ crv: literal(o["crv"], "P-256"),
237
+ x: re(o["x"], COORD_RE),
238
+ y: re(o["y"], COORD_RE)
239
+ };
240
+ }
241
+ function image(v) {
242
+ const o = obj(v, ["src", "src2x", "alt", "width", "height"]);
243
+ return {
244
+ src: baseUrl(o["src"]),
245
+ src2x: baseUrl(o["src2x"]),
246
+ alt: str(o["alt"], 1, 120),
247
+ width: literal(o["width"], 640),
248
+ height: literal(o["height"], 360)
249
+ };
250
+ }
251
+ var BUSINESS_KEYS = [
252
+ "id",
253
+ "idx",
254
+ "name",
255
+ "description",
256
+ "url",
257
+ "domain",
258
+ "image",
259
+ "category",
260
+ "location",
261
+ "region",
262
+ "tags",
263
+ "pinned",
264
+ "position",
265
+ "instrumentation"
266
+ ];
267
+ function business(v) {
268
+ const o = obj(v, BUSINESS_KEYS);
269
+ const name = str(o["name"], 2, 60);
270
+ if (/^[=+@]/.test(name)) fail();
271
+ const domain = re(o["domain"], HOST_RE, 253);
272
+ if (domain.length < 1 || domain.startsWith("www.")) fail();
273
+ const tags = o["tags"];
274
+ if (!Array.isArray(tags) || tags.length > 10) fail();
275
+ return {
276
+ id: re(o["id"], SLUG_RE, SLUG_MAX),
277
+ idx: int(o["idx"], 1, 65535),
278
+ name,
279
+ description: str(o["description"], 1, 140),
280
+ url: destinationUrl(o["url"]),
281
+ domain,
282
+ image: image(o["image"]),
283
+ category: nullable(o["category"], (x) => str(x, 0, 40)),
284
+ location: nullable(o["location"], (x) => str(x, 0, 60)),
285
+ region: nullable(o["region"], (x) => str(x, 0, 40)),
286
+ tags: tags.map((t) => str(t, 0, 24)),
287
+ pinned: bool(o["pinned"]),
288
+ position: int(o["position"], 0, MAX_ITEMS_CEILING - 1),
289
+ instrumentation: oneOf(o["instrumentation"], ["none", "landings", "conversions"])
290
+ };
291
+ }
292
+ function signer(v) {
293
+ const o = obj(v, ["kid", "jwk", "notBefore", "notAfter", "rootKid", "rootSig", "revokedKids"]);
294
+ const revoked = o["revokedKids"];
295
+ if (!Array.isArray(revoked)) fail();
296
+ return {
297
+ kid: re(o["kid"], KID_RE),
298
+ jwk: parsePublicJwk(o["jwk"]),
299
+ notBefore: re(o["notBefore"], ISO_DATE_RE),
300
+ notAfter: re(o["notAfter"], ISO_DATE_RE),
301
+ rootKid: re(o["rootKid"], KID_RE),
302
+ rootSig: re(o["rootSig"], P1363_RE),
303
+ revokedKids: revoked.map((k) => re(k, KID_RE))
304
+ };
305
+ }
306
+ var MANIFEST_KEYS = [
307
+ "schema",
308
+ "revision",
309
+ "publishedAt",
310
+ "site",
311
+ "siteIndex",
312
+ "channel",
313
+ "cdnBase",
314
+ "minSdkVersion",
315
+ "analytics",
316
+ "links",
317
+ "section",
318
+ "businesses",
319
+ "signer",
320
+ "sig"
321
+ ];
322
+ function parseManifest(input) {
323
+ try {
324
+ const o = obj(input, MANIFEST_KEYS);
325
+ const a = obj(o["analytics"], [
326
+ "enabled",
327
+ "endpoint",
328
+ "impressions",
329
+ "clicks",
330
+ "landings",
331
+ "samplePermille",
332
+ "token"
333
+ ]);
334
+ const l = obj(o["links"], ["utmMedium", "utmCampaign", "utmSource", "refParam", "nofollow"]);
335
+ const sec = obj(o["section"], ["title", "subtitle"]);
336
+ const sig = obj(o["sig"], ["alg", "kid", "value"]);
337
+ const list = o["businesses"];
338
+ if (!Array.isArray(list) || list.length > MAX_ITEMS_CEILING) fail();
339
+ return {
340
+ schema: literal(o["schema"], MANIFEST_SCHEMA_MAJOR),
341
+ revision: int(o["revision"], 1, Number.MAX_SAFE_INTEGER),
342
+ publishedAt: re(o["publishedAt"], ISO_DATE_RE),
343
+ site: re(o["site"], SLUG_RE, SLUG_MAX),
344
+ siteIndex: int(o["siteIndex"], 1, 65535),
345
+ channel: oneOf(o["channel"], ["production", "preview"]),
346
+ cdnBase: baseUrl(o["cdnBase"]),
347
+ minSdkVersion: re(o["minSdkVersion"], /^\d+\.\d+\.\d+$/),
348
+ analytics: {
349
+ enabled: bool(a["enabled"]),
350
+ endpoint: baseUrl(a["endpoint"]),
351
+ impressions: bool(a["impressions"]),
352
+ clicks: bool(a["clicks"]),
353
+ landings: bool(a["landings"]),
354
+ samplePermille: int(a["samplePermille"], 0, 1e3),
355
+ token: re(a["token"], TOKEN_RE)
356
+ },
357
+ links: {
358
+ utmMedium: str(l["utmMedium"], 1, 40),
359
+ utmCampaign: str(l["utmCampaign"], 1, 40),
360
+ utmSource: re(l["utmSource"], SLUG_RE, SLUG_MAX),
361
+ refParam: re(l["refParam"], /^[a-z]{1,8}$/),
362
+ nofollow: bool(l["nofollow"])
363
+ },
364
+ section: {
365
+ title: str(sec["title"], 1, 60),
366
+ subtitle: nullable(sec["subtitle"], (x) => str(x, 0, 120))
367
+ },
368
+ businesses: list.map(business),
369
+ signer: signer(o["signer"]),
370
+ sig: {
371
+ alg: literal(sig["alg"], "ES256"),
372
+ kid: re(sig["kid"], KID_RE),
373
+ value: re(sig["value"], P1363_RE)
374
+ }
375
+ };
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
380
+
381
+ // src/canonicalJson.ts
382
+ var CanonicalJsonError = class extends Error {
383
+ constructor(message, path) {
384
+ super(`${message} at ${path || "$"}`);
385
+ this.path = path;
386
+ this.name = "CanonicalJsonError";
387
+ }
388
+ path;
389
+ };
390
+ function canonicalJson(value) {
391
+ return serialise(value, "$");
392
+ }
393
+ function serialise(v, path) {
394
+ if (v === null) return "null";
395
+ switch (typeof v) {
396
+ case "boolean":
397
+ return v ? "true" : "false";
398
+ case "string":
399
+ return JSON.stringify(v);
400
+ case "number":
401
+ if (!Number.isSafeInteger(v))
402
+ throw new CanonicalJsonError(`non-integer or unsafe number ${String(v)}`, path);
403
+ return String(v);
404
+ case "object": {
405
+ if (Array.isArray(v)) return `[${v.map((x, i) => serialise(x, `${path}[${i}]`)).join(",")}]`;
406
+ const proto = Object.getPrototypeOf(v);
407
+ if (proto !== Object.prototype && proto !== null)
408
+ throw new CanonicalJsonError("unsupported object type", path);
409
+ const obj2 = v;
410
+ return `{${Object.keys(obj2).sort().map((k) => {
411
+ const child = obj2[k];
412
+ if (child === void 0)
413
+ throw new CanonicalJsonError(`undefined value for key "${k}" (use null)`, path);
414
+ return `${JSON.stringify(k)}:${serialise(child, `${path}.${k}`)}`;
415
+ }).join(",")}}`;
416
+ }
417
+ default:
418
+ throw new CanonicalJsonError(`unsupported type ${typeof v}`, path);
419
+ }
420
+ }
421
+
422
+ // src/verify/base64url.ts
423
+ var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
424
+ var LOOKUP = {};
425
+ for (let i = 0; i < 64; i++) LOOKUP[ALPHABET[i]] = i;
426
+ function isBase64urlAlphabet(s) {
427
+ return /^[A-Za-z0-9_-]*$/.test(s);
428
+ }
429
+ function decodeCanonicalBase64url(s, bytes) {
430
+ if (typeof s !== "string" || !isBase64urlAlphabet(s)) return null;
431
+ if (s.length !== Math.ceil(bytes * 4 / 3)) return null;
432
+ const out = new Uint8Array(bytes);
433
+ let acc = 0;
434
+ let bits = 0;
435
+ let o = 0;
436
+ for (let i = 0; i < s.length; i++) {
437
+ acc = (acc << 6 | LOOKUP[s[i]]) & 65535;
438
+ bits += 6;
439
+ if (bits >= 8) {
440
+ bits -= 8;
441
+ if (o >= bytes) return null;
442
+ out[o++] = acc >> bits & 255;
443
+ }
444
+ }
445
+ if (o !== bytes) return null;
446
+ if (bits > 0 && (acc & (1 << bits) - 1) !== 0) return null;
447
+ return out;
448
+ }
449
+ function bytesToHex(b) {
450
+ let h = "";
451
+ for (const x of b) h += x.toString(16).padStart(2, "0");
452
+ return h;
453
+ }
454
+
455
+ // src/verify/p1363.ts
456
+ var P256_N = BigInt("0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551");
457
+ var HALF_N = P256_N >> 1n;
458
+ function subtle() {
459
+ const c = globalThis.crypto;
460
+ return c && c.subtle ? c.subtle : null;
461
+ }
462
+ function p1363Problem(signature) {
463
+ const sig = decodeCanonicalBase64url(signature, 64);
464
+ if (!sig) return "encoding";
465
+ const r = BigInt(`0x${bytesToHex(sig.subarray(0, 32))}`);
466
+ const s = BigInt(`0x${bytesToHex(sig.subarray(32))}`);
467
+ if (r === 0n || r >= P256_N || s === 0n || s > HALF_N) return "range";
468
+ return null;
469
+ }
470
+ var keyCache = /* @__PURE__ */ new WeakMap();
471
+ async function importKey(jwk, api) {
472
+ const hit = keyCache.get(jwk);
473
+ if (hit) return hit;
474
+ const { kty, crv, x, y } = jwk;
475
+ const p = api.importKey(
476
+ "jwk",
477
+ { ...parsePublicJwk({ kty, crv, x, y }), ext: true },
478
+ { name: "ECDSA", namedCurve: "P-256" },
479
+ false,
480
+ ["verify"]
481
+ );
482
+ keyCache.set(jwk, p);
483
+ return p;
484
+ }
485
+ async function verifyP1363(jwk, data, signature) {
486
+ try {
487
+ const api = subtle();
488
+ if (!api) return false;
489
+ if (p1363Problem(signature) !== null) return false;
490
+ const sig = decodeCanonicalBase64url(signature, 64);
491
+ const key = await importKey(jwk, api);
492
+ return await api.verify(
493
+ { name: "ECDSA", hash: "SHA-256" },
494
+ key,
495
+ sig,
496
+ new TextEncoder().encode(data)
497
+ );
498
+ } catch {
499
+ return false;
500
+ }
501
+ }
502
+
503
+ // src/verify/verifyManifest.ts
504
+ function certificatePayload(s) {
505
+ return canonicalJson({
506
+ kid: s.kid,
507
+ jwk: { kty: s.jwk.kty, crv: s.jwk.crv, x: s.jwk.x, y: s.jwk.y },
508
+ notBefore: s.notBefore,
509
+ notAfter: s.notAfter,
510
+ rootKid: s.rootKid
511
+ });
512
+ }
513
+ function signedPayload(m) {
514
+ const { sig: _sig, ...payload } = m;
515
+ return canonicalJson(payload);
516
+ }
517
+ var isKidList = (v) => Array.isArray(v) && v.every((k) => typeof k === "string");
518
+ function parseIso(s) {
519
+ const t = Date.parse(s);
520
+ if (!new Date(t).toISOString().startsWith(s.slice(0, 19))) fail();
521
+ return t;
522
+ }
523
+ async function verifyManifest(input, opts) {
524
+ try {
525
+ if (!opts || typeof opts !== "object") return { ok: false, reason: "malformed" };
526
+ const t = opts.now instanceof Date ? opts.now.getTime() : NaN;
527
+ if (!Number.isFinite(t)) return { ok: false, reason: "malformed" };
528
+ if (opts.source !== "live" && opts.source !== "fallback")
529
+ return { ok: false, reason: "malformed" };
530
+ if (!opts.expect || typeof opts.expect.site !== "string" || typeof opts.expect.channel !== "string")
531
+ return { ok: false, reason: "malformed" };
532
+ if (opts.source === "live" && !isKidList(opts.revokedKids))
533
+ return { ok: false, reason: "malformed" };
534
+ if (opts.revokedKids !== void 0 && !isKidList(opts.revokedKids))
535
+ return { ok: false, reason: "malformed" };
536
+ const m = parseManifest(input);
537
+ if (!m) return { ok: false, reason: "malformed" };
538
+ if (m.site !== opts.expect.site || m.channel !== opts.expect.channel || m.links.utmSource !== opts.expect.site)
539
+ return { ok: false, reason: "identity" };
540
+ const s = m.signer;
541
+ const roots = Array.isArray(opts.roots) ? opts.roots : [];
542
+ const root = roots.find((r) => r && r.kid === s.rootKid);
543
+ if (!root) return { ok: false, reason: "root_unknown" };
544
+ if (!await verifyP1363(root.jwk, certificatePayload(s), s.rootSig))
545
+ return { ok: false, reason: "root_sig" };
546
+ const notBefore = parseIso(s.notBefore);
547
+ const notAfter = parseIso(s.notAfter);
548
+ if (notAfter < notBefore) return { ok: false, reason: "malformed" };
549
+ if (opts.source === "live" && (t < notBefore || t > notAfter + SIGNER_GRACE_MS))
550
+ return { ok: false, reason: "window" };
551
+ if (m.sig.kid !== s.kid) return { ok: false, reason: "kid_mismatch" };
552
+ if (!await verifyP1363(s.jwk, signedPayload(m), m.sig.value))
553
+ return { ok: false, reason: "sig" };
554
+ if ((opts.revokedKids ?? []).includes(m.sig.kid) || s.revokedKids.includes(m.sig.kid))
555
+ return { ok: false, reason: "revoked" };
556
+ return { ok: true, manifest: m };
557
+ } catch {
558
+ return { ok: false, reason: "malformed" };
559
+ }
560
+ }
561
+ async function verifyManifestBytes(raw, opts) {
562
+ try {
563
+ let text;
564
+ if (typeof raw === "string") {
565
+ if (raw.length > MANIFEST_MAX_BYTES || new TextEncoder().encode(raw).length > MANIFEST_MAX_BYTES)
566
+ return { ok: false, reason: "malformed" };
567
+ text = raw;
568
+ } else {
569
+ if (!(raw instanceof Uint8Array) || raw.length > MANIFEST_MAX_BYTES)
570
+ return { ok: false, reason: "malformed" };
571
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
572
+ }
573
+ const parsed = JSON.parse(text);
574
+ if (canonicalJson(parsed) !== text) return { ok: false, reason: "malformed" };
575
+ return await verifyManifest(parsed, opts);
576
+ } catch {
577
+ return { ok: false, reason: "malformed" };
578
+ }
579
+ }
580
+
581
+ // src/cli/shared.ts
582
+ var CliError = class extends Error {
583
+ constructor(message, exitCode = 1) {
584
+ super(message);
585
+ this.exitCode = exitCode;
586
+ }
587
+ exitCode;
588
+ };
589
+ function requireWebCrypto() {
590
+ if (subtle() === null)
591
+ throw new CliError("WebCrypto unavailable (Node >= 18 with webcrypto required)");
592
+ }
593
+ function parseArgs(argv) {
594
+ const flags = {};
595
+ const positional = [];
596
+ for (let i = 0; i < argv.length; i++) {
597
+ const a = argv[i];
598
+ if (!a.startsWith("--")) {
599
+ positional.push(a);
600
+ continue;
601
+ }
602
+ const eq = a.indexOf("=");
603
+ if (eq > 0) {
604
+ flags[a.slice(2, eq)] = a.slice(eq + 1);
605
+ continue;
606
+ }
607
+ const key = a.slice(2);
608
+ const next = argv[i + 1];
609
+ if (next !== void 0 && !next.startsWith("--")) {
610
+ flags[key] = next;
611
+ i++;
612
+ } else flags[key] = true;
613
+ }
614
+ return { positional, flags };
615
+ }
616
+ var str2 = (v) => typeof v === "string" && v.length > 0 ? v : void 0;
617
+ var log = {
618
+ info: (m) => console.log(`network-sdk: ${m}`),
619
+ ok: (m) => console.log(`network-sdk: \u2713 ${m}`),
620
+ warn: (m) => console.warn(`network-sdk: warning: ${m}`),
621
+ error: (m) => console.error(`network-sdk: error: ${m}`)
622
+ };
623
+ function projectRoot(flags) {
624
+ const root = resolve(str2(flags["root"]) ?? process.cwd());
625
+ if (!existsSync(join(root, "package.json")))
626
+ throw new CliError(
627
+ `no package.json in ${root} (run from the site's frontend directory or pass --root <dir>)`
628
+ );
629
+ return root;
630
+ }
631
+ function readJsonFile(path) {
632
+ try {
633
+ return JSON.parse(readFileSync(path, "utf8"));
634
+ } catch {
635
+ return null;
636
+ }
637
+ }
638
+ function writeFileAtomic(path, content) {
639
+ mkdirSync(dirname(path), { recursive: true });
640
+ const tmp = `${path}.tmp-${process.pid}`;
641
+ writeFileSync(tmp, content);
642
+ renameSync(tmp, path);
643
+ }
644
+ function resolveRootKeys(flags) {
645
+ const raw = str2(flags["root-keys"]);
646
+ if (!raw) {
647
+ if (rootPublicKeys.length === 0)
648
+ throw new CliError(
649
+ `no production root key is compiled into this SDK version; pass --root-keys '[{"kid":\u2026,"jwk":{\u2026}}]' (the hub's MANIFEST_ROOT_PUBLIC_KEYS value)`
650
+ );
651
+ return { roots: [...rootPublicKeys], explicit: false };
652
+ }
653
+ let json;
654
+ try {
655
+ json = JSON.parse(raw);
656
+ } catch {
657
+ throw new CliError("--root-keys must be a JSON array of { kid, jwk }");
658
+ }
659
+ if (!Array.isArray(json) || json.length === 0 || json.length > 8)
660
+ throw new CliError("--root-keys must be a non-empty JSON array of { kid, jwk }");
661
+ const roots = [];
662
+ for (const e of json) {
663
+ if (!e || typeof e !== "object")
664
+ throw new CliError("--root-keys: each entry must be { kid, jwk }");
665
+ const { kid, jwk } = e;
666
+ if (typeof kid !== "string" || !KID_RE.test(kid))
667
+ throw new CliError(`--root-keys: invalid kid ${String(kid)}`);
668
+ let parsed;
669
+ try {
670
+ parsed = parsePublicJwk(jwk);
671
+ } catch {
672
+ throw new CliError(`--root-keys: invalid jwk for ${kid}`);
673
+ }
674
+ if (roots.some((r) => r.kid === kid)) throw new CliError(`--root-keys: duplicate kid ${kid}`);
675
+ roots.push({ kid, jwk: parsed });
676
+ }
677
+ return { roots, explicit: true };
678
+ }
679
+ function readConfigLiterals(root) {
680
+ const path = join(root, CONFIG_PATH);
681
+ const out = {
682
+ path,
683
+ exists: existsSync(path),
684
+ siteKey: null,
685
+ channel: null,
686
+ cdnBase: null,
687
+ manifestUrl: null,
688
+ consentMode: null,
689
+ manifestRootKeys: null
690
+ };
691
+ if (!out.exists) return out;
692
+ const text = readFileSync(path, "utf8");
693
+ const lit = (key, re2) => {
694
+ const line = text.match(new RegExp(`\\b${key}\\s*:([^\\n]*)`));
695
+ if (!line) return null;
696
+ const m = line[1].match(re2);
697
+ return m ? m[1] : null;
698
+ };
699
+ out.siteKey = lit("siteKey", /['"]([a-z0-9]+(?:-[a-z0-9]+)*)['"]/);
700
+ const ch = lit("channel", /['"](production|preview)['"]/);
701
+ out.channel = ch === "preview" || ch === "production" ? ch : null;
702
+ out.cdnBase = lit("cdnBase", /['"](https?:\/\/[^'"]+)['"]/);
703
+ out.manifestUrl = lit("manifestUrl", /['"](https?:\/\/[^'"]+)['"]/);
704
+ out.consentMode = lit("consentMode", /['"]([a-z-]+)['"]/);
705
+ out.manifestRootKeys = lit("manifestRootKeys", /(\[.*\])/);
706
+ return out;
707
+ }
708
+ async function downloadManifest(url, cache = "no-cache") {
709
+ const ac = new AbortController();
710
+ const timer = setTimeout(() => ac.abort(), 1e4);
711
+ try {
712
+ const res = await fetch(url, {
713
+ cache,
714
+ signal: ac.signal,
715
+ headers: { "Cache-Control": "no-cache" }
716
+ });
717
+ if (!res.ok)
718
+ return { ok: false, kind: "http", status: res.status, detail: `HTTP ${res.status}`, url };
719
+ const buf = new Uint8Array(await res.arrayBuffer());
720
+ if (buf.length > MANIFEST_MAX_BYTES)
721
+ return { ok: false, kind: "size", detail: `${buf.length} bytes > ${MANIFEST_MAX_BYTES}`, url };
722
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buf);
723
+ return { ok: true, text, bytes: buf.length, status: res.status, headers: res.headers, url };
724
+ } catch (err) {
725
+ return {
726
+ ok: false,
727
+ kind: "network",
728
+ detail: err instanceof Error ? err.message : String(err),
729
+ url
730
+ };
731
+ } finally {
732
+ clearTimeout(timer);
733
+ }
734
+ }
735
+ function verifyDownloaded(text, roots, expect, revokedKids) {
736
+ return verifyManifestBytes(text, { roots, now: /* @__PURE__ */ new Date(), expect, source: "live", revokedKids });
737
+ }
738
+ function verifyCommitted(fb, roots, expect) {
739
+ return verifyManifest(fb, { roots, now: /* @__PURE__ */ new Date(), expect, source: "fallback" });
740
+ }
741
+ function readFallback(root) {
742
+ const path = join(root, FALLBACK_PATH);
743
+ const exists = existsSync(path);
744
+ return { path, exists, json: exists ? readJsonFile(path) : null };
745
+ }
746
+ function aliasUrl(base, site, channel) {
747
+ return manifestUrlFor(base ?? DEFAULT_CDN_BASE, site, channel);
748
+ }
749
+ var NO_MANIFEST_MESSAGE = (siteKey) => `site ${siteKey} has no published manifest; Publish in the admin first`;
750
+ function fallbackFileContent(parsed) {
751
+ return `${JSON.stringify(parsed, null, 2)}
752
+ `;
753
+ }
754
+ function daysBetween(a, b) {
755
+ return Math.round((b.getTime() - a.getTime()) / 864e5);
756
+ }
757
+
758
+ // src/cli/doctorBackend.ts
759
+ var BACKEND_ENV = ["INR_HUB_URL", "INR_SITE_KEY", "INR_KEY_ID", "INR_SITE_SECRET"];
760
+ var BACKEND_EXT = /* @__PURE__ */ new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
761
+ var PING_TIMEOUT_MS = 5e3;
762
+ function readEnvFile(path) {
763
+ const out = {};
764
+ if (!existsSync2(path)) return out;
765
+ for (const line of readFileSync2(path, "utf8").split(/\r?\n/)) {
766
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z0-9_]+)\s*=\s*(.*)$/);
767
+ if (!m) continue;
768
+ const v = m[2].trim();
769
+ out[m[1]] = /^(['"]).*\1$/.test(v) ? v.slice(1, -1) : v;
770
+ }
771
+ return out;
772
+ }
773
+ function envValues(dir, names) {
774
+ const file = readEnvFile(join2(dir, ".env"));
775
+ return Object.fromEntries(names.map((n) => [n, process.env[n] || file[n] || ""]));
776
+ }
777
+ var VALID = {
778
+ INR_HUB_URL: (v) => hubOrigin(v) !== null,
779
+ INR_SITE_KEY: (v) => SITE_KEY_RE.test(v),
780
+ INR_KEY_ID: (v) => KEY_ID_RE.test(v),
781
+ INR_SITE_SECRET: (v) => SITE_SECRET_RE.test(v)
782
+ };
783
+ function backendEnvFinding(env, where) {
784
+ const state = BACKEND_ENV.map((n) => {
785
+ const v = env[n] ?? "";
786
+ return [n, !v ? "unset" : VALID[n](v) ? "set" : "set (invalid)"];
787
+ });
788
+ const complete = state.every(([, s]) => s === "set");
789
+ return {
790
+ complete,
791
+ finding: {
792
+ level: complete ? "ok" : "fail",
793
+ text: `backend env (${where}): ${state.map(([n, s]) => `${n}=${s}`).join(", ")}`
794
+ }
795
+ };
796
+ }
797
+ var transportCode = (err) => {
798
+ const e = err;
799
+ if (e?.name === "AbortError" || e?.name === "TimeoutError")
800
+ return `timeout (${PING_TIMEOUT_MS / 1e3} s)`;
801
+ return typeof e?.cause?.code === "string" ? e.cause.code : "connection failed";
802
+ };
803
+ async function signedPing(env) {
804
+ const label = `signed POST ${SIGNED_PATHS.ping}`;
805
+ const hub = hubOrigin(env.INR_HUB_URL);
806
+ const body = "{}";
807
+ const { headers } = signRequest({
808
+ secret: env.INR_SITE_SECRET,
809
+ siteKey: env.INR_SITE_KEY,
810
+ keyId: env.INR_KEY_ID,
811
+ method: "POST",
812
+ path: SIGNED_PATHS.ping,
813
+ body
814
+ });
815
+ const ac = new AbortController();
816
+ const timer = setTimeout(() => ac.abort(), PING_TIMEOUT_MS);
817
+ const tSend = Date.now();
818
+ try {
819
+ const res = await fetch(hub + SIGNED_PATHS.ping, {
820
+ method: "POST",
821
+ headers: { ...headers, "User-Agent": `network-sdk-doctor/${SDK_VERSION}` },
822
+ body,
823
+ redirect: "error",
824
+ signal: ac.signal
825
+ });
826
+ const tRecv = Date.now();
827
+ let code = null;
828
+ try {
829
+ const json = JSON.parse((await res.text()).slice(0, 8192));
830
+ const c = json?.error?.code ?? json?.code;
831
+ code = typeof c === "string" ? c : null;
832
+ } catch {
833
+ code = null;
834
+ }
835
+ const s = res.status;
836
+ if (s === 204) return { level: "ok", text: `${label}: 204 (key id and secret accepted)` };
837
+ if (s === 401) return { level: "fail", text: `${label}: 401, key id or secret rejected` };
838
+ if (s === 400 && code === "TIMESTAMP_SKEW") {
839
+ const offset = estimateOffsetMs(res.headers.get("date"), tSend, tRecv);
840
+ return {
841
+ level: "fail",
842
+ text: `${label}: TIMESTAMP_SKEW, this host's clock is ${offset === null ? "off by an unknown amount" : `${Math.abs(Math.round(offset / 1e3))} s ${offset > 0 ? "behind" : "ahead of"} the hub`} (fix NTP)`
843
+ };
844
+ }
845
+ if (s === 404) return { level: "fail", text: `${label}: 404, wrong INR_HUB_URL?` };
846
+ if (s === 429 || s === 503)
847
+ return { level: "warn", text: `${label}: ${s}${code ? ` ${code}` : ""}, retry later` };
848
+ return { level: "fail", text: `${label}: HTTP ${s}${code ? ` ${code}` : ""}` };
849
+ } catch (err) {
850
+ return { level: "fail", text: `${label}: network error (${transportCode(err)})` };
851
+ } finally {
852
+ clearTimeout(timer);
853
+ }
854
+ }
855
+ async function checkOutboxIndexes(root, uri, collection) {
856
+ const label = `outbox indexes (${collection})`;
857
+ let driver;
858
+ try {
859
+ driver = createRequire(join2(root, "package.json"))("mongodb");
860
+ } catch {
861
+ return { level: "fail", text: `${label}: the mongodb driver is not installed under --root` };
862
+ }
863
+ const client = new driver.MongoClient(uri, { serverSelectionTimeoutMS: 5e3 });
864
+ try {
865
+ await client.connect();
866
+ const found = await client.db().collection(collection).indexes();
867
+ const missing = OUTBOX_INDEXES.filter(([key, options]) => {
868
+ const ix = found.find((i) => JSON.stringify(i.key) === JSON.stringify(key));
869
+ return !ix || ix.expireAfterSeconds !== options["expireAfterSeconds"] || Boolean(ix.unique) !== Boolean(options["unique"]);
870
+ });
871
+ if (missing.length === 0)
872
+ return {
873
+ level: "ok",
874
+ text: `${label}: createdAt TTL 14 d, nextAttemptAt, unique (eventType, eventId)`
875
+ };
876
+ return {
877
+ level: "fail",
878
+ text: `${label}: missing or different: ${missing.map(([k]) => Object.keys(k).join("+")).join(", ")} (start the backend once with the reporter enabled; it creates them)`
879
+ };
880
+ } catch (err) {
881
+ const e = err;
882
+ const why = typeof e?.codeName === "string" ? e.codeName : String(e?.name ?? "error");
883
+ return { level: "fail", text: `${label}: ${why}` };
884
+ } finally {
885
+ await client.close().catch(() => void 0);
886
+ }
887
+ }
888
+ async function backendFindings(root, flags, scan) {
889
+ const out = [];
890
+ const add = (level, text) => out.push({ level, text });
891
+ const major = Number(process.versions.node.split(".")[0]);
892
+ add(major >= 18 ? "ok" : "fail", `Node ${process.versions.node}${major >= 18 ? "" : " (< 18)"}`);
893
+ const ids = scan.identifiers;
894
+ add(
895
+ ids.createConversionReporter.length > 0 ? "ok" : "fail",
896
+ `createConversionReporter() (server entry): ${ids.createConversionReporter.join(", ") || "missing"}`
897
+ );
898
+ const ref = [.../* @__PURE__ */ new Set([...ids.referralFromRequest, ...ids.referralMiddleware])];
899
+ add(
900
+ ref.length > 0 ? "ok" : "fail",
901
+ `referralFromRequest() / referralMiddleware(): ${ref.join(", ") || "missing"}`
902
+ );
903
+ add(
904
+ "ok",
905
+ `${scan.files} source file(s) scanned (node_modules, dist, build, coverage, .git skipped)`
906
+ );
907
+ const pkgPath = join2(root, "package.json");
908
+ if (!existsSync2(pkgPath)) add("skip", "package.json: none (optional for --backend)");
909
+ else {
910
+ let dep;
911
+ try {
912
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
913
+ dep = pkg.dependencies?.["@intenque/network-sdk"];
914
+ } catch {
915
+ add("warn", "package.json is not valid JSON");
916
+ }
917
+ if (!dep) add("warn", `@intenque/network-sdk is not a dependency (CLI ${SDK_VERSION})`);
918
+ else if (/^(file|link):/.test(dep))
919
+ add("warn", `@intenque/network-sdk ${dep}: a local path; a deploy build cannot install it`);
920
+ else add("ok", `@intenque/network-sdk ${dep} (CLI ${SDK_VERSION})`);
921
+ }
922
+ const env = envValues(root, [...BACKEND_ENV, "INR_OUTBOX_COLLECTION"]);
923
+ const { finding, complete } = backendEnvFinding(
924
+ env,
925
+ existsSync2(join2(root, ".env")) ? "process env, .env" : "process env"
926
+ );
927
+ out.push(finding);
928
+ if (complete) out.push(await signedPing(env));
929
+ else add("skip", `signed POST ${SIGNED_PATHS.ping}: needs all four INR_* values`);
930
+ const uri = str2(flags["mongo-uri"]);
931
+ if (!uri) add("skip", "outbox indexes: pass --mongo-uri <uri> to check them");
932
+ else
933
+ out.push(
934
+ await checkOutboxIndexes(
935
+ root,
936
+ uri,
937
+ env["INR_OUTBOX_COLLECTION"] || OUTBOX_DEFAULT_COLLECTION
938
+ )
939
+ );
940
+ return out;
941
+ }
942
+
943
+ // src/cli/doctor.ts
944
+ function doctorImpressionBody(m, now = Date.now(), pv = randomUUID2()) {
945
+ const t = Math.round(now);
946
+ return {
947
+ v: 2,
948
+ k: m.site,
949
+ pv,
950
+ r: m.revision,
951
+ src: "doctor",
952
+ sdk: SDK_VERSION,
953
+ tok: m.analytics.token,
954
+ sp: m.analytics.samplePermille,
955
+ smp: m.analytics.samplePermille > 0 ? 1 : 0,
956
+ t,
957
+ hd: 0,
958
+ m: t,
959
+ items: [],
960
+ ss: false,
961
+ sm: 0,
962
+ vp: { w: 0, h: 0 },
963
+ why: "timer"
964
+ };
965
+ }
966
+ async function sendDoctorImpression(m, timeoutMs = 5e3) {
967
+ const url = `${m.analytics.endpoint.replace(/\/+$/, "")}/impression`;
968
+ const ac = new AbortController();
969
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
970
+ try {
971
+ const res = await fetch(url, {
972
+ method: "POST",
973
+ headers: {
974
+ "content-type": "text/plain;charset=UTF-8",
975
+ "user-agent": `network-sdk-doctor/${SDK_VERSION}`
976
+ },
977
+ body: JSON.stringify(doctorImpressionBody(m)),
978
+ signal: ac.signal
979
+ });
980
+ return { url, status: res.status };
981
+ } catch (err) {
982
+ return { url, error: err instanceof Error ? err.message : String(err) };
983
+ } finally {
984
+ clearTimeout(timer);
985
+ }
986
+ }
987
+ var EXT = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"]);
988
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", ".git", "coverage"]);
989
+ function listSourceFiles(root, limit = 5e3, ext = EXT) {
990
+ const out = [];
991
+ const walk = (dir) => {
992
+ if (out.length >= limit) return;
993
+ let entries;
994
+ try {
995
+ entries = readdirSync(dir);
996
+ } catch {
997
+ return;
998
+ }
999
+ for (const name of entries) {
1000
+ if (SKIP_DIRS.has(name)) continue;
1001
+ const p = join3(dir, name);
1002
+ let st;
1003
+ try {
1004
+ st = statSync(p);
1005
+ } catch {
1006
+ continue;
1007
+ }
1008
+ if (st.isDirectory()) walk(p);
1009
+ else if (ext.has(name.slice(name.lastIndexOf("."))) && out.length < limit) out.push(p);
1010
+ }
1011
+ };
1012
+ walk(root);
1013
+ return out;
1014
+ }
1015
+ var IDENTIFIERS = [
1016
+ "DiscoverBusinesses",
1017
+ "NetworkProvider",
1018
+ "captureReferral",
1019
+ "setConsent",
1020
+ "attachReferral",
1021
+ "attachReferralHeader",
1022
+ "createConversionReporter",
1023
+ "referralFromRequest",
1024
+ "referralMiddleware"
1025
+ ];
1026
+ function scanSources(root, files) {
1027
+ const scan = {
1028
+ imports: { react: [], capture: [], server: [] },
1029
+ identifiers: Object.fromEntries(IDENTIFIERS.map((i) => [i, []])),
1030
+ headerMode: false
1031
+ };
1032
+ for (const f of files) {
1033
+ let text;
1034
+ try {
1035
+ text = readFileSync3(f, "utf8");
1036
+ } catch {
1037
+ continue;
1038
+ }
1039
+ if (!text.includes("@intenque/network-sdk") && !IDENTIFIERS.some((i) => text.includes(i)))
1040
+ continue;
1041
+ const rel = relative(root, f);
1042
+ for (const entry of ["react", "capture", "server"])
1043
+ if (new RegExp(`['"]@intenque/network-sdk/${entry}['"]`).test(text))
1044
+ scan.imports[entry].push(rel);
1045
+ for (const id of IDENTIFIERS)
1046
+ if (new RegExp(`\\b${id}\\b`).test(text)) scan.identifiers[id].push(rel);
1047
+ if (/attachReferralHeader\s*\(/.test(text) || /mode\s*:\s*['"]header['"]/.test(text))
1048
+ scan.headerMode = true;
1049
+ }
1050
+ return scan;
1051
+ }
1052
+ function gtagConfigsWithoutPageLocation(htmlPath) {
1053
+ if (!existsSync3(htmlPath)) return null;
1054
+ let text;
1055
+ try {
1056
+ text = readFileSync3(htmlPath, "utf8");
1057
+ } catch {
1058
+ return null;
1059
+ }
1060
+ const configs = [...text.matchAll(/gtag\(\s*['"]config['"]/g)];
1061
+ if (configs.length === 0) return null;
1062
+ let naked = 0;
1063
+ for (const m of configs) {
1064
+ const start = m.index ?? 0;
1065
+ const end = text.indexOf(";", start);
1066
+ const statement = text.slice(start, end === -1 ? void 0 : end);
1067
+ if (!/\bpage_location\b/.test(statement)) naked++;
1068
+ }
1069
+ return naked;
1070
+ }
1071
+ function envPresence(dir, names) {
1072
+ return Object.fromEntries(Object.entries(envValues(dir, names)).map(([k, v]) => [k, v !== ""]));
1073
+ }
1074
+ async function runBackendDoctor(flags) {
1075
+ const root = resolve2(str2(flags["root"]) ?? process.cwd());
1076
+ const files = listSourceFiles(root, 5e3, BACKEND_EXT);
1077
+ const scan = scanSources(root, files);
1078
+ const findings = await backendFindings(root, flags, {
1079
+ files: files.length,
1080
+ identifiers: {
1081
+ createConversionReporter: scan.identifiers["createConversionReporter"],
1082
+ referralFromRequest: scan.identifiers["referralFromRequest"],
1083
+ referralMiddleware: scan.identifiers["referralMiddleware"]
1084
+ }
1085
+ });
1086
+ return finish(findings);
1087
+ }
1088
+ async function runDoctor(flags) {
1089
+ if (flags["backend"] === true) return runBackendDoctor(flags);
1090
+ const root = projectRoot(flags);
1091
+ const findings = [];
1092
+ const add = (level, text) => findings.push({ level, text });
1093
+ const backend = flags["frontend"] !== true;
1094
+ const files = listSourceFiles(join3(root, existsSync3(join3(root, "src")) ? "src" : "."));
1095
+ const scan = scanSources(root, files);
1096
+ const report = (label, paths, required) => {
1097
+ if (paths.length > 0) add("ok", `${label}: ${paths.join(", ")}`);
1098
+ else add(required ? "fail" : "warn", `${label}: missing`);
1099
+ };
1100
+ report("<DiscoverBusinesses /> (react entry)", scan.identifiers["DiscoverBusinesses"], true);
1101
+ report("captureReferral() (capture entry, main.jsx)", scan.identifiers["captureReferral"], true);
1102
+ report("setConsent() in the consent banner", scan.identifiers["setConsent"], false);
1103
+ report(
1104
+ `attachReferral() on the API client (${scan.headerMode ? "HEADER mode" : "body mode"})`,
1105
+ [...scan.identifiers["attachReferral"], ...scan.identifiers["attachReferralHeader"]],
1106
+ false
1107
+ );
1108
+ const naked = gtagConfigsWithoutPageLocation(join3(root, "index.html"));
1109
+ if (naked !== null)
1110
+ add(
1111
+ naked > 0 ? "warn" : "ok",
1112
+ naked > 0 ? `index.html: gtag('config') without page_location (${naked}): GA reads location.href before captureReferral strips inr; pass page_location with the inr parameter removed (install guide, GA step)` : "index.html: gtag('config') passes page_location (inr stripped before GA reads the URL)"
1113
+ );
1114
+ if (backend) {
1115
+ const found = scan.identifiers["createConversionReporter"];
1116
+ if (found.length > 0)
1117
+ add("ok", `createConversionReporter() (server entry): ${found.join(", ")}`);
1118
+ else
1119
+ add(
1120
+ "skip",
1121
+ "createConversionReporter(): not found (doctor --backend in a split backend; required for capabilities.conversions sites)"
1122
+ );
1123
+ }
1124
+ const cfg = readConfigLiterals(root);
1125
+ if (!cfg.exists) {
1126
+ add("fail", `${relative(root, cfg.path)} missing: run network-sdk init --site-key <key>`);
1127
+ return finish(findings);
1128
+ }
1129
+ const siteKey = str2(flags["site-key"]) ?? cfg.siteKey;
1130
+ if (!siteKey) add("fail", `${relative(root, cfg.path)} has no literal siteKey`);
1131
+ else add("ok", `siteKey ${siteKey} (${relative(root, cfg.path)})`);
1132
+ const channel = flags["channel"] === "preview" ? "preview" : cfg.channel ?? "production";
1133
+ const fe = envPresence(root, ["VITE_INR_SITE_KEY", "VITE_INR_CHANNEL", "VITE_INR_MANIFEST_URL"]);
1134
+ add(
1135
+ "ok",
1136
+ `frontend env: ${Object.entries(fe).map(([k, v]) => `${k}=${v ? "set" : "unset"}`).join(", ")} (optional overrides)`
1137
+ );
1138
+ if (fe["VITE_INR_MANIFEST_URL"])
1139
+ add("warn", "VITE_INR_MANIFEST_URL is set: staging-hub override; never for a production site");
1140
+ let backendEnv = null;
1141
+ if (backend) {
1142
+ const dir = existsSync3(join3(root, "backend")) ? join3(root, "backend") : existsSync3(join3(root, "..", "backend")) ? join3(root, "..", "backend") : root;
1143
+ const env = envValues(dir, BACKEND_ENV);
1144
+ const where = relative(root, dir) || ".";
1145
+ if (Object.values(env).every((v) => v === ""))
1146
+ add("skip", `backend env not set in ${where} (run doctor --backend in the backend)`);
1147
+ else {
1148
+ const { finding, complete } = backendEnvFinding(env, where);
1149
+ findings.push(finding);
1150
+ if (complete) backendEnv = env;
1151
+ }
1152
+ }
1153
+ let roots;
1154
+ try {
1155
+ const f = !str2(flags["root-keys"]) && cfg.manifestRootKeys ? { ...flags, "root-keys": cfg.manifestRootKeys } : flags;
1156
+ roots = resolveRootKeys(f).roots;
1157
+ add(
1158
+ f === flags ? "ok" : "warn",
1159
+ `root keys: ${roots.map((r) => r.kid).join(", ")}${f === flags ? "" : " (from manifestRootKeys in network.config.js: a development override, not the compiled production root)"}`
1160
+ );
1161
+ } catch (err) {
1162
+ add("fail", err instanceof Error ? err.message : String(err));
1163
+ return finish(findings);
1164
+ }
1165
+ if (!siteKey) return finish(findings);
1166
+ const expect = { site: siteKey, channel };
1167
+ const fb = readFallback(root);
1168
+ let trusted = null;
1169
+ if (!fb.exists) add("fail", `${FALLBACK_PATH} missing (run network-sdk sync-fallback)`);
1170
+ else if (fb.json === null) add("fail", `${FALLBACK_PATH} is not valid JSON`);
1171
+ else {
1172
+ const r = await verifyCommitted(fb.json, roots, expect);
1173
+ const site = fb.json.site;
1174
+ if (site !== siteKey)
1175
+ add(
1176
+ "fail",
1177
+ `${FALLBACK_PATH} site ${String(site)} \u2260 ${siteKey} (\xA74.6: sync-fallback overwrites it; commit the result)`
1178
+ );
1179
+ else if (!r.ok) add("fail", `${FALLBACK_PATH} fails the identity check: ${r.reason}`);
1180
+ else {
1181
+ trusted = r.manifest;
1182
+ const age = daysBetween(new Date(trusted.publishedAt), /* @__PURE__ */ new Date());
1183
+ const left = daysBetween(/* @__PURE__ */ new Date(), new Date(trusted.signer.notAfter));
1184
+ add(
1185
+ "ok",
1186
+ `fallback: revision ${trusted.revision}, ${trusted.businesses.length} items, published ${trusted.publishedAt} (${age} d ago), signer ${trusted.sig.kid} valid until ${trusted.signer.notAfter}`
1187
+ );
1188
+ if (left < 60)
1189
+ add(
1190
+ "warn",
1191
+ `fallback signer notAfter is in ${left} d (< 60 d): redeploy after the next publish so sync-fallback refreshes it`
1192
+ );
1193
+ }
1194
+ }
1195
+ const url = str2(flags["url"]) ? aliasUrl(str2(flags["url"]), siteKey, channel) : cfg.manifestUrl ?? aliasUrl(cfg.cdnBase ?? void 0, siteKey, channel);
1196
+ let live = null;
1197
+ const dl = await downloadManifest(url);
1198
+ if (!dl.ok) add("fail", `manifest fetch failed: ${dl.detail} (${url})`);
1199
+ else {
1200
+ const h = (n) => dl.headers.get(n) ?? "\u2014";
1201
+ add(
1202
+ "ok",
1203
+ `manifest ${url}: HTTP ${dl.status}, ${dl.bytes} bytes${dl.bytes > 10 * 1024 ? " (> 10 KB target)" : ""}`
1204
+ );
1205
+ add(
1206
+ "ok",
1207
+ `headers: content-type ${h("content-type")}; cache-control ${h("cache-control")}; x-amz-meta-revision ${h("x-amz-meta-revision")}; x-amz-meta-manifest-hash ${h("x-amz-meta-manifest-hash").slice(0, 16)}\u2026; age ${h("age")}`
1208
+ );
1209
+ const v = await verifyDownloaded(dl.text, roots, expect, trusted?.signer.revokedKids ?? []);
1210
+ if (!v.ok) add("fail", `signature: ${v.reason}`);
1211
+ else {
1212
+ const m = v.manifest;
1213
+ live = m;
1214
+ add(
1215
+ "ok",
1216
+ `signature OK: revision ${m.revision}, ${m.businesses.length} items, publishedAt ${m.publishedAt}, signer ${m.sig.kid} (root ${m.signer.rootKid}), minSdkVersion ${m.minSdkVersion}, analytics.enabled ${m.analytics.enabled}`
1217
+ );
1218
+ if (trusted && trusted.revision < m.revision)
1219
+ add(
1220
+ "warn",
1221
+ `fallback revision ${trusted.revision} is behind live ${m.revision}: the next build refreshes it (sync-fallback)`
1222
+ );
1223
+ if (trusted && trusted.revision > m.revision)
1224
+ add(
1225
+ "warn",
1226
+ `fallback revision ${trusted.revision} is AHEAD of live ${m.revision}: stale CDN edge or rollback in flight (the SDK would render the fallback with rev_rollback)`
1227
+ );
1228
+ }
1229
+ }
1230
+ const pkg = JSON.parse(readFileSync3(join3(root, "package.json"), "utf8"));
1231
+ const scripts = pkg.scripts ?? {};
1232
+ const hasPrebuild = /network:sync|sync-fallback/.test(scripts["prebuild"] ?? "");
1233
+ const inlined = /sync-fallback|network:sync/.test(scripts["build"] ?? "");
1234
+ if (hasPrebuild || inlined)
1235
+ add(
1236
+ "ok",
1237
+ `package.json: sync-fallback runs in ${[hasPrebuild && "prebuild", inlined && "build"].filter(Boolean).join(" and ")}`
1238
+ );
1239
+ else
1240
+ add(
1241
+ "warn",
1242
+ "package.json lacks the prebuild hook / an inlined sync-fallback in build: the fallback will go stale"
1243
+ );
1244
+ add(
1245
+ "ok",
1246
+ "App Platform: the frontend component's Build Command must be `npm run build` (prebuild/inlined sync-fallback run only then)"
1247
+ );
1248
+ const dep = pkg.dependencies?.["@intenque/network-sdk"];
1249
+ add(
1250
+ dep ? "ok" : "warn",
1251
+ `@intenque/network-sdk ${dep ?? "is not a dependency"} (CLI ${SDK_VERSION})`
1252
+ );
1253
+ if (scan.headerMode) {
1254
+ const api = str2(flags["api-url"]);
1255
+ if (!api)
1256
+ add(
1257
+ "warn",
1258
+ "header mode detected: pass --api-url <backend origin> [--preflight-path /api/book] to check the CORS preflight"
1259
+ );
1260
+ else {
1261
+ const target = new URL(str2(flags["preflight-path"]) ?? "/", api).href;
1262
+ try {
1263
+ const res = await fetch(target, {
1264
+ method: "OPTIONS",
1265
+ headers: {
1266
+ Origin: "https://doctor.invalid",
1267
+ "Access-Control-Request-Method": "POST",
1268
+ "Access-Control-Request-Headers": "x-inr-ref, x-inr-ref-scope, x-inr-ref-first"
1269
+ }
1270
+ });
1271
+ const allowed = (res.headers.get("access-control-allow-headers") ?? "").toLowerCase();
1272
+ const ok = ["x-inr-ref", "x-inr-ref-scope", "x-inr-ref-first"].every((h) => allowed.includes(h)) || allowed === "*";
1273
+ add(
1274
+ ok ? "ok" : "fail",
1275
+ `CORS preflight ${target}: ${res.status}, allow-headers "${allowed || "\u2014"}"${ok ? "" : " \u2014 append X-INR-Ref, X-INR-Ref-Scope, X-INR-Ref-First to allowedHeaders or use body mode"}`
1276
+ );
1277
+ } catch (err) {
1278
+ add(
1279
+ "fail",
1280
+ `CORS preflight ${target} failed: ${err instanceof Error ? err.message : String(err)}`
1281
+ );
1282
+ }
1283
+ }
1284
+ } else add("ok", "body mode (default): no CORS change needed");
1285
+ if (flags["no-impression"] === true) add("skip", "test impression (src: doctor): --no-impression");
1286
+ else if (!live)
1287
+ add("skip", "test impression (src: doctor): no verified live manifest to take the token from");
1288
+ else {
1289
+ const r = await sendDoctorImpression(live);
1290
+ if ("error" in r) add("warn", `test impression (src: doctor) \u2192 ${r.url}: ${r.error}`);
1291
+ else
1292
+ add(
1293
+ r.status === 204 ? "ok" : "warn",
1294
+ `test impression (src: doctor) \u2192 ${r.url}: HTTP ${r.status}${r.status === 204 ? "" : " (expected 204)"}`
1295
+ );
1296
+ }
1297
+ if (backendEnv) findings.push(await signedPing(backendEnv));
1298
+ else add("skip", "signed POST /v1/conversions/ping: no backend env here (doctor --backend)");
1299
+ return finish(findings);
1300
+ }
1301
+ function finish(findings) {
1302
+ const icon = { ok: "\u2713", warn: "!", fail: "\u2717", skip: "\u2013" };
1303
+ for (const f of findings) console.log(` ${icon[f.level]} ${f.text}`);
1304
+ const fails = findings.filter((f) => f.level === "fail").length;
1305
+ const warns = findings.filter((f) => f.level === "warn").length;
1306
+ console.log(`network-sdk doctor: ${fails} failed, ${warns} warnings`);
1307
+ if (fails > 0) throw new CliError(`${fails} check(s) failed`);
1308
+ return 0;
1309
+ }
1310
+
1311
+ // src/cli/init.ts
1312
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
1313
+ import { join as join4 } from "path";
1314
+ function detectSite(root) {
1315
+ const pkg = (() => {
1316
+ try {
1317
+ return JSON.parse(readFileSync4(join4(root, "package.json"), "utf8"));
1318
+ } catch {
1319
+ return {};
1320
+ }
1321
+ })();
1322
+ const deps = {
1323
+ ...pkg["dependencies"] ?? {},
1324
+ ...pkg["devDependencies"] ?? {}
1325
+ };
1326
+ const twFile = [
1327
+ "tailwind.config.js",
1328
+ "tailwind.config.cjs",
1329
+ "tailwind.config.mjs",
1330
+ "tailwind.config.ts"
1331
+ ].map((f) => join4(root, f)).find((p) => existsSync4(p));
1332
+ const colours = {};
1333
+ if (twFile) {
1334
+ const text = readFileSync4(twFile, "utf8");
1335
+ for (const m of text.matchAll(/\b([A-Za-z][\w-]*)\s*:\s*['"](#[0-9a-fA-F]{3,8})['"]/g))
1336
+ colours[m[1]] = m[2];
1337
+ }
1338
+ const tracker = join4(root, "src/api/tracker.js");
1339
+ const consentImport = existsSync4(tracker) && /export\s+function\s+getConsent\b/.test(readFileSync4(tracker, "utf8")) ? `import { getConsent } from './api/tracker'` : null;
1340
+ return {
1341
+ tailwind: !!twFile || "tailwindcss" in deps,
1342
+ colours,
1343
+ lucide: "lucide-react" in deps,
1344
+ consentImport
1345
+ };
1346
+ }
1347
+ function tailwindTheme(colours) {
1348
+ const gold = "gold" in colours ? "gold" : "[#ffcb50]";
1349
+ const white = "mycustomwhite" in colours ? "mycustomwhite" : "white";
1350
+ return {
1351
+ section: "py-16 px-4 bg-black",
1352
+ container: "max-w-6xl mx-auto",
1353
+ heading: `text-${white} text-3xl font-bold text-center`,
1354
+ accentWord: `text-${gold}`,
1355
+ subheading: "text-gray-300 text-center mt-2",
1356
+ grid: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 mt-10",
1357
+ card: "bg-white rounded-2xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden flex flex-col",
1358
+ imageBox: "aspect-video bg-gray-100 overflow-hidden",
1359
+ image: "w-full h-full object-cover",
1360
+ placeholder: "w-full h-full flex items-center justify-center text-5xl font-bold text-gray-400 bg-gray-200",
1361
+ body: "flex flex-col flex-grow gap-2 p-5",
1362
+ name: "text-xl font-semibold text-black",
1363
+ meta: "text-sm text-gray-500",
1364
+ description: "text-gray-700 line-clamp-3 flex-grow",
1365
+ button: `self-start mt-2 inline-flex items-center gap-2 bg-${gold} text-black font-semibold rounded-full px-5 py-2 hover:bg-black hover:text-${gold} transition-all`,
1366
+ buttonIcon: "w-4 h-4"
1367
+ };
1368
+ }
1369
+ function renderConfig(input) {
1370
+ const { siteKey, channel, cdnBase, rootKeysJson, detected } = input;
1371
+ const theme = detected.tailwind ? tailwindTheme(detected.colours) : null;
1372
+ const lines = [
1373
+ `// Written by \`network-sdk init\` (@intenque/network-sdk ${SDK_VERSION}) \u2014 the only per-site file (spec \xA76.2).`,
1374
+ `// Everything else (title, items, UTM values, analytics on/off, images) comes from the signed manifest.`,
1375
+ `import { defineNetworkConfig } from '@intenque/network-sdk/react'`,
1376
+ `import fallback from './data/network.fallback.json'`
1377
+ ];
1378
+ if (detected.consentImport)
1379
+ lines.push(`${detected.consentImport} // consent adapter: get() => 'granted' | 'denied' | null`);
1380
+ lines.push(
1381
+ ``,
1382
+ `export default defineNetworkConfig({`,
1383
+ ` siteKey: import.meta.env.VITE_INR_SITE_KEY || '${siteKey}', // the literal is what sync-fallback reads (\xA74.6)`,
1384
+ ` channel: import.meta.env.VITE_INR_CHANNEL || '${channel}',`
1385
+ );
1386
+ if (cdnBase)
1387
+ lines.push(
1388
+ ` // Development CDN base written by \`init --url\`; remove when the production CDN is live (DEFAULT_CDN_BASE).`,
1389
+ ` cdnBase: '${cdnBase}',`
1390
+ );
1391
+ lines.push(
1392
+ ` manifestUrl: import.meta.env.VITE_INR_MANIFEST_URL, // separate staging hub only (with manifestRootKeys)`
1393
+ );
1394
+ if (rootKeysJson)
1395
+ lines.push(
1396
+ ` // Root public keys written by \`init --root-keys\` (dev root). Remove when the SDK ships the production root.`,
1397
+ ` manifestRootKeys: ${rootKeysJson},`
1398
+ );
1399
+ lines.push(
1400
+ ` fallback, // written by \`network-sdk sync-fallback\` in prebuild; also the rollback floor`,
1401
+ detected.consentImport ? ` consent: { get: getConsent },` : ` consent: undefined, // TODO: { get: () => 'granted' | 'denied' | null }`,
1402
+ ` consentMode: 'session-default', // 'require-consent' for sites with EU/UK visitors (\xA75.9)`,
1403
+ ` dev: import.meta.env.DEV,`
1404
+ );
1405
+ if (theme) {
1406
+ lines.push(
1407
+ ` unstyled: true, // Tailwind classes below; drop this line and import '@intenque/network-sdk/styles.css' instead`
1408
+ );
1409
+ lines.push(` theme: {`);
1410
+ for (const [k, v] of Object.entries(theme)) lines.push(` ${k}: '${v}',`);
1411
+ lines.push(` },`);
1412
+ } else {
1413
+ lines.push(
1414
+ ` // No Tailwind detected: import '@intenque/network-sdk/styles.css' in main.jsx and tune the --inr-* variables.`
1415
+ );
1416
+ }
1417
+ lines.push(`})`, ``);
1418
+ return lines.join("\n");
1419
+ }
1420
+ function patchScripts(scripts, syncCommand) {
1421
+ const out = { ...scripts };
1422
+ const changes = [];
1423
+ if (out["network:sync"] !== syncCommand) {
1424
+ out["network:sync"] = syncCommand;
1425
+ changes.push('"network:sync"');
1426
+ }
1427
+ if (!out["prebuild"]) {
1428
+ out["prebuild"] = "npm run network:sync";
1429
+ changes.push('"prebuild"');
1430
+ } else if (!out["prebuild"].includes("network:sync") && !out["prebuild"].includes("sync-fallback")) {
1431
+ out["prebuild"] = `npm run network:sync && ${out["prebuild"]}`;
1432
+ changes.push('"prebuild" (prepended)');
1433
+ }
1434
+ const build = out["build"];
1435
+ if (build && !build.includes("sync-fallback") && !build.includes("network:sync")) {
1436
+ out["build"] = `${syncCommand} && ${build}`;
1437
+ changes.push('"build" (sync-fallback inlined)');
1438
+ }
1439
+ return { scripts: out, changes };
1440
+ }
1441
+ async function runInit(flags) {
1442
+ const siteKey = str2(flags["site-key"]);
1443
+ if (!siteKey || !SLUG_RE.test(siteKey) || siteKey.length > 48)
1444
+ throw new CliError(
1445
+ "usage: network-sdk init --site-key <site-key> [--url <cdn-base>] [--root-keys <json>] [--channel production|preview]"
1446
+ );
1447
+ const root = projectRoot(flags);
1448
+ const channel = flags["channel"] === "preview" ? "preview" : "production";
1449
+ const base = str2(flags["url"]);
1450
+ const { roots, explicit } = resolveRootKeys(flags);
1451
+ const expect = { site: siteKey, channel };
1452
+ const url = aliasUrl(base, siteKey, channel);
1453
+ const detected = detectSite(root);
1454
+ const committed = readFallback(root);
1455
+ const committedOk = committed.exists && committed.json !== null ? (await verifyCommitted(committed.json, roots, expect)).ok : false;
1456
+ log.info(`downloading ${url}`);
1457
+ const dl = await downloadManifest(url);
1458
+ if (!dl.ok) {
1459
+ if (committedOk)
1460
+ log.warn(`download failed (${dl.detail}); keeping the committed ${FALLBACK_PATH}`);
1461
+ else throw new CliError(`${NO_MANIFEST_MESSAGE(siteKey)} (${dl.detail} at ${url})`);
1462
+ } else {
1463
+ const trustedRevoked = committedOk && committed.json ? committed.json.signer.revokedKids ?? [] : [];
1464
+ const v = await verifyDownloaded(dl.text, roots, expect, trustedRevoked);
1465
+ if (!v.ok)
1466
+ throw new CliError(
1467
+ `downloaded manifest failed verification (${v.reason}) at ${url}; not written`
1468
+ );
1469
+ writeFileAtomic(committed.path, fallbackFileContent(JSON.parse(dl.text)));
1470
+ log.ok(
1471
+ `wrote ${FALLBACK_PATH} (revision ${v.manifest.revision}, ${v.manifest.businesses.length} items, ${dl.bytes} bytes, signer ${v.manifest.sig.kid})`
1472
+ );
1473
+ }
1474
+ const configPath = join4(root, CONFIG_PATH);
1475
+ if (existsSync4(configPath) && flags["force"] !== true)
1476
+ log.warn(`${CONFIG_PATH} exists; left unchanged (use --force to rewrite)`);
1477
+ else {
1478
+ writeFileAtomic(
1479
+ configPath,
1480
+ renderConfig({
1481
+ siteKey,
1482
+ channel,
1483
+ cdnBase: base,
1484
+ rootKeysJson: explicit ? JSON.stringify(roots) : void 0,
1485
+ detected
1486
+ })
1487
+ );
1488
+ log.ok(
1489
+ `wrote ${CONFIG_PATH} (${detected.tailwind ? "Tailwind theme slots" : "styles.css"}${detected.consentImport ? ", consent from src/api/tracker.js" : ""}${detected.lucide ? ", lucide-react available" : ""})`
1490
+ );
1491
+ }
1492
+ const pkgPath = join4(root, "package.json");
1493
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1494
+ const syncFlags = [
1495
+ base ? `--url ${base}` : "",
1496
+ explicit ? `--root-keys '${JSON.stringify(roots)}'` : "",
1497
+ channel === "preview" ? "--channel preview" : ""
1498
+ ].filter(Boolean).join(" ");
1499
+ const syncCommand = `network-sdk sync-fallback${syncFlags ? ` ${syncFlags}` : ""}`;
1500
+ const { scripts, changes } = patchScripts(pkg["scripts"] ?? {}, syncCommand);
1501
+ if (changes.length > 0) {
1502
+ pkg["scripts"] = scripts;
1503
+ writeFileAtomic(pkgPath, `${JSON.stringify(pkg, null, 2)}
1504
+ `);
1505
+ log.ok(`package.json scripts updated: ${changes.join(", ")}`);
1506
+ } else log.ok("package.json scripts already up to date");
1507
+ if (base || explicit)
1508
+ log.warn(
1509
+ "dev flags (--url / --root-keys) are inlined into the sync scripts; re-run init without them once the production CDN and root are live"
1510
+ );
1511
+ console.log(`
1512
+ Next steps (spec \xA76.7):
1513
+ main.jsx (before ReactDOM.createRoot, before any tracker or GA snippet):
1514
+ import { captureReferral } from '@intenque/network-sdk/capture'
1515
+ import { NetworkProvider } from '@intenque/network-sdk/react'
1516
+ import networkConfig from './network.config'
1517
+ captureReferral(networkConfig.capture)
1518
+ \u2026render(<NetworkProvider config={networkConfig}><App /></NetworkProvider>)
1519
+ Home page (after <ReviewCarousel />):
1520
+ import { DiscoverBusinesses } from '@intenque/network-sdk/react'
1521
+ <DiscoverBusinesses />
1522
+ ConsentBanner (next to grantConsent() / denyConsent()):
1523
+ import { setConsent } from '@intenque/network-sdk/capture' \u2192 setConsent('granted') / setConsent('denied')
1524
+ API client (api/axios.js):
1525
+ import { attachReferral } from '@intenque/network-sdk/capture' \u2192 attachReferral(api)
1526
+ App Platform: the frontend component's Build Command must be \`npm run build\` (so sync-fallback runs on every deploy).
1527
+ Then: npx network-sdk doctor
1528
+ `);
1529
+ return 0;
1530
+ }
1531
+
1532
+ // src/cli/printConfig.ts
1533
+ import { relative as relative2 } from "path";
1534
+ function runPrintConfig(flags) {
1535
+ const root = projectRoot(flags);
1536
+ const cfg = readConfigLiterals(root);
1537
+ const fb = readFallback(root);
1538
+ const j = fb.json;
1539
+ const channel = cfg.channel ?? "production";
1540
+ const out = {
1541
+ sdkVersion: SDK_VERSION,
1542
+ configFile: cfg.exists ? relative2(root, cfg.path) : null,
1543
+ siteKey: cfg.siteKey,
1544
+ channel,
1545
+ cdnBase: cfg.cdnBase ?? (j && typeof j["cdnBase"] === "string" ? j["cdnBase"] : DEFAULT_CDN_BASE),
1546
+ manifestUrl: cfg.manifestUrl ?? (cfg.siteKey ? aliasUrl(cfg.cdnBase ?? j?.["cdnBase"], cfg.siteKey, channel) : null),
1547
+ consentMode: cfg.consentMode ?? "session-default",
1548
+ fallback: fb.exists ? {
1549
+ file: relative2(root, fb.path),
1550
+ revision: j?.["revision"] ?? null,
1551
+ publishedAt: j?.["publishedAt"] ?? null,
1552
+ site: j?.["site"] ?? null,
1553
+ channel: j?.["channel"] ?? null,
1554
+ items: Array.isArray(j?.["businesses"]) ? j["businesses"].length : null,
1555
+ signer: j?.["sig"]?.kid ?? null,
1556
+ analyticsEnabled: j?.["analytics"]?.enabled ?? null
1557
+ } : null
1558
+ };
1559
+ if (!cfg.exists) log.warn("src/network.config.js not found");
1560
+ console.log(JSON.stringify(out, null, 2));
1561
+ return 0;
1562
+ }
1563
+
1564
+ // src/cli/syncFallback.ts
1565
+ function emptyPlaceholder(site, channel) {
1566
+ return {
1567
+ schema: 2,
1568
+ revision: 0,
1569
+ site,
1570
+ channel,
1571
+ businesses: [],
1572
+ placeholder: "written by network-sdk sync-fallback --allow-empty"
1573
+ };
1574
+ }
1575
+ async function runSyncFallback(flags) {
1576
+ const root = projectRoot(flags);
1577
+ const cfg = readConfigLiterals(root);
1578
+ if (!cfg.exists)
1579
+ throw new CliError(`${cfg.path} not found; run \`network-sdk init --site-key <key>\` first`);
1580
+ const siteKey = str2(flags["site-key"]) ?? cfg.siteKey;
1581
+ if (!siteKey)
1582
+ throw new CliError(
1583
+ `cannot resolve the site key: ${cfg.path} has no literal siteKey (e.g. siteKey: 'jcm-detailing')`
1584
+ );
1585
+ const { roots } = resolveRootKeys(flags);
1586
+ const base = str2(flags["url"]);
1587
+ if (!base && (process.env["VITE_INR_MANIFEST_URL"] || process.env["INR_MANIFEST_URL"]))
1588
+ log.info(
1589
+ "VITE_INR_MANIFEST_URL is set but ignored: sync-fallback reads the production CDN unless --url is explicit (\xA74.6)"
1590
+ );
1591
+ const committed = readFallback(root);
1592
+ const committedJson = committed.json;
1593
+ const committedChannel = committedJson && (committedJson["channel"] === "preview" || committedJson["channel"] === "production") ? committedJson["channel"] : null;
1594
+ const explicitChannel = flags["channel"] === "preview" ? "preview" : flags["channel"] === "production" ? "production" : null;
1595
+ const channels = explicitChannel ? [explicitChannel] : committedChannel ? [committedChannel] : ["production", "preview"];
1596
+ let trusted = null;
1597
+ if (committed.exists) {
1598
+ if (committedJson === null)
1599
+ log.warn(`${FALLBACK_PATH} is not valid JSON; it will be overwritten`);
1600
+ else {
1601
+ const r = await verifyCommitted(committedJson, roots, {
1602
+ site: siteKey,
1603
+ channel: channels[0]
1604
+ });
1605
+ if (r.ok) trusted = r.manifest;
1606
+ else if (isPlaceholder(committedJson))
1607
+ log.info(`${FALLBACK_PATH} is the --allow-empty placeholder`);
1608
+ else
1609
+ log.warn(
1610
+ `${FALLBACK_PATH} fails the identity check (${r.reason}: site ${String(committedJson["site"])}, channel ${String(committedJson["channel"])}); it will be overwritten`
1611
+ );
1612
+ }
1613
+ }
1614
+ let dl = null;
1615
+ let channel = channels[0];
1616
+ for (const ch of channels) {
1617
+ channel = ch;
1618
+ dl = await downloadManifest(aliasUrl(base, siteKey, ch));
1619
+ if (dl.ok || dl.kind !== "http") break;
1620
+ }
1621
+ if (!dl) throw new CliError("internal: no download attempted");
1622
+ if (!dl.ok) {
1623
+ const allowEmpty = flags["allow-empty"] === true;
1624
+ if (trusted) {
1625
+ log.warn(
1626
+ `${dl.detail} at ${dl.url}; keeping the committed ${FALLBACK_PATH} (revision ${trusted.revision})${allowEmpty ? "; --allow-empty does not replace a trusted fallback" : ""}`
1627
+ );
1628
+ return 0;
1629
+ }
1630
+ if (committed.exists && committedJson && isPlaceholder(committedJson)) {
1631
+ log.warn(`${dl.detail} at ${dl.url}; keeping the committed empty placeholder`);
1632
+ return 0;
1633
+ }
1634
+ if (allowEmpty) {
1635
+ writeFileAtomic(committed.path, fallbackFileContent(emptyPlaceholder(siteKey, channel)));
1636
+ log.warn(
1637
+ `${dl.detail} at ${dl.url}; wrote an EMPTY placeholder (--allow-empty): the section renders nothing`
1638
+ );
1639
+ return 0;
1640
+ }
1641
+ throw new CliError(
1642
+ `${NO_MANIFEST_MESSAGE(siteKey)} (${dl.detail} at ${dl.url}; no committed ${FALLBACK_PATH} passes the identity check)`
1643
+ );
1644
+ }
1645
+ const v = await verifyDownloaded(
1646
+ dl.text,
1647
+ roots,
1648
+ { site: siteKey, channel },
1649
+ trusted?.signer.revokedKids ?? []
1650
+ );
1651
+ if (!v.ok) {
1652
+ if (trusted) {
1653
+ log.warn(
1654
+ `downloaded manifest failed verification (${v.reason}) at ${dl.url}; keeping the committed file`
1655
+ );
1656
+ return 0;
1657
+ }
1658
+ throw new CliError(
1659
+ `downloaded manifest failed verification (${v.reason}) at ${dl.url} and no trusted committed ${FALLBACK_PATH} exists`
1660
+ );
1661
+ }
1662
+ const live = v.manifest;
1663
+ if (trusted && live.revision < trusted.revision) {
1664
+ log.warn(
1665
+ `live revision ${live.revision} is lower than the committed revision ${trusted.revision}; refusing to downgrade (a stale CDN edge or a rollback in flight)`
1666
+ );
1667
+ return 0;
1668
+ }
1669
+ if (trusted && live.revision === trusted.revision) {
1670
+ log.ok(`${FALLBACK_PATH} already at revision ${live.revision}`);
1671
+ return 0;
1672
+ }
1673
+ writeFileAtomic(committed.path, fallbackFileContent(JSON.parse(dl.text)));
1674
+ const check = readJsonFile(committed.path);
1675
+ if (!check || check.site !== siteKey)
1676
+ throw new CliError(`${FALLBACK_PATH} site ${String(check?.site)} \u2260 ${siteKey} after the write`);
1677
+ log.ok(
1678
+ `wrote ${FALLBACK_PATH}: revision ${live.revision} (${trusted ? `was ${trusted.revision}` : "new"}), ${live.businesses.length} items, ${dl.bytes} bytes, ${channel} channel, signer ${live.sig.kid}`
1679
+ );
1680
+ return 0;
1681
+ }
1682
+ function isPlaceholder(o) {
1683
+ return o["revision"] === 0 && Array.isArray(o["businesses"]) && o["businesses"].length === 0 && !("sig" in o);
1684
+ }
1685
+
1686
+ // src/cli/main.ts
1687
+ var HELP = `network-sdk ${SDK_VERSION} \u2014 @intenque/network-sdk CLI (spec \xA76.6)
1688
+
1689
+ network-sdk init --site-key <key> [--url <cdn-base>] [--root-keys <json>] [--channel production|preview] [--root <dir>] [--force]
1690
+ network-sdk sync-fallback [--url <cdn-base>] [--root-keys <json>] [--channel \u2026] [--allow-empty] [--root <dir>]
1691
+ network-sdk doctor [--frontend] [--url <cdn-base>] [--root-keys <json>] [--api-url <origin>] [--preflight-path <path>] [--no-impression] [--root <dir>]
1692
+ network-sdk doctor --backend [--mongo-uri <uri>] [--root <dir>]
1693
+ network-sdk print-config [--root <dir>]
1694
+
1695
+ --url development / staging CDN base (sync-fallback otherwise reads the production CDN only, \xA74.6)
1696
+ --root-keys inline JSON [{ kid, jwk }] (the hub's MANIFEST_ROOT_PUBLIC_KEYS value); otherwise the compiled production roots
1697
+ --mongo-uri checks the outbox indexes with the site's own mongodb driver (never printed)
1698
+ `;
1699
+ async function main(argv) {
1700
+ const { positional, flags } = parseArgs(argv);
1701
+ const cmd = positional[0];
1702
+ if (!cmd || cmd === "help" || flags["help"] === true) {
1703
+ console.log(HELP);
1704
+ return cmd ? 0 : 2;
1705
+ }
1706
+ if (flags["version"] === true || cmd === "--version") {
1707
+ console.log(SDK_VERSION);
1708
+ return 0;
1709
+ }
1710
+ try {
1711
+ if (cmd === "init" || cmd === "sync-fallback" || cmd === "doctor") requireWebCrypto();
1712
+ switch (cmd) {
1713
+ case "init":
1714
+ return await runInit(flags);
1715
+ case "sync-fallback":
1716
+ return await runSyncFallback(flags);
1717
+ case "doctor":
1718
+ return await runDoctor(flags);
1719
+ case "print-config":
1720
+ return runPrintConfig(flags);
1721
+ default:
1722
+ log.error(`unknown command "${cmd}"`);
1723
+ console.log(HELP);
1724
+ return 2;
1725
+ }
1726
+ } catch (err) {
1727
+ if (err instanceof CliError) {
1728
+ log.error(err.message);
1729
+ return err.exitCode;
1730
+ }
1731
+ log.error(err instanceof Error ? err.message : String(err));
1732
+ return 1;
1733
+ }
1734
+ }
1735
+
1736
+ // src/cli/index.ts
1737
+ globalThis.crypto ??= webcrypto;
1738
+ main(process.argv.slice(2)).then(
1739
+ (code) => process.exit(code),
1740
+ (err) => {
1741
+ log.error(err instanceof Error ? err.message : String(err));
1742
+ process.exit(1);
1743
+ }
1744
+ );
1745
+ //# sourceMappingURL=index.js.map