npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

agesignals

v0.3.4

Published

Does your Android build handle the new app-store age signals? Static scanner for Texas SB 2420, Brazil ECA Digital and related laws. Runs locally; your build never leaves your machine.

Readme

agesignals

A two-tier scanner that tells an Android developer whether their app handles the new app-store age signals — and, if not, exactly what is wrong. In force today in Brazil and Texas; more jurisdictions follow.

It is the free wedge for a paid remediation service, not a product in itself.

node cli.js app-release.apk   # scan a build (any size)
node server.js                # http://localhost:3000
node test/run-tests.js        # 184 assertions, no install step
node test/check-live.js       # RUN THIS FIRST — verifies parsing against 13 real store listings

No binary is ever uploaded

The shallow tier is a web page. The deep scan is a CLI that runs on the developer's own machine. There is no upload endpoint, and that is a product decision rather than a technical limitation.

Asking a company to send its unreleased binary to a third-party service triggers legal and security review at any organisation large enough to be worth selling to — NDA terms, source-protection policy, sometimes export control. That blocker is completely independent of file size: a 40 MB banking app hits the same review as a 2 GB game, so no upload cap addresses it. The population who can freely upload is the population with no review process, which is also the population least likely to pay. Meanwhile "runs locally, nothing leaves your network" clears the same review in minutes.

Deleting the endpoint also deleted the only code here that could exhaust memory or corrupt a binary in transit: the multipart parser, the memory-budgeted admission control, the 512 MB cap, the 413 path, and the 2 GiB Buffer ceiling as a concept. /api/deep now answers 410 with a pointer to the CLI.

agesignals app-release.apk
agesignals app-release.apk --mapping build/outputs/mapping/release/mapping.txt --pdf report.pdf
npx agesignals@latest app-release.apk --fail-on high --quiet   # in CI, always current

Exit codes are a CI contract and are asserted in the tests: 0 nothing at or above --fail-on (default critical), 1 findings at or above it, 2 could not scan. So a pipeline breaks the build the day someone removes the age-signal call. --json - writes the report to stdout and suppresses all other output, so it stays parseable.

check-live.js is the verification that could not be done in the build sandbox (outbound requests to play.google.com were blocked there). It hits real listings and prints, field by field, what came back. Run it on a machine with normal internet before trusting the shallow tier.

Node 18+. Zero dependencies — no npm install, no lockfile, no supply chain. Everything (ZIP reading, binary-XML parsing, DEX scanning, PDF writing) is implemented against Node built-ins so it can be dropped onto any host or serverless runtime. The one caveat is throttling: both limiters hold state in process memory and are only meaningful on a long-lived single instance. See The outbound gate below.


The two tiers, and why they are split this way

Shallow — paste a store URL. Reads only what the stores already publish on a public listing. Never downloads an app binary. This is a deliberate constraint, not a limitation of effort: mass-retrieving APKs to scan apps that never asked you to would breach Play's terms, doesn't work on iOS at all, and would leave you holding thousands of companies' weaknesses that they never handed over. The shallow tier is shareable and legally clean.

Deep — the developer runs agesignals against their own build. Consent is implicit because they run it themselves, and the binary never moves. See No binary is ever uploaded above for why this is not a web upload.

What it can and cannot prove

This is the load-bearing design rule and it is enforced in the report wording.

Reliable (absence), with one stated condition. "The Play Age Signals library is not in this build" comes from a DEX string search. Same for "no Play Integrity." These are the findings that sell.

The condition is name obfuscation — which is not the same thing as minification, and the distinction matters in client-facing text. R8 shrinks, optimises and renames independently, and large apps routinely shrink while keeping names so their crash reports stay readable. Only renaming affects what this scanner can prove: a class renamed to A reads exactly like one that was never there. Calling such a build "minified" in a report is checkably false to the developer reading it, so the report never says it.

In practice the condition is mild, though not for the reason once written here. This file used to say that Play Age Signals ships consumer keep rules which preserve its entry points. It does not: age-signals 0.0.4 contains no proguard.txt at all, verified against the artifact Google publishes on 2026-08-05.

What actually holds is narrower and sufficient. R8 renames method names far more readily than class names, and this scanner matches on class and package names. Measured on a real shipping build that carries the library: every class name survived intact — AgeSignalsManager, AgeSignalsResult — while every accessor on them had been renamed to a single letter. Detection reads the names that survive.

A library bundled by a build that renames aggressively could still be present and go unreported, which is what mapping.txt is for.

