diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d3aba..f2d6b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `applySignedPinBundle(bundle, { publicKey })` — the verify-and-apply half of + `updatePinsFromUrl`, for callers who fetch the signed bundle themselves + (custom networking/caching, push-delivered bundles, offline tests). + +### Changed +- **Slimmer published package** — the npm tarball now ships only the runtime + scripts (`cli`, `cli-utils`, `postinstall`, `postinstall-lib`, + `bun-postinstall`, `nsc-utils`) via an explicit `files` allowlist. Dev/test/ + maintainer scripts moved to `scripts/dev/` and are no longer published. A + `pack-contents` test guards against re-bloat. +- **Docs** — rewrote the OTA section with the signed-bundle format, the + `OtaError` code table, the `applySignedPinBundle` / `verifyOtaBundle` + + `setSSLConfig` lower-level paths, and a clarification that `isExpired` / + `expirationDate` (config fail-open) are unrelated to OTA bundle freshness + (`expiresAt` / `maxAgeMs`). + +### Removed +- The `monorepo-setup` CLI command and its `monorepo-setup.js` / `monorepo-utils.js` + helpers (plus the `pnpm-monorepo` fixture). Monorepo/pnpm support is unchanged + — it lives in the postinstall safety logic; see the README "Monorepo & pnpm" + section for the resolved-path Gradle snippet. + ## [2.1.0] - 2026-07-19 ### Fixed diff --git a/README.md b/README.md index 13203ed..0e7e561 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,6 @@ npx react-native-ssl-manager | `pins ` | Print live SPKI pins + config snippet | | `pins --pem cert.pem` | Pin from a local PEM | | `verify [--config …]` | Fail CI when live chain matches none of the pins | -| `monorepo-setup` | Validate monorepo / pnpm layout + Gradle snippet | | `keygen` / `sign` | Author signed OTA pin bundles | --- @@ -293,18 +292,25 @@ cd apps/your-app npx expo prebuild --clean ``` -```sh -# Recommended in monorepos / pnpm isolated installs -export SSL_MANAGER_SKIP_POSTINSTALL=1 -``` +The postinstall step detects monorepo / pnpm-isolated `node_modules` and skips +the brittle Gradle `apply from` line automatically. You can opt out entirely: ```sh -npx react-native-ssl-manager monorepo-setup +# Skip postinstall (e.g. CI, or you wire Gradle up yourself) +export SSL_MANAGER_SKIP_POSTINSTALL=1 ``` -Bare Android (no Expo): use the Gradle snippet from `monorepo-setup` (Node `require.resolve` works with pnpm). +**Bare Android (no Expo)** in a monorepo: reference the plugin by resolved path +in `android/app/build.gradle` — `require.resolve` works across pnpm/hoisted +layouts: -Fixture: [`fixtures/pnpm-monorepo`](./fixtures/pnpm-monorepo). +```gradle +apply from: new File( + ["node", "-e", "process.stdout.write(require.resolve('react-native-ssl-manager/package.json'))"] + .execute().text.trim(), + "../android/ssl-pinning-setup.gradle" +) +``` --- @@ -317,7 +323,7 @@ Fixture: [`fixtures/pnpm-monorepo`](./fixtures/pnpm-monorepo). | iOS pin toggle does nothing | Kill app and relaunch | | Expo Xcode error adding `ssl_config.json` | Upgrade library; `npx expo prebuild --clean` | | Metro fails on Android debug | Keep localhost / `10.0.2.2` cleartext in NSC | -| pnpm Android path issues | Expo plugin or `monorepo-setup`; skip postinstall | +| pnpm Android path issues | Use the Expo plugin, or the resolved-path Gradle snippet; skip postinstall | --- @@ -331,7 +337,9 @@ Fixture: [`fixtures/pnpm-monorepo`](./fixtures/pnpm-monorepo). | `setSSLConfig(config \| string): Promise` | Runtime config (iOS: next launch) | | `getPinnedDomains(): Promise` | Active domains | | `addPinningFailureListener(fn): () => void` | Subscribe; returns unsubscribe | -| `updatePinsFromUrl(url, { publicKey }): Promise` | Apply signed OTA bundle | +| `updatePinsFromUrl(url, { publicKey }): Promise` | Fetch + verify + apply a signed OTA bundle | +| `applySignedPinBundle(bundle, { publicKey }): Promise` | Verify + apply a bundle you already fetched | +| `isExpired(date, now): boolean` | Helper: has a `YYYY-MM-DD` `expirationDate` passed? |
TypeScript types @@ -365,27 +373,120 @@ interface PinningFailureEvent { ## Advanced
-OTA pin rotation +OTA pin rotation (signed bundles) + +Rotate pins **without shipping an app update**. You publish a small JSON bundle, +signed with an Ed25519 key; the app fetches it and applies it only if the +signature (and freshness) check out. The private key stays offline — only the +**public** key is embedded in the app. + +**Step 1 — generate a keypair once (offline / CI secret):** ```sh npx react-native-ssl-manager keygen +# → writes ssl-manager-ota.key.pem (PRIVATE — keep it out of git) +# → prints the public key (base64) to embed in the app +``` + +**Step 2 — sign your config into a bundle (in CI, on each rotation):** + +```sh npx react-native-ssl-manager sign \ --config ssl_config.json \ --key ssl-manager-ota.key.pem \ --expires-in 30d \ --out ssl-pins-bundle.json +# host ssl-pins-bundle.json on any HTTPS URL (CDN, S3, your API) +``` + +The bundle is just signed JSON — nothing secret, safe to serve publicly: + +```json +{ + "payload": "", + "signature": "" +} +``` + +```jsonc +// the decoded payload +{ + "version": 1, + "issuedAt": "2026-07-21T10:00:00.000Z", + "expiresAt": "2026-08-20T10:00:00.000Z", // optional + "config": { "sha256Keys": { "api.example.com": ["sha256/…=", "sha256/…="] } } +} ``` +**Step 3 — apply it from the app:** + ```ts import { updatePinsFromUrl } from 'react-native-ssl-manager' await updatePinsFromUrl('https://cdn.example.com/ssl-pins-bundle.json', { - publicKey: '…', // from keygen — safe to ship in the app - maxAgeMs: 7 * 24 * 3600 * 1000, + publicKey: 'Z8S8T6o…=', // from `keygen` — safe to ship in the app + maxAgeMs: 7 * 24 * 3600 * 1000, // also reject bundles older than 7 days }) ``` -Tampered / expired / rolled-back bundles are rejected; the active config is unchanged. +`updatePinsFromUrl` = **fetch → verify → apply**. If you'd rather control the +networking (caching, retries, a bundle delivered over your own channel or a push +payload), fetch the bundle yourself and call the verify-and-apply half directly: + +```ts +import { applySignedPinBundle } from 'react-native-ssl-manager' + +const bundle = await myTransport.getPinBundle() // { payload, signature } +await applySignedPinBundle(bundle, { publicKey: 'Z8S8T6o…=' }) +``` + +Both verify the Ed25519 signature against `publicKey`, then check freshness +(`expiresAt` / `maxAgeMs`) and reject a bundle older than the last one applied +this session (anti-rollback). **On any failure the active config is left +untouched** and the call rejects with an `OtaError.code`: + +| Code | Meaning | +|------|---------| +| `OTA_FETCH_FAILED` | Couldn't download the bundle (network / HTTP error) | +| `OTA_INVALID_BUNDLE` | Malformed JSON / base64 | +| `OTA_INVALID_SIGNATURE` | Signature doesn't match `publicKey` | +| `OTA_EXPIRED` | Past `expiresAt`, or older than `maxAgeMs` | +| `OTA_ROLLBACK` | Older than the bundle already applied this session | + +> **Lower level still?** `verifyOtaBundle(bundle, { publicKey })` (from the same +> package) verifies and returns the config **without touching pinning**, so you +> can apply it yourself via `setSSLConfig(config)`. That's the exact seam if you +> prefer your own crypto/transport around a plain `setSSLConfig`. + +
+ +
+`expirationDate` vs OTA freshness — two different clocks + +These sound similar but are unrelated: + +- **`domains..expirationDate`** (in `ssl_config.json`) is a **per-domain + fail-open date**: after it passes, pinning for that host stops being enforced + so an abandoned install never bricks. The exported **`isExpired(date, now)`** + helper just tells you whether such a date has passed — use it for your own UI / + telemetry, e.g. to nudge a rotation: + + ```ts + import { isExpired } from 'react-native-ssl-manager' + + const expiresOn = '2026-12-31' + if (isExpired(expiresOn, Date.now())) { + // pinning for this domain has failed open — fetch fresh pins + await updatePinsFromUrl(BUNDLE_URL, { publicKey: OTA_PUBLIC_KEY }) + } + ``` + +- **OTA freshness** (`expiresAt` / `maxAgeMs` on a *signed bundle*) is about how + old an OTA **update** may be before `updatePinsFromUrl` refuses it. It has + nothing to do with a domain's `expirationDate`. + +In short: `isExpired` inspects your **config**; `maxAgeMs`/`expiresAt` gate an +**OTA bundle**.
diff --git a/__tests__/monorepo-fixture.test.js b/__tests__/monorepo-fixture.test.js deleted file mode 100644 index d908ec7..0000000 --- a/__tests__/monorepo-fixture.test.js +++ /dev/null @@ -1,220 +0,0 @@ -/** - * Monorepo / pnpm fixture + utils. - * - * Proves workspace detection, app-root resolution, gradle apply snippets, - * Expo plugin registration in the fixture, and that postinstall does not - * mutate android/app/build.gradle under monorepo + pnpm-isolated layouts. - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); -const { - findWorkspaceRoot, - findAppRoot, - resolveSslManagerDir, - getGradleApplySnippet, - getGradleApplySnippetNodeResolve, - sslConfigSearchCandidates, - findSslConfigPath, - validateMonorepoAppLayout, -} = require('../scripts/monorepo-utils'); -const postinstall = require('../scripts/postinstall-lib'); - -const root = path.join(__dirname, '..'); -const fixtureRoot = path.join(root, 'fixtures', 'pnpm-monorepo'); -const fixtureApp = path.join(fixtureRoot, 'apps', 'mobile'); - -describe('fixtures/pnpm-monorepo structure', () => { - it('has pnpm workspace + isolated linker config', () => { - expect(fs.existsSync(path.join(fixtureRoot, 'pnpm-workspace.yaml'))).toBe( - true - ); - const npmrc = fs.readFileSync(path.join(fixtureRoot, '.npmrc'), 'utf8'); - expect(npmrc).toMatch(/node-linker\s*=\s*isolated/); - }); - - it('app package depends on the library + nitro + registers Expo plugin', () => { - const pkg = JSON.parse( - fs.readFileSync(path.join(fixtureApp, 'package.json'), 'utf8') - ); - expect(pkg.dependencies['react-native-ssl-manager']).toMatch(/file:/); - expect(pkg.dependencies['react-native-nitro-modules']).toBeTruthy(); - expect(pkg.dependencies.expo).toBeTruthy(); - - const appJson = JSON.parse( - fs.readFileSync(path.join(fixtureApp, 'app.json'), 'utf8') - ); - const plugins = appJson.expo.plugins; - const ssl = plugins.find( - (p) => - p === 'react-native-ssl-manager' || - (Array.isArray(p) && p[0] === 'react-native-ssl-manager') - ); - expect(ssl).toBeTruthy(); - expect(Array.isArray(ssl) ? ssl[1].sslConfigPath : true).toBeTruthy(); - }); - - it('ships ssl_config.json and android stub', () => { - expect(fs.existsSync(path.join(fixtureApp, 'ssl_config.json'))).toBe(true); - expect( - fs.existsSync(path.join(fixtureApp, 'android', 'app', 'build.gradle')) - ).toBe(true); - }); -}); - -describe('monorepo-utils against fixture', () => { - it('finds workspace root and app root', () => { - expect(findWorkspaceRoot(fixtureApp)).toBe(fixtureRoot); - expect(findAppRoot(fixtureApp)).toBe(fixtureApp); - expect(findAppRoot(path.join(fixtureApp, 'android', 'app'))).toBe( - fixtureApp - ); - }); - - it('finds ssl_config at the app package root', () => { - const cfg = findSslConfigPath(fixtureApp); - expect(cfg).toBe(path.join(fixtureApp, 'ssl_config.json')); - const candidates = sslConfigSearchCandidates(fixtureApp); - expect(candidates[0]).toBe(path.join(fixtureApp, 'ssl_config.json')); - }); - - it('resolves the library from the repo (file: dependency target)', () => { - // From library root (where this test runs), package resolves to itself. - const dir = resolveSslManagerDir(root); - expect(dir).toBe(root); - }); - - it('builds a relative Gradle apply line from android/app', () => { - const androidApp = path.join(fixtureApp, 'android', 'app'); - // Resolve library from repo root (simulates linked install). - const snippet = getGradleApplySnippet(androidApp, root); - expect(snippet.ok).toBe(true); - expect(snippet.line).toMatch(/^apply from: "/); - expect(snippet.line).toContain('ssl-pinning-setup.gradle'); - // Must not be the old brittle hardcoded path only - expect(snippet.gradleScriptPath).toContain( - path.join('android', 'ssl-pinning-setup.gradle') - ); - }); - - it('exposes Node-resolve Gradle snippet for pnpm', () => { - const text = getGradleApplySnippetNodeResolve(); - expect(text).toContain('require.resolve'); - expect(text).toContain('ssl-pinning-setup.gradle'); - expect(text).toContain('apply from: file'); - }); - - it('validateMonorepoAppLayout passes on the fixture when package resolves', () => { - // Run from fixture app but resolution uses require paths from process — - // seed NODE path by resolving from root via a manual check. - const result = validateMonorepoAppLayout(fixtureApp); - // dep.ssl-manager, expo.plugin, ssl_config, workspace should pass. - // resolve.package may fail if node can't resolve from fixture without install. - const byId = Object.fromEntries(result.checks.map((c) => [c.id, c])); - expect(byId['package.json'].ok).toBe(true); - expect(byId['dep.ssl-manager'].ok).toBe(true); - expect(byId['dep.nitro'].ok).toBe(true); - expect(byId['expo.plugin'].ok).toBe(true); - expect(byId['ssl_config.json'].ok).toBe(true); - expect(byId.workspace.ok).toBe(true); - }); -}); - -describe('postinstall safety in a synthetic monorepo', () => { - let tmp; - let appDir; - let gradlePath; - - beforeEach(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssl-mono-e2e-')); - // workspace - fs.writeFileSync( - path.join(tmp, 'pnpm-workspace.yaml'), - "packages:\n - 'apps/*'\n" - ); - fs.writeFileSync( - path.join(tmp, 'package.json'), - JSON.stringify({ name: 'ws', private: true }) - ); - // app - appDir = path.join(tmp, 'apps', 'mobile'); - fs.mkdirSync(path.join(appDir, 'android', 'app'), { recursive: true }); - fs.writeFileSync( - path.join(appDir, 'package.json'), - JSON.stringify({ - name: 'mobile', - dependencies: { - 'react-native': '0.79.0', - 'react-native-ssl-manager': '2.0.3', - }, - }) - ); - gradlePath = path.join(appDir, 'android', 'app', 'build.gradle'); - fs.writeFileSync(gradlePath, 'android {}\n'); - // pnpm isolated marker at workspace - fs.mkdirSync(path.join(tmp, 'node_modules', '.pnpm'), { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(tmp, { recursive: true, force: true }); - }); - - it('detects monorepo + pnpm isolated and skips host mutation', () => { - expect(postinstall.isMonorepo(appDir)).toBe(true); - expect(postinstall.isPnpmIsolated(tmp)).toBe(true); - expect( - postinstall.shouldSkipMutatingHost({ - monorepo: true, - pnpmIsolated: true, - }) - ).toBe(true); - }); - - it('findProjectRoot prefers the app with android/app/build.gradle', () => { - expect(postinstall.findProjectRoot(appDir)).toBe(appDir); - }); - - it('does not leave the brittle hardcoded apply path in postinstall.js', () => { - const src = fs.readFileSync( - path.join(root, 'scripts', 'postinstall.js'), - 'utf8' - ); - expect(src).not.toContain( - "apply from: '../../node_modules/react-native-ssl-manager/android/ssl-pinning-setup.gradle'" - ); - }); -}); - -describe('gradle monorepo search paths', () => { - it('ssl-pinning-setup.gradle searches workspace-root depths', () => { - const gradle = fs.readFileSync( - path.join(root, 'android', 'ssl-pinning-setup.gradle'), - 'utf8' - ); - expect(gradle).toContain('project.rootDir}/../../ssl_config.json'); - expect(gradle).toContain('monorepo'); - }); -}); - -describe('CLI monorepo-setup is wired', () => { - it('cli.js documents and routes monorepo-setup', () => { - const cli = fs.readFileSync(path.join(root, 'scripts', 'cli.js'), 'utf8'); - expect(cli).toContain("case 'monorepo-setup'"); - expect(cli).toContain('monorepo-setup'); - }); - - it('monorepo-setup script exits 0 against fixture for structural checks', () => { - const { spawnSync } = require('child_process'); - const script = path.join(root, 'scripts', 'monorepo-setup.js'); - const result = spawnSync( - process.execPath, - [script, '--app', fixtureApp], - { encoding: 'utf8' } - ); - // May exit 1 if package resolve fails without install — still print report. - expect(result.stdout + result.stderr).toMatch(/monorepo setup/i); - expect(result.stdout).toMatch(/expo\.plugin/); - expect(result.stdout).toMatch(/ssl_config\.json/); - }); -}); diff --git a/__tests__/pack-contents.test.js b/__tests__/pack-contents.test.js new file mode 100644 index 0000000..8ba14f6 --- /dev/null +++ b/__tests__/pack-contents.test.js @@ -0,0 +1,58 @@ +/** + * Guards the published tarball contents: only runtime scripts should ship, + * never dev/test/maintainer tooling or fixtures. Prevents the package from + * quietly re-bloating (see issue #18 feedback). + */ +const { execSync } = require('child_process'); +const path = require('path'); + +function packedFiles() { + const cwd = path.join(__dirname, '..'); + // --ignore-scripts keeps lifecycle scripts (e.g. `prepare`) from writing to + // stdout and corrupting the JSON payload. + const raw = execSync('npm pack --dry-run --json --ignore-scripts', { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const json = raw.slice(raw.indexOf('[')); + return JSON.parse(json)[0].files.map((f) => f.path); +} + +describe('published package contents', () => { + let files; + beforeAll(() => { + files = packedFiles(); + }); + + const shouldShip = [ + 'scripts/cli.js', + 'scripts/cli-utils.js', + 'scripts/postinstall.js', + 'scripts/postinstall-lib.js', + 'scripts/bun-postinstall.js', + 'scripts/nsc-utils.js', + ]; + + it.each(shouldShip)('ships runtime script %s', (p) => { + expect(files).toContain(p); + }); + + it('does not ship dev/test/maintainer scripts', () => { + const dev = files.filter( + (f) => + f.startsWith('scripts/dev/') || + /rebuild-and-test|run-feature-tests|test-bun|deprecate-v1|build\.sh/.test( + f + ) + ); + expect(dev).toEqual([]); + }); + + it('does not ship monorepo tooling or fixtures', () => { + const junk = files.filter( + (f) => /monorepo-(setup|utils)/.test(f) || f.startsWith('fixtures/') + ); + expect(junk).toEqual([]); + }); +}); diff --git a/__tests__/rebuild-and-test.test.js b/__tests__/rebuild-and-test.test.js index d1f0519..f8adff1 100644 --- a/__tests__/rebuild-and-test.test.js +++ b/__tests__/rebuild-and-test.test.js @@ -7,7 +7,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const root = path.join(__dirname, '..'); -const script = path.join(root, 'scripts', 'rebuild-and-test.js'); +const script = path.join(root, 'scripts', 'dev', 'rebuild-and-test.js'); const config = path.join(root, 'example-expo', 'ssl_config.json'); describe('rebuild-and-test script', () => { diff --git a/fixtures/pnpm-monorepo/.npmrc b/fixtures/pnpm-monorepo/.npmrc deleted file mode 100644 index 0c52f46..0000000 --- a/fixtures/pnpm-monorepo/.npmrc +++ /dev/null @@ -1,4 +0,0 @@ -# Match customer layout: isolated node_modules (pnpm default) -node-linker=isolated -auto-install-peers=true -strict-peer-dependencies=false diff --git a/fixtures/pnpm-monorepo/README.md b/fixtures/pnpm-monorepo/README.md deleted file mode 100644 index f73b483..0000000 --- a/fixtures/pnpm-monorepo/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# pnpm monorepo fixture - -Minimal **pnpm** workspace used to validate `react-native-ssl-manager` in the -layout customers hit: - -```text -fixtures/pnpm-monorepo/ - pnpm-workspace.yaml - apps/mobile/ ← Expo-style app package - package.json ← depends on file:../../.. (this library) - app.json ← config plugin registered - ssl_config.json - metro.config.js ← singleton react / RN / nitro - android/app/ ← stub gradle for path tests -``` - -## Quick validate (no full install) - -From the **library repo root**: - -```bash -npm run test:monorepo -# or -node scripts/monorepo-setup.js --app fixtures/pnpm-monorepo/apps/mobile -``` - -## Full pnpm install (optional, network) - -```bash -cd fixtures/pnpm-monorepo -SSL_MANAGER_SKIP_POSTINSTALL=1 pnpm install -pnpm --filter @fixture/mobile exec node ../../../scripts/monorepo-setup.js -``` - -## Consumer checklist - -1. Install **in the app package**, not only the workspace root - `pnpm add react-native-ssl-manager react-native-nitro-modules --filter @your/app` -2. `ssl_config.json` next to that app’s `app.json` -3. Expo plugin in `app.json` → `npx expo prebuild --clean` from the app dir -4. Set `SSL_MANAGER_SKIP_POSTINSTALL=1` in CI / shell -5. Bare Android: run `npx react-native-ssl-manager monorepo-setup` and paste the apply line diff --git a/fixtures/pnpm-monorepo/apps/mobile/android/app/build.gradle b/fixtures/pnpm-monorepo/apps/mobile/android/app/build.gradle deleted file mode 100644 index 0d98d8d..0000000 --- a/fixtures/pnpm-monorepo/apps/mobile/android/app/build.gradle +++ /dev/null @@ -1,12 +0,0 @@ -// Stub consumer android/app/build.gradle for monorepo tests. -// After `expo prebuild` or bare RN init this file is real; the apply line -// is validated by scripts/monorepo-setup.js and __tests__/monorepo-fixture.test.js - -android { - namespace "com.fixture.sslmonorepo" -} - -// react-native-ssl-manager: paste the apply line from -// npx react-native-ssl-manager monorepo-setup -// Example (relative path is computed for your install layout): -// apply from: "../../../node_modules/react-native-ssl-manager/android/ssl-pinning-setup.gradle" diff --git a/fixtures/pnpm-monorepo/apps/mobile/app.json b/fixtures/pnpm-monorepo/apps/mobile/app.json deleted file mode 100644 index b1c2795..0000000 --- a/fixtures/pnpm-monorepo/apps/mobile/app.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "expo": { - "name": "ssl-monorepo-mobile", - "slug": "ssl-monorepo-mobile", - "version": "1.0.0", - "newArchEnabled": true, - "ios": { - "bundleIdentifier": "com.fixture.sslmonorepo" - }, - "android": { - "package": "com.fixture.sslmonorepo" - }, - "plugins": [ - [ - "react-native-ssl-manager", - { - "sslConfigPath": "./ssl_config.json" - } - ] - ] - } -} diff --git a/fixtures/pnpm-monorepo/apps/mobile/metro.config.js b/fixtures/pnpm-monorepo/apps/mobile/metro.config.js deleted file mode 100644 index 28230e4..0000000 --- a/fixtures/pnpm-monorepo/apps/mobile/metro.config.js +++ /dev/null @@ -1,29 +0,0 @@ -// Monorepo-safe Metro: single copies of react / RN / nitro from the app. -const { getDefaultConfig } = require('expo/metro-config'); -const path = require('path'); - -const projectRoot = __dirname; -const workspaceRoot = path.resolve(projectRoot, '../..'); -// Library lives at repo root (three levels up from apps/mobile). -const libraryRoot = path.resolve(projectRoot, '../../..'); - -/** @type {import('expo/metro-config').MetroConfig} */ -const config = getDefaultConfig(projectRoot); - -config.watchFolders = [workspaceRoot, libraryRoot]; -config.resolver.nodeModulesPaths = [ - path.join(projectRoot, 'node_modules'), - path.join(workspaceRoot, 'node_modules'), -]; -config.resolver.disableHierarchicalLookup = true; -config.resolver.extraNodeModules = { - 'react-native-ssl-manager': libraryRoot, - react: path.join(projectRoot, 'node_modules/react'), - 'react-native': path.join(projectRoot, 'node_modules/react-native'), - 'react-native-nitro-modules': path.join( - projectRoot, - 'node_modules/react-native-nitro-modules' - ), -}; - -module.exports = config; diff --git a/fixtures/pnpm-monorepo/apps/mobile/package.json b/fixtures/pnpm-monorepo/apps/mobile/package.json deleted file mode 100644 index a35241a..0000000 --- a/fixtures/pnpm-monorepo/apps/mobile/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "@fixture/mobile", - "private": true, - "version": "0.0.0", - "main": "index.js", - "dependencies": { - "expo": "~53.0.0", - "react": "19.0.0", - "react-native": "0.79.5", - "react-native-nitro-modules": "0.35.9", - "react-native-ssl-manager": "file:../../.." - } -} diff --git a/fixtures/pnpm-monorepo/apps/mobile/ssl_config.json b/fixtures/pnpm-monorepo/apps/mobile/ssl_config.json deleted file mode 100644 index 9da5a10..0000000 --- a/fixtures/pnpm-monorepo/apps/mobile/ssl_config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "sha256Keys": { - "api.example.com": [ - "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" - ] - }, - "domains": { - "api.example.com": { - "enforcePinning": true, - "includeSubdomains": true - } - } -} diff --git a/fixtures/pnpm-monorepo/package.json b/fixtures/pnpm-monorepo/package.json deleted file mode 100644 index 1ba4933..0000000 --- a/fixtures/pnpm-monorepo/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "ssl-manager-pnpm-monorepo-fixture", - "private": true, - "version": "0.0.0", - "description": "Minimal pnpm workspace fixture for testing react-native-ssl-manager monorepo integration", - "scripts": { - "validate": "node ../../scripts/monorepo-setup.js --app apps/mobile", - "check": "node ../../scripts/monorepo-setup.js --app apps/mobile" - }, - "engines": { - "node": ">=18" - } -} diff --git a/fixtures/pnpm-monorepo/pnpm-workspace.yaml b/fixtures/pnpm-monorepo/pnpm-workspace.yaml deleted file mode 100644 index 8ab3e17..0000000 --- a/fixtures/pnpm-monorepo/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - - 'apps/*' diff --git a/package.json b/package.json index a480c55..8d2c1b2 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,12 @@ "*.podspec", "expo-module.config.json", "react-native.config.js", - "scripts", + "scripts/cli.js", + "scripts/cli-utils.js", + "scripts/postinstall.js", + "scripts/postinstall-lib.js", + "scripts/bun-postinstall.js", + "scripts/nsc-utils.js", "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", @@ -46,18 +51,17 @@ "clean": "expo-module clean", "lint": "expo-module lint", "test": "expo-module test", - "test:features": "node scripts/run-feature-tests.js", - "test:monorepo": "jest __tests__/monorepo-fixture.test.js __tests__/postinstall-monorepo.test.js --no-coverage", - "test:rebuild": "node scripts/rebuild-and-test.js", - "test:rebuild:verify": "node scripts/rebuild-and-test.js --no-build", - "test:rebuild:demo": "node scripts/rebuild-and-test.js --no-build --skip-verify", - "monorepo:setup": "node scripts/monorepo-setup.js", + "test:features": "node scripts/dev/run-feature-tests.js", + "test:monorepo": "jest __tests__/postinstall-monorepo.test.js --no-coverage", + "test:rebuild": "node scripts/dev/rebuild-and-test.js", + "test:rebuild:verify": "node scripts/dev/rebuild-and-test.js --no-build", + "test:rebuild:demo": "node scripts/dev/rebuild-and-test.js --no-build --skip-verify", "prepare": "echo 'Skipping prepare script'", "typecheck": "tsc --noEmit", "specs": "nitrogen", "release": "release-it", - "build:all": "./scripts/build.sh", - "build:examples": "./scripts/build.sh --with-examples", + "build:all": "./scripts/dev/build.sh", + "build:examples": "./scripts/dev/build.sh --with-examples", "example:install": "cd example && yarn install", "postinstall": "node scripts/postinstall.js", "postinstall:bun": "bun scripts/bun-postinstall.js", @@ -72,7 +76,7 @@ "bun:test": "bun test", "bun:example:install": "cd example && bun install", "bun:example-expo:install": "cd example-expo && bun install", - "bun:test-compatibility": "bun scripts/test-bun.js" + "bun:test-compatibility": "bun scripts/dev/test-bun.js" }, "keywords": [ "react-native", diff --git a/scripts/cli.js b/scripts/cli.js index f0e28e4..de8b501 100644 --- a/scripts/cli.js +++ b/scripts/cli.js @@ -6,7 +6,6 @@ * verify Diff live chains against ssl_config.json (CI-friendly) * keygen Generate an Ed25519 keypair for OTA pin bundles * sign Sign ssl_config.json into an OTA bundle - * monorepo-setup Validate monorepo/pnpm app layout + print Gradle snippet */ const fs = require('fs'); @@ -122,7 +121,9 @@ async function cmdVerify({ flags }) { }; for (const result of results) { console.log( - `${icons[result.status]} ${result.host} [${result.status}] ${result.message}` + `${icons[result.status]} ${result.host} [${result.status}] ${ + result.message + }` ); if (result.expiresSoon) { console.log( @@ -190,19 +191,8 @@ Commands: keygen [--out ] [--force] Generate an Ed25519 keypair for OTA bundles sign --config --key Sign the config into an OTA pin bundle [--expires-in 30d] [--out bundle.json] [--public-key ] - monorepo-setup [--app ] Validate monorepo/pnpm layout + Gradle snippet `; -function cmdMonorepoSetup(argv) { - // Delegate to the dedicated script so logic stays in one place. - const script = path.join(__dirname, 'monorepo-setup.js'); - const { spawnSync } = require('child_process'); - const result = spawnSync(process.execPath, [script, ...argv], { - stdio: 'inherit', - }); - process.exit(result.status == null ? 1 : result.status); -} - async function main() { const [command, ...rest] = process.argv.slice(2); const parsed = parseArgs(rest); @@ -220,9 +210,6 @@ async function main() { case 'sign': cmdSign(parsed); break; - case 'monorepo-setup': - cmdMonorepoSetup(rest); - break; default: console.log(HELP); process.exit(command ? 1 : 0); diff --git a/scripts/build.sh b/scripts/dev/build.sh similarity index 100% rename from scripts/build.sh rename to scripts/dev/build.sh diff --git a/scripts/deprecate-v1.sh b/scripts/dev/deprecate-v1.sh similarity index 100% rename from scripts/deprecate-v1.sh rename to scripts/dev/deprecate-v1.sh diff --git a/scripts/rebuild-and-test.js b/scripts/dev/rebuild-and-test.js similarity index 99% rename from scripts/rebuild-and-test.js rename to scripts/dev/rebuild-and-test.js index 3a1f2c6..354f1ea 100644 --- a/scripts/rebuild-and-test.js +++ b/scripts/dev/rebuild-and-test.js @@ -20,7 +20,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); -const root = path.join(__dirname, '..'); +const root = path.join(__dirname, '..', '..'); const exampleExpo = path.join(root, 'example-expo'); const configPath = path.join(exampleExpo, 'ssl_config.json'); diff --git a/scripts/run-feature-tests.js b/scripts/dev/run-feature-tests.js similarity index 94% rename from scripts/run-feature-tests.js rename to scripts/dev/run-feature-tests.js index 85e65bd..0e1b78d 100644 --- a/scripts/run-feature-tests.js +++ b/scripts/dev/run-feature-tests.js @@ -13,7 +13,7 @@ const { spawnSync } = require('child_process'); const path = require('path'); -const root = path.join(__dirname, '..'); +const root = path.join(__dirname, '..', '..'); const jestBin = path.join(root, 'node_modules', '.bin', 'jest'); /** @type {{ feature: string; suites: string[]; device?: string }[]} */ @@ -69,11 +69,6 @@ const MATRIX = [ feature: 'pnpm / monorepo postinstall safety', suites: ['postinstall-monorepo.test.js', 'postinstall-nsc.test.js'], }, - { - feature: 'pnpm monorepo fixture + monorepo-setup CLI', - suites: ['monorepo-fixture.test.js'], - device: 'fixtures/pnpm-monorepo + npm run test:monorepo', - }, { feature: 'CLI pins / verify / sign helpers', suites: ['cli-utils.test.js', 'ota-verify.test.js'], diff --git a/scripts/test-bun.js b/scripts/dev/test-bun.js similarity index 100% rename from scripts/test-bun.js rename to scripts/dev/test-bun.js diff --git a/scripts/monorepo-setup.js b/scripts/monorepo-setup.js deleted file mode 100644 index fc5cbad..0000000 --- a/scripts/monorepo-setup.js +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env node -/** - * Print monorepo / pnpm setup status and the exact Gradle snippet to paste. - * - * Usage (from the app package): - * npx react-native-ssl-manager monorepo-setup - * node path/to/scripts/monorepo-setup.js - * node scripts/monorepo-setup.js --app apps/mobile - */ - -const path = require('path'); -const { - findAppRoot, - findWorkspaceRoot, - getGradleApplySnippet, - getGradleApplySnippetNodeResolve, - validateMonorepoAppLayout, - formatValidationReport, - resolveSslManagerDir, -} = require('./monorepo-utils'); - -function parseArgs(argv) { - const args = { app: null }; - for (let i = 0; i < argv.length; i++) { - if (argv[i] === '--app' && argv[i + 1]) { - args.app = path.resolve(argv[++i]); - } - } - return args; -} - -function main() { - const args = parseArgs(process.argv.slice(2)); - const start = args.app || process.cwd(); - const appRoot = findAppRoot(start) || start; - const workspace = findWorkspaceRoot(appRoot); - - console.log('🔧 react-native-ssl-manager — monorepo setup\n'); - console.log(`cwd: ${process.cwd()}`); - console.log(`app: ${appRoot}`); - console.log(`workspace: ${workspace || '(none detected)'}`); - console.log(''); - - const result = validateMonorepoAppLayout(appRoot); - console.log(formatValidationReport(result)); - console.log(''); - - const libDir = resolveSslManagerDir(appRoot); - if (libDir) { - console.log(`📦 Package resolved: ${libDir}`); - } - - const androidApp = path.join(appRoot, 'android', 'app'); - console.log('\n── Android (bare RN or after prebuild) ──'); - console.log('Option A — relative apply (frozen after install):\n'); - const snippet = getGradleApplySnippet(androidApp, appRoot); - if (snippet.ok) { - console.log(snippet.line); - } else { - console.log(`// ${snippet.error}`); - console.log('// Install the package in this app first, then re-run.'); - } - - console.log('\nOption B — Node resolve (pnpm-proof, recommended):\n'); - console.log(getGradleApplySnippetNodeResolve()); - - console.log('\n── Expo ──'); - console.log('Prefer the config plugin; no postinstall required:'); - console.log(` -{ - "expo": { - "plugins": [ - ["react-native-ssl-manager", { "sslConfigPath": "./ssl_config.json" }] - ] - } -} -`.trim()); - console.log('\nThen from the app package: npx expo prebuild --clean'); - - console.log('\n── Env ──'); - console.log('SSL_MANAGER_SKIP_POSTINSTALL=1 # recommended in monorepos'); - - process.exit(result.ok ? 0 : 1); -} - -main(); diff --git a/scripts/monorepo-utils.js b/scripts/monorepo-utils.js deleted file mode 100644 index 2ca87a4..0000000 --- a/scripts/monorepo-utils.js +++ /dev/null @@ -1,387 +0,0 @@ -/** - * Monorepo / pnpm helpers for consumers and tests. - * - * Goals: - * - Find the app package root vs workspace root - * - Resolve this library without hardcoded node_modules layouts - * - Emit a Gradle `apply from:` line that works under pnpm isolation - * - List ssl_config.json search candidates for monorepo depths - */ - -const fs = require('fs'); -const path = require('path'); - -const LIBRARY_NAME = 'react-native-ssl-manager'; - -/** - * Walk up looking for pnpm-workspace.yaml or package.json#workspaces. - */ -function findWorkspaceRoot(startDir) { - let dir = path.resolve(startDir); - while (dir !== path.dirname(dir)) { - if (fs.existsSync(path.join(dir, 'pnpm-workspace.yaml'))) { - return dir; - } - const pkgPath = path.join(dir, 'package.json'); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - if (pkg.workspaces) { - return dir; - } - } catch { - // ignore - } - } - dir = path.dirname(dir); - } - return null; -} - -/** - * App root = nearest package.json that depends on react-native and is not - * this library. Prefer a directory that already has android/app or app.json. - */ -function findAppRoot(startDir, libraryName = LIBRARY_NAME) { - let dir = path.resolve(startDir); - let fallback = null; - - while (dir !== path.dirname(dir)) { - const pkgPath = path.join(dir, 'package.json'); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - if (pkg.name === libraryName) { - dir = path.dirname(dir); - continue; - } - const deps = { - ...(pkg.dependencies || {}), - ...(pkg.devDependencies || {}), - }; - if (deps['react-native'] || deps['expo']) { - const looksLikeApp = - fs.existsSync(path.join(dir, 'app.json')) || - fs.existsSync(path.join(dir, 'app.config.js')) || - fs.existsSync(path.join(dir, 'app.config.ts')) || - fs.existsSync(path.join(dir, 'android', 'app', 'build.gradle')) || - fs.existsSync(path.join(dir, 'ios')); - if (looksLikeApp) { - return dir; - } - if (!fallback) { - fallback = dir; - } - } - } catch { - // ignore - } - } - dir = path.dirname(dir); - } - return fallback; -} - -/** - * Resolve absolute path to a package directory via Node module resolution - * (works with pnpm symlinks / .pnpm store). - */ -function resolvePackageDir(packageName, fromDir = process.cwd()) { - try { - const pkgJson = require.resolve(`${packageName}/package.json`, { - paths: [fromDir], - }); - return path.dirname(pkgJson); - } catch { - return null; - } -} - -/** - * Resolve react-native-ssl-manager package dir. - * 1) require.resolve (installed / linked) - * 2) package.json dependency `file:…` (fixtures / monorepo without install yet) - * 3) walk up for a package.json named react-native-ssl-manager - */ -function resolveSslManagerDir(fromDir = process.cwd()) { - const viaNode = resolvePackageDir(LIBRARY_NAME, fromDir); - if (viaNode) { - return viaNode; - } - - const start = path.resolve(fromDir); - const appRoot = findAppRoot(start) || start; - const pkgPath = path.join(appRoot, 'package.json'); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - const deps = { - ...(pkg.dependencies || {}), - ...(pkg.devDependencies || {}), - }; - const spec = deps[LIBRARY_NAME]; - if (typeof spec === 'string' && spec.startsWith('file:')) { - const target = path.resolve(appRoot, spec.slice('file:'.length)); - if ( - fs.existsSync(path.join(target, 'package.json')) || - fs.existsSync(path.join(target, 'android', 'ssl-pinning-setup.gradle')) - ) { - return target; - } - } - } catch { - // ignore - } - } - - let dir = start; - while (dir !== path.dirname(dir)) { - const candidate = path.join(dir, 'package.json'); - if (fs.existsSync(candidate)) { - try { - const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8')); - if (pkg.name === LIBRARY_NAME) { - return dir; - } - } catch { - // ignore - } - } - dir = path.dirname(dir); - } - return null; -} - -/** - * Relative Gradle apply line from android/app → ssl-pinning-setup.gradle - */ -function getGradleApplySnippet(androidAppDir, fromDir = process.cwd()) { - const libDir = resolveSslManagerDir(fromDir); - if (!libDir) { - return { - ok: false, - error: `Could not resolve ${LIBRARY_NAME} from ${fromDir}`, - line: null, - gradleScriptPath: null, - }; - } - const gradleScriptPath = path.join( - libDir, - 'android', - 'ssl-pinning-setup.gradle' - ); - if (!fs.existsSync(gradleScriptPath)) { - return { - ok: false, - error: `ssl-pinning-setup.gradle missing at ${gradleScriptPath}`, - line: null, - gradleScriptPath, - }; - } - const rel = path - .relative(path.resolve(androidAppDir), gradleScriptPath) - .split(path.sep) - .join('/'); - return { - ok: true, - error: null, - line: `apply from: "${rel}"`, - gradleScriptPath, - }; -} - -/** - * Node-based Gradle apply that does not depend on relative node_modules. - * Useful when the consumer prefers providers.exec over a frozen relative path. - */ -function getGradleApplySnippetNodeResolve() { - return [ - '// react-native-ssl-manager — monorepo / pnpm safe apply', - 'def __sslManagerGradle = providers.exec {', - " workingDir = rootDir", - " commandLine 'node', '--print', \"require('path').join(require('path').dirname(require.resolve('react-native-ssl-manager/package.json')), 'android/ssl-pinning-setup.gradle')\"", - '}.standardOutput.asText.get().trim()', - 'apply from: file(__sslManagerGradle)', - ].join('\n'); -} - -/** - * Places to look for ssl_config.json from an app package root. - */ -function sslConfigSearchCandidates(appRoot) { - const root = path.resolve(appRoot); - const workspace = findWorkspaceRoot(root); - const candidates = [ - path.join(root, 'ssl_config.json'), - path.join(root, 'android', 'ssl_config.json'), - path.join(root, 'config', 'ssl_config.json'), - ]; - if (workspace && workspace !== root) { - candidates.push(path.join(workspace, 'ssl_config.json')); - } - return candidates; -} - -function findSslConfigPath(appRoot) { - for (const candidate of sslConfigSearchCandidates(appRoot)) { - if (fs.existsSync(candidate)) { - return candidate; - } - } - return null; -} - -/** - * Validate a monorepo app layout for SSL manager integration. - * Returns { ok, checks: [{ id, ok, detail }] }. - */ -function validateMonorepoAppLayout(appRoot) { - const checks = []; - const root = path.resolve(appRoot); - - const pkgPath = path.join(root, 'package.json'); - const hasPkg = fs.existsSync(pkgPath); - checks.push({ - id: 'package.json', - ok: hasPkg, - detail: hasPkg ? pkgPath : 'missing package.json', - }); - - let deps = {}; - if (hasPkg) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }; - } catch (error) { - checks.push({ - id: 'package.json.parse', - ok: false, - detail: String(error.message || error), - }); - } - } - - const hasSsl = Boolean(deps[LIBRARY_NAME]); - checks.push({ - id: 'dep.ssl-manager', - ok: hasSsl, - detail: hasSsl - ? `${LIBRARY_NAME} in dependencies` - : `add ${LIBRARY_NAME} to the app package (not only workspace root)`, - }); - - const hasNitro = Boolean(deps['react-native-nitro-modules']); - checks.push({ - id: 'dep.nitro', - ok: hasNitro, - detail: hasNitro - ? 'react-native-nitro-modules present' - : 'peer react-native-nitro-modules required', - }); - - const isExpo = Boolean(deps.expo); - const appJsonPath = path.join(root, 'app.json'); - const appConfigJs = path.join(root, 'app.config.js'); - const hasExpoConfig = - fs.existsSync(appJsonPath) || fs.existsSync(appConfigJs); - - if (isExpo || hasExpoConfig) { - let pluginOk = false; - let pluginDetail = 'plugin not registered in app.json/app.config'; - if (fs.existsSync(appJsonPath)) { - try { - const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8')); - const plugins = (appJson.expo && appJson.expo.plugins) || []; - pluginOk = plugins.some( - (p) => - p === LIBRARY_NAME || - (Array.isArray(p) && p[0] === LIBRARY_NAME) - ); - pluginDetail = pluginOk - ? 'Expo config plugin registered' - : `add ["${LIBRARY_NAME}", { "sslConfigPath": "./ssl_config.json" }] to expo.plugins`; - } catch (error) { - pluginDetail = `app.json parse error: ${error.message}`; - } - } - checks.push({ id: 'expo.plugin', ok: pluginOk, detail: pluginDetail }); - } - - const sslConfig = findSslConfigPath(root); - checks.push({ - id: 'ssl_config.json', - ok: Boolean(sslConfig), - detail: sslConfig || 'create ssl_config.json in the app package root', - }); - - const workspaceRoot = findWorkspaceRoot(root); - checks.push({ - id: 'workspace', - ok: Boolean(workspaceRoot), - detail: workspaceRoot - ? `workspace root: ${workspaceRoot}` - : 'no pnpm-workspace.yaml / workspaces field above app (standalone ok)', - }); - - const libDir = resolveSslManagerDir(root); - checks.push({ - id: 'resolve.package', - ok: Boolean(libDir), - detail: libDir - ? `resolved ${LIBRARY_NAME} → ${libDir}` - : `cannot require.resolve('${LIBRARY_NAME}') from app — check install filter`, - }); - - const androidApp = path.join(root, 'android', 'app'); - if (fs.existsSync(path.join(androidApp, 'build.gradle'))) { - const snippet = getGradleApplySnippet(androidApp, root); - checks.push({ - id: 'android.gradle-apply', - ok: snippet.ok, - detail: snippet.ok - ? `suggested: ${snippet.line}` - : snippet.error || 'could not build apply line', - }); - } else { - checks.push({ - id: 'android.gradle-apply', - ok: true, - detail: 'no android/app yet (Expo prebuild will generate it)', - }); - } - - const ok = checks.every((c) => c.ok); - return { ok, appRoot: root, workspaceRoot, checks }; -} - -/** - * Build a printable setup report for CI / CLI. - */ -function formatValidationReport(result) { - const lines = []; - lines.push(`App root: ${result.appRoot}`); - if (result.workspaceRoot) { - lines.push(`Workspace: ${result.workspaceRoot}`); - } - lines.push(''); - for (const check of result.checks) { - lines.push(`${check.ok ? '✅' : '❌'} ${check.id}: ${check.detail}`); - } - lines.push(''); - lines.push(result.ok ? 'Monorepo app layout: OK' : 'Monorepo app layout: NEEDS FIXES'); - return lines.join('\n'); -} - -module.exports = { - LIBRARY_NAME, - findWorkspaceRoot, - findAppRoot, - resolvePackageDir, - resolveSslManagerDir, - getGradleApplySnippet, - getGradleApplySnippetNodeResolve, - sslConfigSearchCandidates, - findSslConfigPath, - validateMonorepoAppLayout, - formatValidationReport, -}; diff --git a/src/index.ts b/src/index.ts index 6174a7f..7374849 100644 --- a/src/index.ts +++ b/src/index.ts @@ -144,8 +144,6 @@ export const updatePinsFromUrl = async ( url: string, options: OtaUpdateOptions ): Promise => { - const native = requireNative(); - const fetchFn = options.fetchFn ?? fetch; let bundle: SignedPinBundle; try { @@ -165,6 +163,31 @@ export const updatePinsFromUrl = async ( ); } + return applySignedPinBundle(bundle, options); +}; + +/** + * Verifies an **already-fetched** signed pin bundle and applies it — the + * verify-and-apply half of {@link updatePinsFromUrl}, exposed for callers who + * obtain the bundle themselves (custom networking/caching, a bundle delivered + * in a push payload, or an offline test). + * + * Verification is identical to {@link updatePinsFromUrl}: the Ed25519 signature + * is checked against `options.publicKey`, then freshness (`expiresAt` / + * `maxAgeMs`) and session anti-rollback. On ANY failure the active + * configuration is left untouched and the promise rejects with an + * {@link OtaError}. + * + * @example + * const bundle = await myCache.getLatestPinBundle(); // { payload, signature } + * await applySignedPinBundle(bundle, { publicKey: OTA_PUBLIC_KEY }); + */ +export const applySignedPinBundle = async ( + bundle: SignedPinBundle, + options: OtaVerifyOptions +): Promise => { + const native = requireNative(); + const result = verifyOtaBundle(bundle, { ...options, minIssuedAt: options.minIssuedAt ?? lastAppliedIssuedAt ?? undefined,