@namiml/sdk-core 3.4.12-dev.202609251647 → 3.4.12-dev.202609281806

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/dist/index.cjs CHANGED
@@ -106,7 +106,7 @@ const {
106
106
  // version — stamped by scripts/version.sh
107
107
  NAMI_SDK_VERSION: exports.NAMI_SDK_VERSION = "3.4.12",
108
108
  // full package version including dev suffix — stamped by scripts/version.sh
109
- NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.4.12-dev.202609251647",
109
+ NAMI_SDK_PACKAGE_VERSION: exports.NAMI_SDK_PACKAGE_VERSION = "3.4.12-dev.202609281806",
110
110
  // environments
111
111
  PRODUCTION: exports.PRODUCTION = "production", DEVELOPMENT: exports.DEVELOPMENT = "development",
112
112
  // error messages
@@ -116,7 +116,25 @@ AUTH_DEVICE: exports.AUTH_DEVICE = "nami_auth_device", NAMI_CONFIGURATION: expor
116
116
  // API settings
117
117
  API_VERSION: exports.API_VERSION = "v3", BASE_URL_PATH: exports.BASE_URL_PATH = `sdk/${exports.API_VERSION}/platform`, BASE_URL: exports.BASE_URL = "https://app.namiml.com", BASE_STAGING_URL: exports.BASE_STAGING_URL = "https://app-staging.namiml.com", CUSTOM_HOST_PREFIX: exports.CUSTOM_HOST_PREFIX = "namiAPIHost=", USE_STAGING_API: exports.USE_STAGING_API = "useStagingAPI",
118
118
  // // extended client info
119
- EXTENDED_CLIENT_INFO_PREFIX: exports.EXTENDED_CLIENT_INFO_PREFIX = "extendedClientInfo", EXTENDED_CLIENT_INFO_DELIMITER: exports.EXTENDED_CLIENT_INFO_DELIMITER = ":", VALIDATE_PRODUCT_GROUPS: exports.VALIDATE_PRODUCT_GROUPS = "validateProductGroups", LOG_HTTP_REQUESTS: exports.LOG_HTTP_REQUESTS = "logHTTPRequests", LOG_HTTP_TRAFFIC: exports.LOG_HTTP_TRAFFIC = "logHTTPTraffic", API_TRAFFIC_OBSERVER: exports.API_TRAFFIC_OBSERVER = "apiTrafficObserver", STARTUP_TELEMETRY: exports.STARTUP_TELEMETRY = "startupTelemetry", EXTENDED_PLATFORM: exports.EXTENDED_PLATFORM = "extended-platform", EXTENDED_PLATFORM_VERSION: exports.EXTENDED_PLATFORM_VERSION = "extended-platform-version", API_MAX_CALLS_LIMIT: exports.API_MAX_CALLS_LIMIT = 2, API_RETRY_DELAY_SEC: exports.API_RETRY_DELAY_SEC = 2, API_TIMEOUT_LIMIT: exports.API_TIMEOUT_LIMIT = 20000, DEVICE_API_TIMEOUT_LIMIT: exports.DEVICE_API_TIMEOUT_LIMIT = 2000,
119
+ EXTENDED_CLIENT_INFO_PREFIX: exports.EXTENDED_CLIENT_INFO_PREFIX = "extendedClientInfo", EXTENDED_CLIENT_INFO_DELIMITER: exports.EXTENDED_CLIENT_INFO_DELIMITER = ":", VALIDATE_PRODUCT_GROUPS: exports.VALIDATE_PRODUCT_GROUPS = "validateProductGroups", LOG_HTTP_REQUESTS: exports.LOG_HTTP_REQUESTS = "logHTTPRequests", LOG_HTTP_TRAFFIC: exports.LOG_HTTP_TRAFFIC = "logHTTPTraffic", API_TRAFFIC_OBSERVER: exports.API_TRAFFIC_OBSERVER = "apiTrafficObserver", STARTUP_TELEMETRY: exports.STARTUP_TELEMETRY = "startupTelemetry",
120
+ // Test hook: every platform-config fetch attempt fails before the request leaves.
121
+ // Same namiCommand as Apple's and Android's, so the shared first-run flows drive it.
122
+ SIMULATE_CONFIG_FETCH_FAILURE: exports.SIMULATE_CONFIG_FETCH_FAILURE = "simulateConfigFetchFailure",
123
+ // Test hook: every per-paywall body fetch fails before the request leaves, so the body cache's
124
+ // failure rows can be exercised without a proxy. Same namiCommand as Apple's and Android's (NAM-4357).
125
+ SIMULATE_PAYWALL_FETCH_FAILURE: exports.SIMULATE_PAYWALL_FETCH_FAILURE = "simulatePaywallFetchFailure",
126
+ // `campaignCacheTTL=<seconds>` overrides the config's campaign_cache_ttl — Apple's command name.
127
+ // It lets the paywall-cache e2e suite pin TTL 3600 / 1 / 0 per load (NAM-4357).
128
+ CAMPAIGN_CACHE_TTL_PREFIX: exports.CAMPAIGN_CACHE_TTL_PREFIX = "campaignCacheTTL=", EXTENDED_PLATFORM: exports.EXTENDED_PLATFORM = "extended-platform", EXTENDED_PLATFORM_VERSION: exports.EXTENDED_PLATFORM_VERSION = "extended-platform-version", API_MAX_CALLS_LIMIT: exports.API_MAX_CALLS_LIMIT = 2, API_RETRY_DELAY_SEC: exports.API_RETRY_DELAY_SEC = 2, API_TIMEOUT_LIMIT: exports.API_TIMEOUT_LIMIT = 20000, DEVICE_API_TIMEOUT_LIMIT: exports.DEVICE_API_TIMEOUT_LIMIT = 2000,
129
+ // Platform-config attempts when no initial config was supplied (Android's
130
+ // CONFIG_MAX_ATTEMPTS; Apple's retry(maxAttempts: 3)).
131
+ CONFIG_MAX_ATTEMPTS: exports.CONFIG_MAX_ATTEMPTS = 3,
132
+ // Per-attempt timeout for GET config/. Android's config client allows 5 s to connect plus 5 s
133
+ // to read (Apple allows 30 s); three attempts keep a total outage near 30 s.
134
+ CONFIG_API_TIMEOUT_LIMIT: exports.CONFIG_API_TIMEOUT_LIMIT = 10000,
135
+ // A foreground within this long of the last launch/resume run is skipped — Apple's
136
+ // NamiStartupCoordinator.shouldRunResume window.
137
+ RESUME_THROTTLE_MS: exports.RESUME_THROTTLE_MS = 30000,
120
138
  // status codes
121
139
  STATUS_SUCCESS: exports.STATUS_SUCCESS = 200, STATUS_BAD_REQUEST: exports.STATUS_BAD_REQUEST = 400, STATUS_NOT_FOUND: exports.STATUS_NOT_FOUND = 404, STATUS_CONFLICT: exports.STATUS_CONFLICT = 409, STATUS_INTERNAL_SERVER_ERROR: exports.STATUS_INTERNAL_SERVER_ERROR = 500,
122
140
  // configuration states
@@ -3047,6 +3065,206 @@ function uniqBy(array, iteratee) {
3047
3065
  return (array && array.length) ? baseUniq(array, baseIteratee(iteratee)) : [];
3048
3066
  }
3049
3067
 
3068
+ /** Storage key of an entry's raw body; the exact URL follows the prefix. */
3069
+ const PAYWALL_BODY_KEY_PREFIX = "api_paywall_body:";
3070
+ /**
3071
+ * Storage key of an entry's metadata. Written AFTER the body, so it is the commit point: an entry
3072
+ * is visible only once both are durable, and a body with no metadata is an interrupted write.
3073
+ */
3074
+ const PAYWALL_BODY_META_KEY_PREFIX = "api_paywall_body_meta:";
3075
+ const DEFAULT_MAX_BODY_ENTRIES = 500;
3076
+ class PaywallBodyCache {
3077
+ constructor(storage = () => getPlatformAdapters().storage, maxEntries = DEFAULT_MAX_BODY_ENTRIES) {
3078
+ this.storage = storage;
3079
+ this.maxEntries = maxEntries;
3080
+ /** Set under `campaign_cache_ttl == 0`: entries live here, in this process only. */
3081
+ this.memory = null;
3082
+ /** Metadata of every persisted entry, loaded once from the storage adapter. */
3083
+ this.index = null;
3084
+ }
3085
+ get count() {
3086
+ return this.memory ? this.memory.size : this.loadIndex().size;
3087
+ }
3088
+ /**
3089
+ * Applies `campaign_cache_ttl` at the start of a resolution. `null` keeps everything, `n > 0`
3090
+ * purges entries whose own age is `>= n` seconds, and `0` purges the persistent store and keeps
3091
+ * this process's entries in memory only.
3092
+ */
3093
+ evaluateTtl(ttl, now = Date.now()) {
3094
+ if (ttl === 0) {
3095
+ if (this.memory)
3096
+ return;
3097
+ this.dropPersisted([...this.loadIndex().keys()]);
3098
+ this.memory = new Map();
3099
+ return;
3100
+ }
3101
+ // Leaving TTL 0 goes back to the persistent store; the process-local entries are dropped.
3102
+ this.memory = null;
3103
+ if (ttl === null)
3104
+ return;
3105
+ const expired = [...this.loadIndex().values()].filter((meta) => isExpired(meta, ttl, now));
3106
+ this.dropPersisted(expired.map((meta) => meta.url));
3107
+ }
3108
+ /**
3109
+ * The entry stored under this exact URL, or null when there is none, it is past `ttl`, or its
3110
+ * body is missing (a half-written entry, which is deleted here).
3111
+ */
3112
+ validEntry(url, ttl, now = Date.now()) {
3113
+ if (this.memory)
3114
+ return this.memory.get(url) ?? null;
3115
+ const meta = this.loadIndex().get(url);
3116
+ if (!meta)
3117
+ return null;
3118
+ if (ttl !== null && ttl > 0 && isExpired(meta, ttl, now)) {
3119
+ this.dropPersisted([url]);
3120
+ return null;
3121
+ }
3122
+ const body = this.read(PAYWALL_BODY_KEY_PREFIX + url);
3123
+ if (body === null) {
3124
+ this.dropPersisted([url]);
3125
+ return null;
3126
+ }
3127
+ return { meta, body };
3128
+ }
3129
+ /**
3130
+ * The stored body for this exact URL, read straight from storage rather than through the index,
3131
+ * with no TTL check. This is what rebuilds the aggregate `api_paywalls`, so it also sees an entry
3132
+ * a host wrote after the index was loaded (the web test apps inject local test paywalls that way).
3133
+ */
3134
+ bodyFor(url) {
3135
+ if (this.memory)
3136
+ return this.memory.get(url)?.body ?? null;
3137
+ return this.read(PAYWALL_BODY_KEY_PREFIX + url);
3138
+ }
3139
+ /** Stores a body under its exact URL. Returns false when the write did not land (quota, I/O). */
3140
+ store(url, contentHash, body, now = Date.now()) {
3141
+ const meta = { url, savedAt: now, ...(contentHash ? { contentHash } : {}) };
3142
+ if (this.memory) {
3143
+ this.memory.set(url, { meta, body });
3144
+ return true;
3145
+ }
3146
+ const index = this.loadIndex();
3147
+ const metaJson = JSON.stringify(meta);
3148
+ // Body first, metadata last. Each write is verified by reading it back: the web adapter
3149
+ // swallows a quota error rather than throwing, so a silent no-op is the common failure.
3150
+ if (this.write(PAYWALL_BODY_KEY_PREFIX + url, body) &&
3151
+ this.write(PAYWALL_BODY_META_KEY_PREFIX + url, metaJson)) {
3152
+ index.set(url, meta);
3153
+ return true;
3154
+ }
3155
+ logger.warn(`Paywall body cache: could not store ${url}; it will be fetched next time`);
3156
+ this.dropPersisted([url]);
3157
+ return false;
3158
+ }
3159
+ /** Stores a bundled body unless an entry already exists — a body the SDK fetched always wins. */
3160
+ seedIfAbsent(url, contentHash, body, now = Date.now()) {
3161
+ const exists = this.memory ? this.memory.has(url) : this.loadIndex().has(url);
3162
+ return exists ? false : this.store(url, contentHash, body, now);
3163
+ }
3164
+ /** Deletes one entry — used for an undecodable body. Not eviction. */
3165
+ remove(url) {
3166
+ if (this.memory)
3167
+ this.memory.delete(url);
3168
+ else
3169
+ this.dropPersisted([url]);
3170
+ }
3171
+ /**
3172
+ * Eviction, run once per resolution after a complete `campaign_rules` fetch: delete every entry
3173
+ * whose URL this resolution did not name, then cap the store at `maxEntries`, oldest first.
3174
+ */
3175
+ reconcile(currentUrls) {
3176
+ const metas = this.memory
3177
+ ? [...this.memory.values()].map((entry) => entry.meta)
3178
+ : [...this.loadIndex().values()];
3179
+ const unnamed = metas.filter((meta) => !currentUrls.has(meta.url)).map((meta) => meta.url);
3180
+ const kept = metas.filter((meta) => currentUrls.has(meta.url)).sort((a, b) => a.savedAt - b.savedAt);
3181
+ const overCap = kept.slice(0, Math.max(0, kept.length - this.maxEntries)).map((meta) => meta.url);
3182
+ [...unnamed, ...overCap].forEach((url) => this.remove(url));
3183
+ }
3184
+ /** Empties the store — persisted and in-memory entries alike. Called by `Nami.reset()`. */
3185
+ purgeAll() {
3186
+ this.dropPersisted([...this.loadIndex().keys()]);
3187
+ this.memory?.clear();
3188
+ }
3189
+ /** Builds the index from the metadata keys, clearing away half-written and unreadable entries. */
3190
+ loadIndex() {
3191
+ if (this.index)
3192
+ return this.index;
3193
+ this.index = new Map();
3194
+ let keys;
3195
+ try {
3196
+ keys = this.storage().getAllKeys();
3197
+ }
3198
+ catch (error) {
3199
+ logger.warn(`Paywall body cache: storage unavailable, caching nothing (${error})`);
3200
+ return this.index;
3201
+ }
3202
+ const bodies = new Set(keys.filter((key) => key.startsWith(PAYWALL_BODY_KEY_PREFIX)).map((key) => key.slice(PAYWALL_BODY_KEY_PREFIX.length)));
3203
+ const stale = [];
3204
+ for (const key of keys) {
3205
+ if (!key.startsWith(PAYWALL_BODY_META_KEY_PREFIX))
3206
+ continue;
3207
+ const url = key.slice(PAYWALL_BODY_META_KEY_PREFIX.length);
3208
+ const meta = parseMeta(this.read(key));
3209
+ if (meta?.url === url && bodies.has(url))
3210
+ this.index.set(url, meta);
3211
+ else
3212
+ stale.push(url);
3213
+ bodies.delete(url);
3214
+ }
3215
+ // Bodies whose metadata never landed: interrupted writes.
3216
+ this.dropPersisted([...stale, ...bodies]);
3217
+ return this.index;
3218
+ }
3219
+ dropPersisted(urls) {
3220
+ for (const url of urls) {
3221
+ this.index?.delete(url);
3222
+ this.removeKey(PAYWALL_BODY_META_KEY_PREFIX + url);
3223
+ this.removeKey(PAYWALL_BODY_KEY_PREFIX + url);
3224
+ }
3225
+ }
3226
+ read(key) {
3227
+ try {
3228
+ return this.storage().getItem(key);
3229
+ }
3230
+ catch {
3231
+ return null;
3232
+ }
3233
+ }
3234
+ write(key, value) {
3235
+ try {
3236
+ this.storage().setItem(key, value);
3237
+ }
3238
+ catch {
3239
+ return false;
3240
+ }
3241
+ return this.read(key) === value;
3242
+ }
3243
+ removeKey(key) {
3244
+ try {
3245
+ this.storage().removeItem(key);
3246
+ }
3247
+ catch {
3248
+ // Nothing more to do: an entry that cannot be deleted is re-validated on the next read.
3249
+ }
3250
+ }
3251
+ }
3252
+ function isExpired(meta, ttlSeconds, now) {
3253
+ return (now - meta.savedAt) / 1000 >= ttlSeconds;
3254
+ }
3255
+ function parseMeta(raw) {
3256
+ if (raw === null)
3257
+ return null;
3258
+ try {
3259
+ const meta = JSON.parse(raw);
3260
+ return typeof meta?.url === "string" && typeof meta?.savedAt === "number" ? meta : null;
3261
+ }
3262
+ catch {
3263
+ return null;
3264
+ }
3265
+ }
3266
+ const paywallBodyCache = new PaywallBodyCache();
3267
+
3050
3268
  class StorageService {
3051
3269
  constructor() {
3052
3270
  this.memoryStore = {};
@@ -3129,6 +3347,20 @@ class StorageService {
3129
3347
  }
3130
3348
  this.setItem(key, paywalls);
3131
3349
  }
3350
+ /**
3351
+ * Writes the aggregate `api_paywalls` as the list of URLs a per-URL resolution produced, rather
3352
+ * than a second copy of their bodies (NAM-4357): the bodies already live in the per-URL body
3353
+ * cache, and `getPaywalls(API_PAYWALLS)` rebuilds the paywall objects from it. The legacy bulk
3354
+ * path still writes whole objects through `setPaywalls`.
3355
+ */
3356
+ setApiPaywallsFromBodyCache(urls) {
3357
+ const index = { bodyCacheUrls: urls };
3358
+ if (this.getCampaignCacheTtl() === 0) {
3359
+ this.memoryStore[exports.API_PAYWALLS] = JSON.stringify(index);
3360
+ return;
3361
+ }
3362
+ this.setItem(exports.API_PAYWALLS, index);
3363
+ }
3132
3364
  getPaywalls(key) {
3133
3365
  if (key === exports.INITIAL_PAYWALLS) {
3134
3366
  return this.memoryStore[exports.INITIAL_PAYWALLS]
@@ -3137,8 +3369,10 @@ class StorageService {
3137
3369
  }
3138
3370
  if (key === exports.API_PAYWALLS) {
3139
3371
  const memoryValue = this.memoryStore[exports.API_PAYWALLS];
3140
- if (memoryValue != null)
3141
- return JSON.parse(memoryValue);
3372
+ if (memoryValue != null) {
3373
+ const parsed = JSON.parse(memoryValue);
3374
+ return isBodyCacheAggregate(parsed) ? paywallsFromBodyCache(parsed.bodyCacheUrls) : parsed;
3375
+ }
3142
3376
  let stored = this.getItem(exports.API_PAYWALLS);
3143
3377
  if (stored === null)
3144
3378
  return null;
@@ -3150,6 +3384,8 @@ class StorageService {
3150
3384
  return stored;
3151
3385
  }
3152
3386
  }
3387
+ if (isBodyCacheAggregate(stored))
3388
+ return paywallsFromBodyCache(stored.bodyCacheUrls);
3153
3389
  return this.isLegacyTimestampedCache(stored) ? stored.data : stored;
3154
3390
  }
3155
3391
  return this.getItem(key);
@@ -3318,7 +3554,18 @@ class StorageService {
3318
3554
  this.resetItem(exports.API_CAMPAIGN_RULES);
3319
3555
  this.resetItem(exports.API_CAMPAIGN_SESSION_TIMESTAMP);
3320
3556
  }
3557
+ /**
3558
+ * The effective `campaign_cache_ttl`: a `campaignCacheTTL=<n>` namiCommand first, then the API
3559
+ * config, then the initial config. Lives here rather than in `utils/config` because this service
3560
+ * needs it to decide where the aggregate caches go, and `utils/config` already imports this one.
3561
+ */
3321
3562
  getCampaignCacheTtl() {
3563
+ const override = this.getNamiConfig()?.namiCommands
3564
+ ?.filter((cmd) => cmd.startsWith(exports.CAMPAIGN_CACHE_TTL_PREFIX))
3565
+ .map((cmd) => cmd.slice(exports.CAMPAIGN_CACHE_TTL_PREFIX.length))
3566
+ .find((value) => /^\d+$/.test(value));
3567
+ if (override !== undefined)
3568
+ return parseInt(override, 10);
3322
3569
  const config = this.getAppConfig(exports.API_CONFIG)
3323
3570
  || this.getAppConfig(exports.INITIAL_APP_CONFIG);
3324
3571
  return config?.campaign_cache_ttl ?? null;
@@ -3410,6 +3657,28 @@ class StorageService {
3410
3657
  this.memoryStore = {};
3411
3658
  }
3412
3659
  }
3660
+ function isBodyCacheAggregate(value) {
3661
+ return typeof value === "object" && value !== null && Array.isArray(value.bodyCacheUrls);
3662
+ }
3663
+ /**
3664
+ * The paywalls a per-URL resolution produced, decoded from the body cache. A URL whose entry has
3665
+ * since gone (evicted, purged, or a write that never landed) is simply absent.
3666
+ */
3667
+ function paywallsFromBodyCache(urls) {
3668
+ const paywalls = [];
3669
+ for (const url of urls) {
3670
+ const body = paywallBodyCache.bodyFor(url);
3671
+ if (body === null)
3672
+ continue;
3673
+ try {
3674
+ paywalls.push(JSON.parse(body));
3675
+ }
3676
+ catch {
3677
+ paywallBodyCache.remove(url);
3678
+ }
3679
+ }
3680
+ return paywalls;
3681
+ }
3413
3682
  const storageService = new StorageService();
3414
3683
 
3415
3684
  class RetryLimitExceededError extends Error {
@@ -6787,11 +7056,8 @@ const hasCapability = (capability) => {
6787
7056
  || storageService.getAppConfig(exports.INITIAL_APP_CONFIG);
6788
7057
  return appConfig?.capabilities?.includes(capability) ?? false;
6789
7058
  };
6790
- const getCampaignCacheTtl = () => {
6791
- const config = storageService.getAppConfig(exports.API_CONFIG)
6792
- || storageService.getAppConfig(exports.INITIAL_APP_CONFIG);
6793
- return config?.campaign_cache_ttl ?? null;
6794
- };
7059
+ /** The effective `campaign_cache_ttl`, `campaignCacheTTL=<n>` namiCommand first. */
7060
+ const getCampaignCacheTtl = () => storageService.getCampaignCacheTtl();
6795
7061
  /**
6796
7062
  * The effective global journey settings from the current config, coalescing a missing
6797
7063
  * `journey_settings` object — or any missing field — to {@link DEFAULT_JOURNEY_SETTINGS}.
@@ -57498,22 +57764,23 @@ class CampaignRuleRepository {
57498
57764
  static hasPaywallUrls(campaigns) {
57499
57765
  return campaigns.some((c) => c.paywall_url || c.page_urls || c.flow?.page_urls);
57500
57766
  }
57767
+ /**
57768
+ * The campaign rules for this form factor, unvalidated, plus whether they came from the network.
57769
+ * `fromNetwork` is false when the stored rules stood in for a failed fetch (or updates are
57770
+ * disabled): the per-URL body cache must then neither resolve nor evict (NAM-4357).
57771
+ */
57501
57772
  async fetchCampaignRulesRaw() {
57502
- const authDevice = storageService.getDevice();
57503
57773
  if (this.disableCampaignUpdates) {
57504
57774
  logger.info("Campaign updates disabled by configuration. Skipping fetch.");
57505
- return [];
57775
+ return { campaigns: [], fromNetwork: false };
57506
57776
  }
57507
- let campaignRules;
57508
- if (!authDevice?.id) {
57509
- campaignRules = await this.getAnonymousCampaigns();
57510
- }
57511
- else {
57512
- campaignRules = await this.getCampaigns(authDevice.id);
57513
- }
57514
- campaignRules = campaignRules?.filter((cRule) => (cRule.paywall && cRule.form_factors.some((f) => f.form_factor === this.currentFormFactor)) ||
57777
+ const authDevice = storageService.getDevice();
57778
+ const fetched = authDevice?.id
57779
+ ? await this.fetchCampaigns(authDevice.id)
57780
+ : await this.fetchAnonymousCampaigns();
57781
+ const campaigns = fetched.campaigns?.filter((cRule) => (cRule.paywall && cRule.form_factors.some((f) => f.form_factor === this.currentFormFactor)) ||
57515
57782
  (cRule.flow && cRule.form_factors?.some((f) => f.form_factor === this.currentFormFactor)));
57516
- return campaignRules;
57783
+ return { campaigns, fromNetwork: fetched.fromNetwork };
57517
57784
  }
57518
57785
  finalizeCampaignRules(campaignRules, paywalls) {
57519
57786
  // NAM-2457: a server flow rule is kept only when BOTH assets are satisfied —
@@ -57549,10 +57816,19 @@ class CampaignRuleRepository {
57549
57816
  listeners.forEach((handler) => handler(campaigns));
57550
57817
  }
57551
57818
  async getAnonymousCampaigns(sdkVersion = exports.NAMI_SDK_VERSION) {
57819
+ return (await this.fetchAnonymousCampaigns(sdkVersion)).campaigns;
57820
+ }
57821
+ async getCampaigns(deviceId) {
57822
+ return (await this.fetchCampaigns(deviceId)).campaigns;
57823
+ }
57824
+ async fetchAnonymousCampaigns(sdkVersion = exports.NAMI_SDK_VERSION) {
57552
57825
  const path = `campaign_rules/?sdk_version=${sdkVersion}`;
57553
57826
  try {
57554
57827
  const resp = await NamiAPI.instance.fetchAPI(path);
57555
- return mapAnonymousCampaigns(resp.results ?? [], this.splitPosition, this.currentFormFactor);
57828
+ return {
57829
+ campaigns: mapAnonymousCampaigns(resp.results ?? [], this.splitPosition, this.currentFormFactor),
57830
+ fromNetwork: true,
57831
+ };
57556
57832
  }
57557
57833
  catch (error) {
57558
57834
  const data = this.fallbackData();
@@ -57560,23 +57836,23 @@ class CampaignRuleRepository {
57560
57836
  logger.error(handleErrors(error.status, path));
57561
57837
  throw error;
57562
57838
  }
57563
- return data.results;
57839
+ return { campaigns: data.results, fromNetwork: false };
57564
57840
  }
57565
57841
  }
57566
- async getCampaigns(deviceId) {
57842
+ async fetchCampaigns(deviceId) {
57567
57843
  const path = `device/${deviceId}/campaign_rules/`;
57568
- let data;
57569
57844
  try {
57570
- data = await NamiAPI.instance.fetchAPI(path);
57845
+ const data = await NamiAPI.instance.fetchAPI(path);
57846
+ return { campaigns: data?.results || [], fromNetwork: true };
57571
57847
  }
57572
57848
  catch (error) {
57573
- data = this.fallbackData();
57849
+ const data = this.fallbackData();
57574
57850
  if (!data) {
57575
57851
  logger.error(handleErrors(error.status, path));
57576
57852
  throw error;
57577
57853
  }
57854
+ return { campaigns: data.results || [], fromNetwork: false };
57578
57855
  }
57579
- return data?.results || [];
57580
57856
  }
57581
57857
  fallbackData() {
57582
57858
  const storedData = storageService.getCampaignRules(exports.API_CAMPAIGN_RULES)
@@ -57859,6 +58135,15 @@ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
57859
58135
  * RetryExhaustedError when all attempts and/or the budget are spent.
57860
58136
  */
57861
58137
  async function fetchWithCdnRetry(url, opts = {}) {
58138
+ const { data, retryCount } = await fetchTextWithCdnRetry(url, opts);
58139
+ return { data: JSON.parse(data), retryCount };
58140
+ }
58141
+ /**
58142
+ * {@link fetchWithCdnRetry}'s retry loop, returning the raw 2xx body text instead of parsed JSON.
58143
+ * The per-URL paywall body cache stores those exact bytes, so a cached body is decoded by the
58144
+ * same `JSON.parse` a fetched one is (NAM-4357).
58145
+ */
58146
+ async function fetchTextWithCdnRetry(url, opts = {}) {
57862
58147
  const maxAttempts = opts.maxAttempts ?? DEFAULTS.maxAttempts;
57863
58148
  const baseDelayMs = opts.baseDelayMs ?? DEFAULTS.baseDelayMs;
57864
58149
  const maxDelayMs = opts.maxDelayMs ?? DEFAULTS.maxDelayMs;
@@ -57908,8 +58193,7 @@ async function fetchWithCdnRetry(url, opts = {}) {
57908
58193
  const responseBody = await response.clone().text();
57909
58194
  logger.debug(`[HTTP] Response ${response.status}: ${responseBody}`);
57910
58195
  }
57911
- const data = (await response.json());
57912
- return { data, retryCount: attempt };
58196
+ return { data: await response.text(), retryCount: attempt };
57913
58197
  }
57914
58198
  catch (err) {
57915
58199
  if (err instanceof NonRetryableHttpError)
@@ -57930,7 +58214,43 @@ async function fetchWithCdnRetry(url, opts = {}) {
57930
58214
  throw new RetryExhaustedError(url, attempt, lastStatus);
57931
58215
  }
57932
58216
 
58217
+ /**
58218
+ * Reads the parts of a `campaign_rules` paywall URL the per-URL body cache needs (NAM-4357).
58219
+ *
58220
+ * Both helpers scan the string rather than build a `URL`: the cache is keyed on the exact bytes
58221
+ * the server sent, so nothing here may normalise, re-encode or reject a URL — a malformed one is
58222
+ * simply un-hashed.
58223
+ */
58224
+ /**
58225
+ * The `content_hash` query value, or undefined when it is missing or empty. When the item appears
58226
+ * more than once the first value wins. The value is never parsed or validated: it is a
58227
+ * cache-validity token, not a content selector.
58228
+ */
58229
+ function contentHashOf(url) {
58230
+ const query = url.split("#")[0].split("?")[1];
58231
+ if (!query)
58232
+ return undefined;
58233
+ for (const item of query.split("&")) {
58234
+ const eq = item.indexOf("=");
58235
+ const name = eq === -1 ? item : item.slice(0, eq);
58236
+ if (name !== "content_hash")
58237
+ continue;
58238
+ const value = eq === -1 ? "" : item.slice(eq + 1);
58239
+ return value || undefined;
58240
+ }
58241
+ return undefined;
58242
+ }
58243
+ /** The path segment after `/paywall/` — the paywall id the URL names — or undefined. */
58244
+ function paywallIdOf(url) {
58245
+ const path = url.split(/[?#]/)[0];
58246
+ const match = /\/paywall\/([^/]+)/.exec(path);
58247
+ return match ? match[1] : undefined;
58248
+ }
58249
+
57933
58250
  class PaywallRepository {
58251
+ constructor(bodyCache = paywallBodyCache) {
58252
+ this.bodyCache = bodyCache;
58253
+ }
57934
58254
  async fetchPaywalls() {
57935
58255
  const authDevice = storageService.getDevice();
57936
58256
  let paywalls;
@@ -57979,34 +58299,131 @@ class PaywallRepository {
57979
58299
  }
57980
58300
  return data?.results || [];
57981
58301
  }
57982
- async fetchPaywallByUrl(url) {
57983
- return fetchWithCdnRetry(url);
57984
- }
57985
- async fetchPaywallsByUrls(urls) {
58302
+ /**
58303
+ * Resolves the URL set named by `campaign_rules` into paywall objects — the decision table of
58304
+ * `api-resilience-and-caching.md` § 2.2, run identically at startup and on `refresh()`:
58305
+ *
58306
+ * | URL | entry for the exact URL within TTL | action | provenance |
58307
+ * |-----------|------------------------------------|-------------------------------|--------------------------|
58308
+ * | hashed | yes | serve it, no request | `cacheHit` |
58309
+ * | hashed | no | fetch; store the 2xx body | `fetched` |
58310
+ * | un-hashed | not consulted | fetch; store the 2xx body | `fetched` |
58311
+ * | either | fetch failed, entry within TTL | serve it, keep it | `cacheAfterFetchFailure` |
58312
+ * | either | fetch failed, no entry | no body | counts `failed` |
58313
+ *
58314
+ * Writes the aggregate `api_paywalls` — as the URLs of every body this resolution produced, since
58315
+ * the bodies themselves are already in the body cache — unless it produced none, so an outage
58316
+ * cannot overwrite a good aggregate with an empty one. Eviction is the caller's: it runs only
58317
+ * after a complete `campaign_rules` fetch.
58318
+ */
58319
+ async resolvePaywallsByUrls(urls) {
57986
58320
  const uniqueUrls = [...new Set(urls)];
57987
- const results = await Promise.allSettled(uniqueUrls.map((url) => this.fetchPaywallByUrl(url)));
57988
- const paywalls = [];
57989
- let retryCount = 0;
57990
- for (const result of results) {
57991
- if (result.status === "fulfilled") {
57992
- paywalls.push(result.value.data);
57993
- retryCount += result.value.retryCount;
57994
- }
57995
- else {
57996
- // Note: an exhausted-failure path contributed retries we can't see
57997
- // from the rejected promise, so retryCount under-reports failures.
57998
- // Acceptable for the telemetry signal; revisit if full accounting matters.
57999
- logger.error(`Failed to fetch individual paywall: ${result.reason}`);
58321
+ const ttl = getCampaignCacheTtl();
58322
+ this.bodyCache.evaluateTtl(ttl);
58323
+ const resolutions = await Promise.all(uniqueUrls.map((url) => this.resolvePaywall(url, ttl)));
58324
+ const outcome = {
58325
+ paywalls: [],
58326
+ urls: uniqueUrls.length,
58327
+ hashed: uniqueUrls.filter((url) => contentHashOf(url) !== undefined).length,
58328
+ cacheHits: 0,
58329
+ fetched: 0,
58330
+ cacheAfterFetchFailure: 0,
58331
+ failed: 0,
58332
+ retryCount: 0,
58333
+ };
58334
+ const resolvedUrls = new Map();
58335
+ resolutions.forEach((resolution, i) => {
58336
+ outcome.retryCount += resolution.retryCount;
58337
+ if (!resolution.paywall) {
58338
+ outcome.failed += 1;
58339
+ return;
58000
58340
  }
58341
+ resolvedUrls.set(resolution.paywall, uniqueUrls[i]);
58342
+ if (resolution.paywall.fetchSource === "cacheHit")
58343
+ outcome.cacheHits += 1;
58344
+ else if (resolution.paywall.fetchSource === "cacheAfterFetchFailure")
58345
+ outcome.cacheAfterFetchFailure += 1;
58346
+ else
58347
+ outcome.fetched += 1;
58348
+ });
58349
+ outcome.paywalls = this.validatePaywalls([...resolvedUrls.keys()]);
58350
+ if (outcome.paywalls.length > 0) {
58351
+ storageService.setApiPaywallsFromBodyCache(outcome.paywalls.map((paywall) => resolvedUrls.get(paywall)));
58001
58352
  }
58002
- if (paywalls.length > 0) {
58003
- const valid = this.validatePaywalls(paywalls);
58004
- if (valid.length > 0) {
58005
- storageService.setPaywalls(exports.API_PAYWALLS, valid);
58353
+ return outcome;
58354
+ }
58355
+ /**
58356
+ * Seeds the body cache from the initial-config bundle (§ 2.2 "Seeding"): every **hashed** bundled
58357
+ * URL whose paywall id matches a bundled body is stored under that exact URL unless an entry
58358
+ * already exists. A hash in a bundled URL was minted against the bundled body, so the pair is
58359
+ * exact. Runs at configure, before the first resolution.
58360
+ */
58361
+ seedFromInitialConfig(campaignRules, paywalls) {
58362
+ const bundled = new Map(paywalls.map((paywall) => [paywall.id, paywall]));
58363
+ this.bodyCache.evaluateTtl(getCampaignCacheTtl());
58364
+ let candidates = 0;
58365
+ let seeded = 0;
58366
+ for (const url of new Set(bundledPaywallUrls(campaignRules))) {
58367
+ const contentHash = contentHashOf(url);
58368
+ const id = paywallIdOf(url);
58369
+ const paywall = id ? bundled.get(id) : undefined;
58370
+ if (!contentHash || !paywall)
58371
+ continue;
58372
+ candidates += 1;
58373
+ if (this.bodyCache.seedIfAbsent(url, contentHash, JSON.stringify(paywall)))
58374
+ seeded += 1;
58375
+ }
58376
+ logger.info(`Paywall body cache seeded from initial config: seeded=${seeded}, candidates=${candidates}`);
58377
+ }
58378
+ /** One row of the decision table. Never throws. */
58379
+ async resolvePaywall(url, ttl) {
58380
+ const contentHash = contentHashOf(url);
58381
+ if (contentHash) {
58382
+ const hit = this.decodeCached(url, ttl);
58383
+ if (hit)
58384
+ return { paywall: withProvenance(hit, "cacheHit", contentHash), retryCount: 0 };
58385
+ }
58386
+ let retryCount = 0;
58387
+ try {
58388
+ const response = await this.fetchPaywallText(url);
58389
+ retryCount = response.retryCount;
58390
+ const paywall = decodePaywall(response.data);
58391
+ if (paywall) {
58392
+ this.bodyCache.store(url, contentHash, response.data);
58393
+ return { paywall: withProvenance(paywall, "fetched", contentHash), retryCount };
58006
58394
  }
58007
- return { paywalls: valid, retryCount };
58395
+ logger.error(`Paywall body from ${url} is not a decodable paywall`);
58396
+ }
58397
+ catch (error) {
58398
+ // Note: an exhausted-failure path contributed retries we can't see from the thrown error,
58399
+ // so retry_count under-reports failures. Acceptable for the telemetry signal.
58400
+ logger.error(`Failed to fetch individual paywall: ${error}`);
58401
+ }
58402
+ const fallback = this.decodeCached(url, ttl);
58403
+ if (fallback) {
58404
+ logger.warn(`Serving the cached body for ${url} after fetch failure (within campaign_cache_ttl)`);
58405
+ return { paywall: withProvenance(fallback, "cacheAfterFetchFailure", contentHash), retryCount };
58008
58406
  }
58009
- return { paywalls, retryCount };
58407
+ return { retryCount };
58408
+ }
58409
+ /** The stored body for this exact URL, decoded; an undecodable one is deleted (not eviction). */
58410
+ decodeCached(url, ttl) {
58411
+ const entry = this.bodyCache.validEntry(url, ttl);
58412
+ if (!entry)
58413
+ return undefined;
58414
+ const paywall = decodePaywall(entry.body);
58415
+ if (!paywall) {
58416
+ logger.warn(`Deleting an undecodable cached paywall body for ${url}`);
58417
+ this.bodyCache.remove(url);
58418
+ }
58419
+ return paywall;
58420
+ }
58421
+ fetchPaywallText(url) {
58422
+ if (storageService.getNamiConfig()?.namiCommands?.includes(exports.SIMULATE_PAYWALL_FETCH_FAILURE)) {
58423
+ logger.warn(`${exports.SIMULATE_PAYWALL_FETCH_FAILURE}: failing the fetch for ${url} before the network`);
58424
+ return Promise.reject(new RetryExhaustedError(url, 0));
58425
+ }
58426
+ return fetchTextWithCdnRetry(url);
58010
58427
  }
58011
58428
  validatePaywalls(paywalls) {
58012
58429
  return paywalls.filter((paywall) => {
@@ -58031,29 +58448,154 @@ class PaywallRepository {
58031
58448
  }
58032
58449
  }
58033
58450
  PaywallRepository.instance = new PaywallRepository();
58451
+ function decodePaywall(text) {
58452
+ try {
58453
+ const value = JSON.parse(text);
58454
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
58455
+ }
58456
+ catch {
58457
+ return undefined;
58458
+ }
58459
+ }
58460
+ function withProvenance(paywall, fetchSource, contentHash) {
58461
+ return { ...paywall, fetchSource, ...(contentHash ? { contentHash } : {}) };
58462
+ }
58463
+ /** Every paywall URL a bundled (anonymous-format) rule names, across all of its segments. */
58464
+ function bundledPaywallUrls(campaignRules) {
58465
+ const urls = [];
58466
+ for (const rule of campaignRules) {
58467
+ for (const segment of rule.segments ?? []) {
58468
+ if (segment.paywall_url)
58469
+ urls.push(segment.paywall_url);
58470
+ if (segment.page_urls)
58471
+ urls.push(...Object.values(segment.page_urls));
58472
+ if (segment.flow?.page_urls)
58473
+ urls.push(...Object.values(segment.flow.page_urls));
58474
+ }
58475
+ }
58476
+ return urls;
58477
+ }
58034
58478
 
58035
58479
  class ConfigRepository {
58480
+ /**
58481
+ * One `GET config/` attempt. Persists the config on success and throws on any failure —
58482
+ * a non-2xx or a transport error alike. `withRetry`'s own retries are turned off, so the
58483
+ * attempt count belongs to {@link fetchConfigForStartup} alone.
58484
+ */
58036
58485
  async fetchConfig() {
58037
- let data;
58038
- try {
58039
- data = await NamiAPI.instance.fetchAPI("config/");
58040
- ;
58041
- }
58042
- catch (error) {
58043
- data = storageService.getAppConfig(exports.API_CONFIG)
58044
- || storageService.getAppConfig(exports.INITIAL_APP_CONFIG);
58045
- if (!data) {
58046
- logger.error(handleErrors(error.status, "config/"));
58047
- throw error;
58048
- }
58486
+ // Thrown from *inside* the attempt on purpose, like Apple's and Android's: the ladder then
58487
+ // treats it exactly as a real 500, which is the whole point of the mock.
58488
+ if (storageService.getNamiConfig()?.namiCommands?.includes(exports.SIMULATE_CONFIG_FETCH_FAILURE)) {
58489
+ throw new RetryLimitExceededError(exports.STATUS_INTERNAL_SERVER_ERROR, exports.SIMULATE_CONFIG_FETCH_FAILURE);
58049
58490
  }
58050
- if (data) {
58491
+ const platformId = storageService.getNamiConfig()?.appPlatformID;
58492
+ const data = await NamiAPI.instance.fetchAPI("config/", exports.CONFIG_API_TIMEOUT_LIMIT, 0);
58493
+ // A late answer (the startup await gave up on it) must not overwrite another platform's
58494
+ // config after a reset or a platform-switching reconfigure.
58495
+ if (storageService.getNamiConfig()?.appPlatformID === platformId) {
58051
58496
  storageService.setAppConfig(exports.API_CONFIG, data);
58052
58497
  }
58498
+ else {
58499
+ logger.debug("Discarding an app config response for a platform that is no longer configured");
58500
+ }
58053
58501
  return data;
58054
58502
  }
58503
+ /**
58504
+ * The startup platform-config step, converged on Apple's `NamiPlatformConfigService.fetch` and
58505
+ * Android's `fetchAppConfigOnce` (NAM-4470, NAM-4589):
58506
+ *
58507
+ * | case | outcome |
58508
+ * |---|---|
58509
+ * | an initial config was supplied | NOT awaited: one attempt runs in the background and stores the config when it lands; usable |
58510
+ * | no initial config, success, capabilities include `sdk` | usable |
58511
+ * | no initial config, success, capabilities lack `sdk` | not usable |
58512
+ * | no initial config, failure | {@link CONFIG_MAX_ATTEMPTS} attempts of up to {@link CONFIG_API_TIMEOUT_LIMIT} each, then usable only if a config is cached (and grants `sdk`) |
58513
+ *
58514
+ * The two fallbacks are consulted at two different points, deliberately. An initial config
58515
+ * decides the case *before* any request: startup does not wait on the network at all, since the
58516
+ * initial config is what makes the SDK ready without it (Apple: "Initial config is available.
58517
+ * Skipping retry."). The config cached by a previous launch is consulted only *after* the
58518
+ * retries run out. A stored `INITIAL_APP_CONFIG` is not that cache.
58519
+ *
58520
+ * One check does apply with an initial config, because it costs no wait: a cached config that
58521
+ * withholds `sdk` still stops startup, as Apple's cache load re-applies `updateSdkFlags`.
58522
+ * Without it a kill switch could never reach an app that ships an initial config — the
58523
+ * background fetch lands after startup has already run, so it takes effect from the next launch.
58524
+ *
58525
+ * A `404` is treated as any other 4xx, matching Apple's effective behaviour (its dedicated 404
58526
+ * branch is unreachable, NAM-4471) and Android's.
58527
+ *
58528
+ * @param hasInitialConfig whether the host passed an `initialConfig` that decoded to content.
58529
+ */
58530
+ async fetchConfigForStartup(hasInitialConfig) {
58531
+ if (hasInitialConfig)
58532
+ return this.startupOnInitialConfig();
58533
+ const maxAttempts = exports.CONFIG_MAX_ATTEMPTS;
58534
+ let lastError;
58535
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
58536
+ if (attempt > 1) {
58537
+ logger.debug(`Retrying app config fetch (attempt ${attempt} of ${maxAttempts})`);
58538
+ }
58539
+ try {
58540
+ return outcomeOf(await this.fetchConfig());
58541
+ }
58542
+ catch (error) {
58543
+ lastError = error;
58544
+ }
58545
+ }
58546
+ const cached = storageService.getAppConfig(exports.API_CONFIG);
58547
+ if (cached) {
58548
+ // A cached kill switch still holds: Apple re-applies `updateSdkFlags` when it loads the
58549
+ // cached config, so one that withheld `sdk` keeps the SDK disabled through an outage.
58550
+ if (!grantsSdk(cached)) {
58551
+ return { usable: false, reason: "the cached app config does not grant the 'sdk' capability" };
58552
+ }
58553
+ logger.info(`App config fetch failed after ${maxAttempts} attempts - continuing on the cached config`);
58554
+ return { usable: true };
58555
+ }
58556
+ return {
58557
+ usable: false,
58558
+ reason: `app config fetch failed after ${maxAttempts} attempts (${describeFailure(lastError)}) ` +
58559
+ "and no initial or cached config is available",
58560
+ };
58561
+ }
58562
+ /**
58563
+ * The initial-config case: nothing is awaited. The cached kill switch is a storage read; the
58564
+ * config request is left running and persists its answer (`fetchConfig`) when it lands.
58565
+ */
58566
+ startupOnInitialConfig() {
58567
+ // Read before the request can land, so this launch is judged on what the last one stored.
58568
+ const cached = storageService.getAppConfig(exports.API_CONFIG);
58569
+ // Issued even when the cache holds a kill switch: that is how a LIFTED kill switch reaches
58570
+ // the next launch.
58571
+ this.fetchConfig().then((config) => {
58572
+ if (!grantsSdk(config)) {
58573
+ logger.warn("App config does not grant the 'sdk' capability - it takes effect from the next launch");
58574
+ }
58575
+ }, (error) => {
58576
+ logger.info(`App config fetch failed (${describeFailure(error)}). Initial config is available. Skipping retry.`);
58577
+ });
58578
+ if (cached && !grantsSdk(cached)) {
58579
+ return { usable: false, reason: "the cached app config does not grant the 'sdk' capability" };
58580
+ }
58581
+ return { usable: true };
58582
+ }
58055
58583
  }
58056
58584
  ConfigRepository.instance = new ConfigRepository();
58585
+ function outcomeOf(config) {
58586
+ return grantsSdk(config)
58587
+ ? { usable: true }
58588
+ : { usable: false, reason: "app config does not grant the 'sdk' capability" };
58589
+ }
58590
+ function grantsSdk(config) {
58591
+ return config.capabilities?.includes(exports.Capabilities.SDK) ?? false;
58592
+ }
58593
+ function describeFailure(error) {
58594
+ const status = error?.status;
58595
+ if (typeof status === "number")
58596
+ return `HTTP ${status}`;
58597
+ return error instanceof Error ? error.message : String(error);
58598
+ }
58057
58599
 
58058
58600
  // Global reference to avoid circular imports
58059
58601
  let namiRefsInstance = null;
@@ -61597,6 +62139,15 @@ class NamiRefs {
61597
62139
  this.inMemoryAnonymousMode = false;
61598
62140
  // Captured before device registration; true only on genuine fresh installs
61599
62141
  this.isFirstSession = false;
62142
+ // Set when the platform-config step leaves no usable config (see isStartupDisabled)
62143
+ this.startupDisabled = false;
62144
+ // Whether this configure() supplied an initial config (the config ladder's early exit)
62145
+ this.hasInitialConfig = false;
62146
+ // Resume bookkeeping: the configuration startup last ran with, when it (or a resume) last
62147
+ // ran, and whether one is running now.
62148
+ this.lastConfig = null;
62149
+ this.lastStartupRunAt = 0;
62150
+ this.startupInFlight = false;
61600
62151
  // Register this instance to avoid circular dependency issues
61601
62152
  setNamiRefsInstance(this);
61602
62153
  }
@@ -61614,13 +62165,101 @@ class NamiRefs {
61614
62165
  getIsFirstSession() {
61615
62166
  return this.isFirstSession;
61616
62167
  }
62168
+ /**
62169
+ * True when the platform-config step found no usable config and startup stopped after it —
62170
+ * the equivalent of Apple's `sdkEnabled = false` and Android's `NamiRefs.startupDisabled`.
62171
+ * Like both, it gates only the startup sequence, nothing else.
62172
+ */
62173
+ isStartupDisabled() {
62174
+ return this.startupDisabled;
62175
+ }
61617
62176
  async init(config) {
61618
62177
  this.isFirstSession = storageService.getDevice() === null && !storageService.getAnonymousMode();
62178
+ // Cleared per configure so a transient failure cannot disable a later run.
62179
+ this.startupDisabled = false;
62180
+ // `Nami.configure` has already normalised a missing or undecodable initialConfig to `{}`,
62181
+ // so "has content" is Apple's `initialConfig != nil`.
62182
+ this.hasInitialConfig = Object.keys(config.initialConfig ?? {}).length > 0;
61619
62183
  this.setInitialValues(config);
61620
- ConfigRepository.instance.fetchConfig();
62184
+ this.lastConfig = config;
62185
+ this.lastStartupRunAt = Date.now();
62186
+ this.startupInFlight = true;
62187
+ try {
62188
+ if (!(await this.fetchPlatformConfig()))
62189
+ return;
62190
+ await this.runStartupAfterConfig(config);
62191
+ }
62192
+ finally {
62193
+ this.startupInFlight = false;
62194
+ }
62195
+ }
62196
+ /**
62197
+ * The app returned to the foreground. Re-reads the platform config, as Apple's `.appResume`
62198
+ * and Android's `onAppComesFromBackground` do, so a config change — the `sdk` kill switch
62199
+ * included — reaches a long-lived tab or app without a cold start.
62200
+ *
62201
+ * - Skipped within {@link RESUME_THROTTLE_MS} of the last launch/resume, and while one is
62202
+ * still running.
62203
+ * - When the launch had **stopped** for want of a config and one is now usable, the rest of
62204
+ * startup runs — the steps an Apple resume re-runs because the launch never executed them.
62205
+ * - Otherwise this is a config refresh only. Apple and Android also re-run device, campaigns,
62206
+ * profile, products and the session start on resume; core does not yet, and has no
62207
+ * session end on background for a second session start to pair with (NAM-4623).
62208
+ */
62209
+ async resume() {
62210
+ if (!this.lastConfig || this.startupInFlight)
62211
+ return;
62212
+ if (Date.now() - this.lastStartupRunAt < exports.RESUME_THROTTLE_MS) {
62213
+ logger.debug("Resuming too quickly to call the service.");
62214
+ return;
62215
+ }
62216
+ const config = this.lastConfig;
62217
+ const wasStopped = this.startupDisabled;
62218
+ this.lastStartupRunAt = Date.now();
62219
+ this.startupInFlight = true;
62220
+ this.startupDisabled = false;
62221
+ try {
62222
+ if (!(await this.fetchPlatformConfig()) || !wasStopped)
62223
+ return;
62224
+ logger.info("Usable app config on resume - completing the startup that stopped at launch");
62225
+ await this.runStartupAfterConfig(config);
62226
+ }
62227
+ catch (error) {
62228
+ logger.warn(`Resume refresh failed: ${error}`);
62229
+ }
62230
+ finally {
62231
+ this.startupInFlight = false;
62232
+ }
62233
+ }
62234
+ /** Everything startup runs once the platform-config step has found a usable config. */
62235
+ async runStartupAfterConfig(config) {
61621
62236
  await this.initAndFetchRequiredData(config);
61622
62237
  SessionService.instance.startSession();
61623
62238
  }
62239
+ /** Forgets the configuration a resume would run against; called by `Nami.reset()`. */
62240
+ clearResumeState() {
62241
+ this.lastConfig = null;
62242
+ this.lastStartupRunAt = 0;
62243
+ this.startupDisabled = false;
62244
+ }
62245
+ /**
62246
+ * The platform-config startup step, ahead of device registration — the position it holds on
62247
+ * Apple (`.fetchPlatformConfig`) and Android (`fetchAppConfigOnce`) (NAM-4464, NAM-4589).
62248
+ * Without an initial config it is awaited, so everything downstream — `campaign_cache_ttl`
62249
+ * included — is evaluated against the config it leaves in storage. With one it resolves at
62250
+ * once and the request finishes in the background (see `fetchConfigForStartup`).
62251
+ *
62252
+ * @returns false when there is no usable config, in which case startup must stop.
62253
+ */
62254
+ async fetchPlatformConfig() {
62255
+ const outcome = await ConfigRepository.instance.fetchConfigForStartup(this.hasInitialConfig);
62256
+ if (!outcome.usable) {
62257
+ this.startupDisabled = true;
62258
+ logger.error(`Nami startup stopped: ${outcome.reason}`);
62259
+ return false;
62260
+ }
62261
+ return true;
62262
+ }
61624
62263
  setInitialValues(config) {
61625
62264
  const baseConfig = { ...config };
61626
62265
  delete baseConfig.initialConfig;
@@ -61636,7 +62275,10 @@ class NamiRefs {
61636
62275
  }
61637
62276
  }
61638
62277
  if (initconfig?.paywalls) {
61639
- storageService.setPaywalls(exports.INITIAL_PAYWALLS, initconfig.paywalls);
62278
+ storageService.setPaywalls(exports.INITIAL_PAYWALLS, initconfig.paywalls.map((paywall) => ({ ...paywall, fetchSource: "initialConfig" })));
62279
+ if (initconfig.campaign_rules?.length) {
62280
+ PaywallRepository.instance.seedFromInitialConfig(initconfig.campaign_rules, initconfig.paywalls);
62281
+ }
61640
62282
  }
61641
62283
  if (initconfig?.products) {
61642
62284
  storageService.setProducts(exports.INITIAL_PRODUCTS, initconfig.products);
@@ -61703,7 +62345,7 @@ class NamiRefs {
61703
62345
  const paywallRepo = PaywallRepository.instance;
61704
62346
  const startTime = Date.now();
61705
62347
  const campaignRulesStartTime = Date.now();
61706
- const rawCampaigns = await campaignRepo.fetchCampaignRulesRaw();
62348
+ const { campaigns: rawCampaigns, fromNetwork } = await campaignRepo.fetchCampaignRulesRaw();
61707
62349
  const campaignRulesDuration = Date.now() - campaignRulesStartTime;
61708
62350
  // Hydrate flow objects: fetch flow JSON from flow.url for campaigns that
61709
62351
  // use the URL-based format (4.0.0+ since NAM-3493) instead of inline flow.object
@@ -61724,24 +62366,35 @@ class NamiRefs {
61724
62366
  const useIndividual = !campaignRepo.useLegacyPaywallFetch &&
61725
62367
  CampaignRuleRepository.hasPaywallUrls(rawCampaigns);
61726
62368
  let paywalls;
61727
- let retryCount = 0;
62369
+ let outcome = null;
61728
62370
  const paywallsStartTime = Date.now();
61729
- if (useIndividual) {
62371
+ if (useIndividual && !fromNetwork) {
62372
+ // campaign_rules came from the stored copy, so this is not a complete page set: no
62373
+ // resolution and no eviction (api-resilience-and-caching.md § 2.2). The aggregate serves.
62374
+ logger.info("Paywall resolution skipped: campaign_rules were served from the stored copy");
62375
+ paywalls = getApiPaywalls();
62376
+ }
62377
+ else if (useIndividual) {
61730
62378
  const urls = CampaignRuleRepository.extractPaywallUrls(rawCampaigns);
61731
- const result = await paywallRepo.fetchPaywallsByUrls(urls);
61732
- paywalls = result.paywalls;
61733
- retryCount = result.retryCount;
62379
+ outcome = await paywallRepo.resolvePaywallsByUrls(urls);
62380
+ paywalls = outcome.paywalls;
62381
+ paywallBodyCache.reconcile(new Set(urls));
61734
62382
  }
61735
62383
  else {
61736
62384
  paywalls = await paywallRepo.fetchPaywalls();
61737
62385
  }
61738
62386
  const paywallsDuration = Date.now() - paywallsStartTime;
61739
62387
  const totalDuration = Date.now() - startTime;
61740
- if (Nami.instance.maxLogging) {
61741
- logger.info(`Paywall fetch telemetry: strategy=${useIndividual ? "individual" : "bulk"}, ` +
61742
- `count=${paywalls.length}, total=${totalDuration}ms, ` +
62388
+ if (!useIndividual || outcome) {
62389
+ // The line every SDK emits once per resolution; the paywall-cache e2e suites parse it.
62390
+ const cacheFields = outcome
62391
+ ? `, urls=${outcome.urls}, hashed=${outcome.hashed}, cache_hits=${outcome.cacheHits}, ` +
62392
+ `fetched=${outcome.fetched}, cache_after_fetch_failure=${outcome.cacheAfterFetchFailure}, failed=${outcome.failed}`
62393
+ : "";
62394
+ logger.debug(`Paywall fetch telemetry: strategy=${useIndividual ? "individual" : "bulk"}, ` +
62395
+ `count=${paywalls.length}${cacheFields}, total=${totalDuration}ms, ` +
61743
62396
  `campaigns=${campaignRulesDuration}ms, paywalls=${paywallsDuration}ms` +
61744
- `${useIndividual ? `, retry_count=${retryCount}` : ""}`);
62397
+ `${outcome ? `, retry_count=${outcome.retryCount}` : ""}`);
61745
62398
  }
61746
62399
  return campaignRepo.finalizeCampaignRules(rawCampaigns, paywalls);
61747
62400
  }
@@ -61881,7 +62534,7 @@ class LastLaunchStore {
61881
62534
  }
61882
62535
  }
61883
62536
 
61884
- var _Nami_isInitialized;
62537
+ var _Nami_isInitialized, _Nami_removeForegroundListener;
61885
62538
  // NamiFlowManager is intentionally NOT imported at top level — it
61886
62539
  // transitively imports back to this module (`Nami` for logging), and
61887
62540
  // pulling it in here causes a load-order cycle that breaks tests
@@ -61889,6 +62542,7 @@ var _Nami_isInitialized;
61889
62542
  class Nami {
61890
62543
  constructor() {
61891
62544
  _Nami_isInitialized.set(this, false);
62545
+ _Nami_removeForegroundListener.set(this, null);
61892
62546
  this.maxLogging = false;
61893
62547
  }
61894
62548
  get isInitialized() {
@@ -61912,9 +62566,15 @@ class Nami {
61912
62566
  * integrating app, consistent with the Apple and Android SDKs.
61913
62567
  */
61914
62568
  static async reset() {
62569
+ var _a;
61915
62570
  storageService.clearAll();
62571
+ // The per-URL paywall body cache keeps its own prefixed keys and an in-memory index (NAM-4357).
62572
+ paywallBodyCache.purgeAll();
61916
62573
  clearInMemoryAnonymousMode();
61917
62574
  __classPrivateFieldSet(Nami.instance, _Nami_isInitialized, false, "f");
62575
+ __classPrivateFieldGet((_a = Nami.instance), _Nami_removeForegroundListener, "f")?.call(_a);
62576
+ __classPrivateFieldSet(Nami.instance, _Nami_removeForegroundListener, null, "f");
62577
+ NamiRefs.instance.clearResumeState();
61918
62578
  // In-memory singleton state that previously survived reset and
61919
62579
  // bled into the next configure() cycle.
61920
62580
  NamiProfileManager$1.instance.setExternalId(undefined);
@@ -61977,6 +62637,18 @@ class Nami {
61977
62637
  }
61978
62638
  config.initialConfig = {};
61979
62639
  }
62640
+ /**
62641
+ * Subscribes the resume config refresh to the host's foreground signal, once per process —
62642
+ * a reconfigure must not stack a second listener (the Android defect NAM-4541 fixed).
62643
+ * Registered before startup runs; `resume()` itself skips while startup is in flight.
62644
+ */
62645
+ listenForAppForeground() {
62646
+ if (__classPrivateFieldGet(this, _Nami_removeForegroundListener, "f"))
62647
+ return;
62648
+ __classPrivateFieldSet(this, _Nami_removeForegroundListener, getPlatformAdapters().device.onAppForeground?.(() => {
62649
+ void NamiRefs.instance.resume();
62650
+ }) ?? null, "f");
62651
+ }
61980
62652
  async initializeSDK(config) {
61981
62653
  let fullReconfig = false;
61982
62654
  let partialReconfig = false;
@@ -62017,6 +62689,7 @@ class Nami {
62017
62689
  // configure SDK with new config
62018
62690
  __classPrivateFieldSet(this, _Nami_isInitialized, true, "f");
62019
62691
  NamiAPI.configure(config);
62692
+ this.listenForAppForeground();
62020
62693
  await NamiRefs.instance.init(config);
62021
62694
  logger.info("SDK successfully initialized!");
62022
62695
  const state = partialReconfig || fullReconfig ? exports.RECONFIG_SUCCESS : exports.INITIAL_SUCCESS;
@@ -62028,7 +62701,7 @@ class Nami {
62028
62701
  };
62029
62702
  }
62030
62703
  }
62031
- _Nami_isInitialized = new WeakMap();
62704
+ _Nami_isInitialized = new WeakMap(), _Nami_removeForegroundListener = new WeakMap();
62032
62705
  Nami.instance = new Nami();
62033
62706
 
62034
62707
  const VALID_STYLES = new Set(['fullscreen', 'sheet', 'compact_sheet', 'modal']);