measureObfuscation in lib/apk.js infers this from class-name shape, and the report states which case it is rather than claiming a flat "definitive negative" either way. Do not restore that phrasing — it is the sentence a client's counsel would quote back. A test asserts the word "minif…" never appears in a report at all.

Both branches are verified against real builds:

| build | ratio | called | reality | |---|---|---|---| | 301 MB, R8-renamed | 0.58 | obfuscated | one of two Facebook markers renamed away; entry-point marker survived and matched | | 19 MB, shrunk but named | 0.04 | not obfuscated | 9,749 readable com/hulu/… classes; libraries intact too |

The threshold is 0.2 rather than something higher because the count is diluted by references to platform and library classes, which are never renamed.

The tool knows how old its own advice is

lib/signatures.js carries the exact call that disables profiling for each SDK — AdSettings.setMixedAudience(true) and so on. Vendors rename these between major versions; eleven entries are already flagged verify: true for that reason.

The danger is not the data ageing. It is ageing silently. npm pins by default, so someone who installs today can still be running this build in a year, getting an exact method name printed into a compliance deliverable and believing it because the tool stated it plainly.

So SIGNATURES_VERIFIED in lib/signatures.js records when the calls were last checked against vendor docs, and confidence degrades on a clock:

| age of the data | behaviour | |---|---| | under 90 days | normal — calls stated plainly | | 90–180 days | an info finding saying the data is old; calls still stated plainly | | over 180 days | a medium finding, and every SDK is treated as verify: true regardless of its own flag |

The calls are never withheld, only marked — a test asserts that. This reuses the existing per-SDK verify mechanism rather than inventing a second one, so the CLI, the report and the PDF already render it. An SDK's own verifyNote is never overwritten by the generic one.

An unparseable date is treated as maximally stale. Failing open there would defeat the purpose.

Bump SIGNATURES_VERIFIED when you re-check the vendor docs — not when the scanner changes. It is a claim about the data. Publish a new version when it moves, and old installs start warning on their own.

mapping.txt — optional, and the scan never depends on it

Pass --mapping and every SDK marker is searched under its original name and under the renamed form the mapping gives it.

The design rule is that it must never be a gate. No mapping, a mapping for the wrong build, or a text file that is not a mapping at all — the scan runs and produces findings in every case. Only the wording of absence findings changes:

| supplied | absence claim | |---|---| | mapping | "searched under the renamed form as well as the original. Absent under both. This negative is definitive." | | none, names obfuscated | "…this negative is strong — but supply your mapping.txt to make it definitive." | | none, names intact | "class names are intact, so this negative is reliable." |

A file that parses to zero classes is rejected with a warning and the scan continues without it — silently accepting it would hand the report a definitive claim it has not earned.

Verified against the real 301 MB obfuscated build: with a mapping renaming com.appsflyer.AppsFlyerLib to a class that build actually contains, AppsFlyer went from undetected to detected, and the report named it as the SDK that would otherwise have been missed. That is the whole value of the feature in one line.

lib/mapping.js parses it. It is never required: a missing, wrong, or unparseable mapping leaves the scan running exactly as it would without one.

Reliable (presence of an SDK). "AppsFlyer is compiled into this app" is solid.

NOT determinable statically, and never claimed:

  • whether checkAgeSignals() is actually called
  • whether each ad SDK's restricted-mode flag is set before that SDK initialises — the ordering requirement that is the most common real failure
  • whether app behaviour actually branches for a minor

Those three require a runtime check, and they are precisely what the paid manual pass covers. The tool can prove non-compliance; it can never certify compliance.

Layout

| file | role | |---|---| | lib/zip.js | Minimal ZIP reader (ZIP64, buffer or file source), no deps | | lib/axml.js | Android binary-XML string-pool parser (UTF-8 and UTF-16) | | lib/signatures.js | SDK signature DB + per-SDK remediation call | | lib/agesignals-api.js | What the Play Age Signals API returns, and the runtime checks it implies | | lib/apk.js | APK inventory: SDKs, permissions, ABIs, frameworks | | lib/shallow.js | Store-URL scan (iTunes lookup API / Play listing) | | lib/rules.js | Findings engine + statute references + disclaimer | | lib/pdf.js | Minimal PDF writer + report renderer (no deps) | | lib/ratelimit.js | Fixed-window per-client limiter | | lib/outbound.js | Global gate on outbound store requests | | lib/leads.js | Email capture, append-only JSONL | | lib/notify.js | Lead delivery to an HTTPS webhook | | lib/mapping.js | R8/ProGuard mapping.txt reader (optional upload) | | cli.js | Command-line scanner, CI exit codes | | server.js | HTTP server, /api/shallow, /api/report.pdf, /api/lead | | public/index.html | Single-page UI |

