Signed, inspectable, and run by plain node. No npm install on the other end.
Philipp Dunkel · @pipobscure.com · pip@pip.fyi
"This is a talk about shipping Node apps, and about installing other people's, and about not having to cross your fingers while you do either."
Who this is for: people who write JavaScript, ship tools or apps built on Node, and install CLIs from npm. Nobody needs to care about ZIP internals tonight, and the talk says so.
Set expectations in one sentence: most of this runs live, and it is experimental, so it needs Node 26.11 and a flag. Say the flag now so nobody feels tricked at the end.
Press T to start the pace timer.
npm install -g your-toolTheir machine resolves your dependency ranges against the registry, today, and runs install scripts while it does.
Works. Ships an operating system to run one command-line tool.
git clone && npm ciA README full of steps, and a support channel for when step four fails.
One file, but an opaque one, often built for a different runtime, and you trust it whole.
Every one of these works. None of them tells the person on the other end what they actually got.
"Hands up if you've published a CLI to npm. Keep it up if you've had a bug report that turned out to be someone's install, not your code."
50 seconds. Don't trash any option: they all work, and the audience uses them. The point is the last line: none of them answers "what did I just get?".
If someone shouts "bun build --compile" or "Node SEA": good, that's the fourth card. Come back to it on the comparison slide.
^1.2.0 means "whatever the registry says today".postinstall scripts run before anyone has looked at anything.You shipped a version. They got whatever resolved.
"When you publish version 1.4, you think you shipped 1.4. What you actually shipped is a recipe, and the user's machine cooks it."
60 seconds. Keep the supply-chain line short and factual. Don't name incidents unless you've checked the details recently; "hijacked accounts and install-script worms, both in 2025" is enough for this room and true.
The 214 packages line gets a laugh because everyone has seen it.
Freeze the whole tree into one file. Sign it. Let Node run it straight out of the file.
Exactly the files it reads. Nothing more.
Decided once, when you release. Can't change afterwards.
No install step, no registry, no extraction to disk.
"So: don't ship the recipe. Ship the meal. One file, decided when you release it, signed, and Node runs it as it is."
45 seconds. This is the thesis. Everything after is "does it work" and "what does that get me".
node --vfs-load=app.nzip mounts a zip and runs the app inside, no extracting."That's as deep as we're going into the format. It's a zip, it's signed as a whole, and Node can run straight out of it now."
40 seconds. If anyone wants the byte layout, there's a whole other talk (Ship the Tree, same repo) and docs/design.md. Don't get pulled in here.
The Node piece, if asked: --vfs-load landed in 26.10, and the last piece for standalone executables in 26.11. Still behind --experimental-vfs.
And the thing it installed is itself a bundle: one signed file, checked before it lands on your PATH.
"Getting the tool is the tool demonstrating itself: plain Node runs the downloaded file straight out of the zip, and that run fetches the latest release, checks its signature, and puts one signed file on your PATH."
Point at the second line: no npm, no install script. --vfs-load is Node mounting the zip and running what's inside. That's the whole trick of the talk, in one command.
30 seconds. Have it installed already; don't run this live unless the network is good.
Two kilobytes. No node_modules on the other end, no install step.
Beat 1, observe (40s). "Static analysis gets dynamic requires and data files wrong. So we don't analyse, we run it and write down what it opened." Point at lib/never-used.js: in the source, not in the bundle, because nothing read it.
Beat 2, create (20s). Fast. --launcher puts a two-line shell script in front so it runs by name; it's decided here, so the review sees it. .unsigned.nzip is just the convention for "not signed yet".
Beat 3, sign (30s). "By default this signs with sigstore: your GitHub Actions identity in CI, a browser login on your laptop. No key file to keep or leak." Live, sign with whatever you rehearsed with; the output shape is the same.
Beat 4, run (40s). The beat people react to. Show the first two lines: it's a two-line shell script, then a zip. Node mounts the file and runs the app inside it.
Fallback: the artifacts from rehearsal are in the demo folder. Run ./greet.nzip Grace and move on; never debug on stage.
./evil.nzip still runs. A #! line can't check anything; it just starts Node.bundle run checks before a single line of the app runs, and keeps checking every file as it's read."One byte. Not the code, not a file inside: one byte anywhere in the file."
Say the honest part out loud (30s). Running the tampered file by name still runs it. A shebang script has no way to check anything, and the tool says so rather than pretending. If you want the check, run it through bundle run, or ship the standalone executable on the next slide, which can't skip it.
This candour earns trust for the rest of the talk. Don't rush it.
"Same app, same build, but now Node itself is inside the file, and the file refuses to run if it's been touched."
Honest number if asked: it's a copy of Node, so it's about 155 MB. The trade-off is "nothing to install".
Windows: the executable gets .exe and runs the same way. The shell-launcher version uses a file association on Windows that bundle install sets up.
Fallback: building the base takes a few seconds. Pre-build it before the talk and pass --base.
bundle is a .nzipThe package's command is the signed file itself. No wrapper script in between.
Tested on Linux and Windows, audited, signed with the workflow's own identity, published. No secrets.
unzip -l shows what's in it. bundle verify shows who signed it.
"The best evidence that this works is that the tool itself is shipped this way, release after release, from CI."
45 seconds. No secrets in CI: npm trusted publishing and sigstore both use the GitHub Actions identity token.
"Think of it as curl-pipe-sh with the two scary parts removed: nothing runs to install it, and nothing lands on disk unless it checks out."
Walk the prompt (40s): a signer you haven't seen before is a question. An auditor your policy trusts goes straight through. A warning from a stranger is shown, not obeyed, because anyone can publish one. A warning from someone you block on refuses it outright.
Three ways to name an app: a URL, a domain (a TXT record says where), or a published name, which is the next slide.
update finds them."The part I like most: if a scanner flags something you installed last week, your next terminal tells you."
60 seconds. The shell check runs at most once a day, gives up quickly offline, and says nothing when there's nothing to say.
Your Atmosphere account, with any provider. It points at your release URL.
A new version is a new GitHub release. The listing never changes.
Nothing new to compromise. Search runs against a local index.
"There's no new registry here. If you have an Atmosphere account, from whichever provider, you already have a place to publish: the listing lives in your own account and says where the file is."
If asked "why atproto?": accounts that already exist, records anyone can verify without trusting the server, and an open index (Constellation) that lets everyone find listings. A domain with a TXT record works too, for people who'd rather not.
Who built it. With sigstore that's your CI workflow or your login. No keys to manage.
Anyone can vouch for a file ("audited", "rebuilt it, same bytes") or warn against it, from their own account.
Whom you trust, what you require, whose warnings stop an install.
"A signature tells you who built it. It doesn't tell you it's safe: most npm compromises shipped perfectly signed packages from real, hijacked accounts. So other people get to say things about the file too."
70 seconds. Attestations are one record per file in the attester's own account. Withdrawing is deleting the record. Checking them works offline from a cache.
import and require. Your plugin code doesn't change."If your app takes plugins today, they're probably npm packages in node_modules. Same code, but now they're signed files, installed for your app, and you can require that they're audited."
Key safety property (say it): plugins resolve last. A plugin called lodash can't swap out your lodash.
Judged as plugins: your app's signing rules don't apply to plugins, because someone else wrote them. Attestation rules do. Don't go deeper unless asked.
"bled is at this URL." An app, or a plugin for one. Releases stay on GitHub.
"These go together, and each must carry this signer or that attestation."
"Audited", "permitted", "malware", about one file's hash, or a whole suite's.
"Here's the whole ecosystem on one slide. Four kinds of people, and every one of them writes only to their own account. Nobody runs a registry."
75 seconds. Walk left to right: the publisher says where; the curator says what goes together and what it must carry; the auditor, the scanner, or your IT department says what they think of it. The fourth person is you, and your policy decides whose word counts.
If asked how anyone finds anything: each kind of record carries a fixed value that an open backlink index (Constellation) can be asked about. It only says whom to ask: every record is fetched from its owner's server and checked against their key.
"A suite is a list of things that go together, like your team's toolset. It doesn't vouch for anything. It says what each tool should carry, so you get asked once instead of ten times."
80 seconds. Then the company case: IT requires 'permitted by us' of everything and blocks on itself. It permits a suite with one attestation, and can still prohibit a single tool inside it, or permit one tool from a suite it prohibited.
If asked "so a suite is a trust shortcut?": no. Attesting a suite pins its requirements, not the files, so a permit is only as strong as what the suite requires. The audit skill says exactly that when it's asked to vet one.
A suite is plain JSON, so it can also be served from a URL or named in a DNS TXT record, with no atproto at all.
"Sometimes one product is thirty archives: services, web apps, drivers. You don't want thirty signatures, and you really don't want half of them updated. So the app lists each part's hash, you sign the app, and that covers all of them."
60 seconds. This is how ordner.fyi ships: one suite app, every service a part. Each systemd unit runs ordner-suite run <service>, which runs that part as the program.
If asked "how is that different from a suite?": a suite is separately published things that go together, each reviewed on its own. Parts are one release of one product: one review, of the app, and its parts follow from its pins.
If asked "why not the sha256 of the file?": that also covers where a signature goes. The pin is the hash attestations name, so a part signed later still matches.
The workflow's own identity signs, and users can require exactly that workflow.
audit --check refuses unless a clean verdict covers these exact bytes.
bundle skill installs the review procedure.
"In CI the signer is the workflow itself, so there's nothing to leak, and your users can say 'only accept it if it was signed by that workflow on main'."
60 seconds. This repo's own publish workflow is the full real version: audit with Claude Code against the last release, gate, sign, publish to npm with trusted publishing.
| npm i -g | Docker | compiled binary | bundle | |
|---|---|---|---|---|
| Needs a registry at install | yes | yes | no | no |
| Runs code to install | can | no | no | no |
| Same bytes everywhere | no | yes | yes | yes |
| Open it, see what's inside | sort of | with effort | no | unzip |
| Checked before it runs | no | if you set it up | rarely | yes, signed whole |
| Size | small | an OS | a runtime | tiny, or a runtime |
"This doesn't replace npm. You still develop with npm. It changes what you hand to the person at the other end."
45 seconds. Don't litigate the cells. "Compiled binary" means pkg-style, nexe, bun --compile and friends: great for one-file delivery, but you can't look inside, and they usually aren't signed over the whole file.
--experimental-vfs, and both sides are experimental.bundle run or a standalone executable when you want the check every time."Before you go and use this for everything: four things it isn't."
50 seconds. Being upfront here is what makes the rest credible.
github.com/pipobscure/bundles
npm: @pipobscure/bundle
@pipobscure.com
"If you ship a CLI, try packing it tonight. I'd love to hear where it breaks."
The third line is the observe step: run the app once, the way it is normally used, and it writes down every file it reads. The README has the rest.
One file. Signed. Run by plain node.
Likely questions, short answers:
The timer turns red when you are behind the planned pace for the current slide and green when you are ahead. Planned runtime is about 20 minutes, leaving room for questions in a 25-minute slot.