Currently detects 27 SDKs across ads, analytics, attribution, engagement, diagnostics and compliance, plus 6 app frameworks. Adding one is a single object in lib/signatures.js.

Known unverified areas — read before shipping

  1. The Google Play HTML scraper is validated against live markup as of 3 Aug 2026 — 10/10 real listings parsed cleanly via check-live.js. Play's page is JS-heavy and its markup still changes without notice, so re-run check-live.js before each engagement. It degrades honestly (reports signalQuality: 'low' and a warning) rather than inventing findings. If it ever comes back empty, either parse the embedded AF_initDataCallback JSON blobs or render the page headlessly.

  2. The iTunes lookup API path is verified working against a live response. But Apple exposes no SDK inventory and no privacy labels through it, so iOS shallow scanning is structurally much weaker than Android. The report says so rather than padding it out.

  3. restrictedModeApi values marked verify: true in signatures.js are SDKs whose child/COPPA API was renamed or deprecated across versions (AppLovin, Vungle, Pangle, Mintegral, InMobi, Chartboost, AdColony, Singular, Kochava, Segment, Braze). Confirm against the version actually bundled before putting these in a client deliverable. The UI already flags them.

  4. Package name is read from the manifest's package attribute and labelled declared. The old most-common-prefix heuristic survives only as a fallback for an unreadable tree, labelled heuristic. It was wrong on real input: Call of Duty's manifest carries more com.google.android strings than com.activision ones, so it reported the game as com.google.android.gms.

  5. .aab bundles are rejected with a message telling the user to export a universal APK. Bundles have no single merged manifest or DEX set.

  6. iOS deep scanning is not implemented. IPAs are FairPlay-encrypted and not practically obtainable. If you need it, the path is asking the developer for the .xcarchive or the unencrypted build, then reading PrivacyInfo.xcprivacy and the Frameworks/ directory.

Legal references (verified 3 Aug 2026)

  • Brazil ECA Digital (Lei 15.211/2025)in force since 17 March 2026, with no grace period or phased rollout. Enforceable earlier than any US statute here. Reaches any provider of services targeted at minors or merely likely to be accessed by them, including companies outside Brazil. Requires reliable age verification and expressly prohibits mere self-declaration — which bears directly on Play Age Signals, whose TIER_A responses are self-declared. Under-16 accounts must be linked to a legal guardian. Penalties reach 10% of Brazilian revenue, suspension, or prohibition from operating. Unconfirmed: whether developers get a safe harbour for relying on app-store signals, as Texas and Utah allow. Establish this before telling anyone a store signal alone suffices in Brazil.
  • Texas SB 2420 — in force since 4 June 2026, after the Fifth Circuit stayed a preliminary injunction. Google states Play began returning age signals for Texas users whose accounts were created after 28 May 2026.
  • Utah — developer obligations postponed to 7 May 2027. Provides a safe harbour for developers relying on app-store age data.
  • Louisiana — delayed to 2027; sources report both 6 May and 1 July 2027. Confirm the operative date before relying on it. Notably gives developers no safe harbour for relying on app-store age data.
  • Play Age Signals: com.google.android.play:age-signals, package com.google.android.play.agesignals, entry point AgeSignalsManagerFactory.create(...).checkAgeSignals(...).

Sources:

Scope-limited regimes — in force, but they do not bind every app

These bind by service type, not by shipping an app. The scope line matters more than the date, and the report prints it above the note for that reason.

  • EU Digital Services Act, Article 28 — in force since 17 February 2024, Commission guidelines 14 July 2025. Prohibits targeting minors with personalised advertising — a prohibition, not a duty to act on a signal, and stricter than any US statute here. Binds "online platforms" as the DSA defines them: a social or UGC app very likely is one, a single-player game with ads is not.
  • UK Online Safety Act 2023 — age assurance duties since 25 July 2025, enforced by Ofcom. Binds user-to-user services, search services, and services publishing pornography. Ofcom's accepted methods are photo-ID matching, facial age estimation, Open Banking, digital identity and mobile-operator checks — an app-store age signal is not among them, so it may not discharge this duty on its own. That is the opposite of the Texas/Utah safe-harbour position.

iOS has its own API

Apple introduced the DeclaredAgeRange framework in iOS 26 (AgeRangeService.requestAgeRange(...)), the counterpart to Play Age Signals. This scanner cannot check for it — IPAs are FairPlay-encrypted — but the iOS report names it, so a thin iOS result is not mistaken for "nothing to do on iOS".

This tool does not give legal advice and does not certify compliance. Statute dates are moving — several were enjoined, stayed or postponed in the last year. Re-check them before each engagement.

PDF export, email capture, rate limiting

PDF export (lib/pdf.js) is a PDF 1.4 writer written against the standard-14 fonts, so nothing is embedded and there is still no dependency. The client posts the report back to POST /api/report.pdf and gets the file. That round trip is deliberate: the deep scan's binary is discarded the moment it is analysed, so the report cannot be regenerated server-side, and keeping one around to enable that would break the promise the upload panel makes.

Text is WinAnsi-encoded, wrapped against real AFM advance widths, and hard-broken when a single token (a long API call) is wider than the column. Unrepresentable codepoints become ? rather than silently vanishing.

Email capture (lib/leads.js) appends JSONL to data/leads.jsonl (LEADS_FILE to relocate). It stores the address, what was scanned, and the severity tally — never the findings, never the SDK inventory, never an IP. This is the same rule that shapes the shallow tier: a list of companies annotated with their specific weaknesses is a liability the moment it exists. A record saying "this person asked about this package" is a sales note; one saying "this package ships six ad SDKs and no age signal" is a target list. There is a test asserting the record's exact key set so it stays that way.

Rate limiting (lib/ratelimit.js) is a fixed window per client per route — shallow 20, deep 10, pdf 30, lead 5, each per 10 minutes, all overridable by env. Rejections carry Retry-After. Invalid input still counts against the limit, otherwise malformed requests are a free bypass. X-Forwarded-For is only trusted when TRUST_PROXY=1, since otherwise any caller can spoof it.

The outbound gate — read this before tuning anything

lib/ratelimit.js and lib/outbound.js do different jobs, and conflating them is how the scanner dies.

The per-client limiter stops one visitor abusing the tool. It does not protect the Play scraper, because Google does not see visitors — it sees this server. A hundred people each politely staying under their own 20-scan limit still adds up to one address making thousands of requests, which is exactly what gets a scraper blocked. A per-client limit is counting the wrong thing.

So lib/outbound.js gates every store request globally, regardless of who asked: one call per second (OUTBOUND_MIN_GAP_MS), at most 20 waiting (OUTBOUND_MAX_QUEUE). Past that depth callers get an immediate 503 with Retry-After rather than a held connection, because the browser would give up on that wait anyway and a fast honest refusal is worth more than a slow timeout. Gates are per host — Play and iTunes have separate quotas, so making one wait behind the other would cost latency and buy no protection.

This is the load-bearing one. Without it a modest traffic spike gets the scraper blocked and the whole free tier stops working. The per-client limit is merely nice to have.

Note check-live.js now takes ~17s rather than ~4s. That is the gate doing its job, not a regression.

Caveat, and the same one applies to both limiters: state is per process and resets on restart. That is correct on a single box. Behind more than one instance the per-client limit should move to the edge (Cloudflare, or nginx limit_req) rather than into shared storage in Node — and note the outbound gate would then allow one call per second per instance, so OUTBOUND_MIN_GAP_MS has to be scaled by the instance count. On a serverless runtime, where processes are ephemeral and numerous, neither limiter is meaningful; the scanner itself deploys there fine, the throttling does not.

The lead form is a queue you have to work

No mail is dispatched. The form records to data/leads.jsonl, logs a [lead] … — reply owed line, and — if LEAD_WEBHOOK_URL is set — POSTs the lead to an HTTPS endpoint of your choosing (Slack, Discord, Zapier, an email API). A human still writes the reply; the webhook only guarantees the lead reaches one.

Set that variable before deploying. Container filesystems are ephemeral, so without it the JSONL file is gone on the next redeploy and the form's promise is not merely unautomated but broken. The server logs a loud warning at boot when it is unset. See DEPLOY.md.

The copy is written to match. It says a person replies and to allow a day, the button settles on "Requested" rather than "Sent", and the confirmation reads "we will be in touch" rather than "we will send" — because someone who is told an email is on its way, and then waits for one that no code will ever produce, has been lied to by the tool that just asked them to trust it with their build.

If you wire up delivery, change the copy back. Under-promising once mail actually sends is its own small cost.

Things not built yet

  • Automated delivery of the checklist (see above — the copy is honest about this, so it is a feature gap rather than a broken promise)
  • Play HTML parsing is validated but unpinned; re-run check-live.js per engagement
  • Runtime verification — whether the age signal is actually requested, whether each SDK's restricted-mode flag is set before that SDK initialises, and whether anything changes for a minor. None of it is statically decidable, and this repository does not attempt it.