Node.js · shipping · supply chain

Your Node app, one file.

Signed, inspectable, and run by plain node. No npm install on the other end.

๐ŸŒณ @pipobscure/bundle node 26.11+ experimental

Philipp Dunkel  ·  @pipobscure.com  ·  pip@pip.fyi

github.com/pipobscure/bundlespress ? for controls
Show of hands

How do you ship a Node tool to someone?

npm install -g your-tool

Their machine resolves your dependency ranges against the registry, today, and runs install scripts while it does.

A Docker image

Works. Ships an operating system to run one command-line tool.

git clone && npm ci

A README full of steps, and a support channel for when step four fails.

"Compile it to a binary"

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.

the options you already know
The awkward question

What is actually running on that machine?

  • Decided at install time, not when you released. ^1.2.0 means "whatever the registry says today".
  • Install runs code. postinstall scripts run before anyone has looked at anything.
  • Nothing records it. Two installs of the same version can differ, and nobody can tell you how.
  • And it gets attacked. Hijacked maintainer accounts and self-spreading install-script worms were both npm news in 2025.
$ npm install -g some-tool added 214 packages in 9s # which 214? from where? # what ran just now?

You shipped a version. They got whatever resolved.

version โ‰  code
The idea

Freeze the whole tree into one file. Sign it. Let Node run it straight out of the file.

your app + node_modules

Exactly the files it reads. Nothing more.

one signed file

Decided once, when you release. Can't change afterwards.

plain node runs it

No install step, no registry, no extraction to disk.

@pipobscure/bundle
The only internals slide

It's a zip.

$ unzip -Z1 greet.nzip index.js lib/hello.js package.json AUTHORITY.PEM # who signed it
  • A real ZIP. Every tool you have can open it. Nothing hidden.
  • One signature over the whole file. Change one byte, anywhere, and it no longer checks out.
  • Node runs it as a file system. Since Node 26, node --vfs-load=app.nzip mounts a zip and runs the app inside, no extracting.
that's all the format you need tonight
Getting the tool

Two commands.

$ curl -LO https://github.com/pipobscure/bundles/releases/latest/download/bundle.nzip $ node --experimental-vfs --vfs-load ./bundle.nzip install * installing this package itself from https://github.com/pipobscure/bundles/releases/โ€ฆ bundle installed in ~/.local/bin * add Tab completion, and a quiet daily re-check of what you install, to ~/.zshrc? [Y/n]

And the thing it installed is itself a bundle: one signed file, checked before it lands on your PATH.

node --vfs-load ./bundle.nzip install
Demo 1

Pack it, sign it, run it.

# 1. run it once, and write down what it reads $ BUNDLE_MANIFEST=app.manifest node --experimental-vfs \ -r @pipobscure/bundle/record --vfs-load=./app -- Ada hello, Ada $ cat app.manifest package.json index.js lib/hello.js # lib/never-used.js? not read, not in
# 2. pack exactly that, as a program $ bundle create --base ./app -f app.manifest \ --launcher -o greet.unsigned.nzip * unsigned archive (3 members, sha256 digests)
# 3. sign it, as it is $ bundle sign greet.unsigned.nzip * signed: 5e8e6ade4ec94378ead2โ€ฆ * wrote greet.nzip (1904 bytes)
# 4. it's a program $ ./greet.nzip Grace hello, Grace $ head -2 greet.nzip #!/bin/sh exec node --no-warnings --experimental-vfs --vfs-load="$0" -- "$@"

Two kilobytes. No node_modules on the other end, no install step.

observe โ†’ create โ†’ sign โ†’ run
Demo 2

Now break it.

# change one byte in the middle $ cp greet.nzip evil.nzip $ printf X | dd of=evil.nzip bs=1 seek=300 conv=notrunc $ bundle verify evil.nzip INVALID โ€” archive hash does not match the recorded hash $ bundle run evil.nzip Grace error: refusing to run 'evil.nzip': invalid โ€” archive hash does not match the recorded hash
$ bundle verify greet.nzip VALID โ€” trusted certificate chain

The honest part

  • ./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.
  • A standalone executable checks itself. That's the next demo.
one byte โ†’ refused
Demo 3

No Node on the other end?

$ bundle sea --output greet.unsigned greet.unsigned.nzip * building the SEA base (node runtime + verifier) * wrote greet.unsigned (unsigned) $ bundle sign greet.unsigned # -> greet $ ./greet Grace hello, Grace # the same byte flip, inside the executable: $ ./greet-tampered Grace refusing to run: โ€ฆ invalid
  • One executable: Node, the verifier, and your app, signed as a whole.
  • It checks itself before running anything, every time. There's no way to start it that skips the check.
  • It can carry its own rules, such as "only run if signed by our release workflow".
  • Worker threads just work, and they're checked too.
bundle sea
Does it actually work?

The tool ships this way.

npm's bundle is a .nzip

The package's command is the signed file itself. No wrapper script in between.

Released by CI

Tested on Linux and Windows, audited, signed with the workflow's own identity, published. No secrets.

Checkable by anyone

unzip -l shows what's in it. bundle verify shows who signed it.

eating our own cooking
Installing other people's apps

See who vouches, then decide.

$ bundle install @alice.example/tool # or a URL, or a domain * tool: sha256:3f1aโ€ฆ (signed) 1 signed by https://github.com/acme/tool/.github/workflows/release.yml@refs/heads/main โ€” new 2 attested by audit.acme.com (did:web:audit.acme.com) as audited โ€” trusted by policy WARNING scanner.example (did:plc:โ€ฆ) as malware โ€” marked it bad * tool: โ€ฆ accept which? numbers (1,2), 'all', or Enter to decline:
  • Nothing runs to install it. Fetch, check, move into place. No install scripts.
  • You decide whom to trust, once. Your answer is remembered for every update.
  • Broken or tampered files never land. Not even briefly.
curl | sh, minus the scary parts
After the install

Stay current, hear about problems.

$ bundle update 1 of 4 updated: tool # a new terminal, the next morning: tool: WARNING โ€” scanner.example (did:plc:โ€ฆ) as malware has marked it bad since it was last checked $ bundle installed tool CHANGED the file is not the bytes that were installed
  • Updates follow the publisher. Moved releases, a new URL, a renamed domain: update finds them.
  • Bad news finds you. A quiet check at shell start tells you when someone flags something you installed.
  • Changed files get noticed, whoever changed them.
update ยท validate ยท installed
Publishing, without a registry

Publish under your handle.

$ bundle publish --as you.example --description 'preview markdown in the terminal' \ mdview https://github.com/you/mdview/releases/latest/download/mdview.nzip published mdview as did:plc:โ€ฆ # anyone, anywhere: $ bundle search markdown @you.example/mdview preview markdown in the terminal $ bundle install @you.example/mdview

A record in your account

Your Atmosphere account, with any provider. It points at your release URL.

Releases stay releases

A new version is a new GitHub release. The listing never changes.

No registry to run

Nothing new to compromise. Search runs against a local index.

publish ยท search ยท install @you/name
Trust, made visible

Who vouches for this file?

๐Ÿ” The signature

Who built it. With sigstore that's your CI workflow or your login. No keys to manage.

โœ๏ธ Attestations

Anyone can vouch for a file ("audited", "rebuilt it, same bytes") or warn against it, from their own account.

Your policy

Whom you trust, what you require, whose warnings stop an install.

$ bundle attest --as audit.acme.com --kind audited tool.nzip # vouch $ bundle attest --as scanner.example --verdict bad --kind malware tool.nzip # warn
signatures ยท attestations ยท policy
For app authors

Plugins that are signed bundles too.

// in your app import { use, list } from '@pipobscure/bundle/plugins'; use('bled'); // this app's plugins const gpio = await import('@alice/bled-gpio'); // plain import // and, if you want, checked as they load: use('bled', { verify: { attesters: ['audited@did:web:bled.dev'] } });
# for your users $ bundle search --for bled gpio $ bundle install --for bled @alice.example/bled-gpio
  • Ordinary import and require. Your plugin code doesn't change.
  • A plugin can't replace your dependencies. Yours always win; plugins only fill gaps.
  • Plugins get your app's modules, the same instance your app has.
  • Installed per app, reviewed like any install, removed with the app.
use() ยท install --for ยท search --for
The whole picture

Everyone writes to their own account.

publisher

Listings

"bled is at this URL." An app, or a plugin for one. Releases stay on GitHub.

curator

Suites

"These go together, and each must carry this signer or that attestation."

auditor ยท scanner ยท IT

Attestations

"Audited", "permitted", "malware", about one file's hash, or a whole suite's.

# one record each, in their own atproto repository at://you.example/com.pipobscure.bundle.listing/mdview at://curator.example/com.pipobscure.bundle.suite/devtools at://it.acme.com/com.pipobscure.bundle.attestation/3f1aโ€ฆ
  • You hold the policy. Whom you trust, what you require, whose warning stops an install.
  • Nothing in the middle to compromise. No registry, no service of ours; an open index only says whom to ask.
listing ยท suite ยท attestation ยท policy
Suites

A whole toolset, one question.

$ bundle install @acme.com/devtools * suite devtools expects its members to carry: 1 signed by โ€ฆ/acme/cli/โ€ฆ/release.yml@refs/heads/main โ€” for cli 2 attested by published@did:plc:โ€ฆ โ€” for cli-git, cli-k8s accept which, for those members? all suite devtools: 3 installed
# IT pushes one rule to every laptop: { "require": { "attesters": ["permitted@did:web:it.acme.com"] }, "block": ["did:web:it.acme.com"] } $ bundle suite attest --kind permitted @acme.com/devtools
  • The suite vouches for nothing. Each member is reviewed on its own. The suite sets a floor, and you're asked once.
  • Updates follow the suite. Members added get installed, members dropped get removed, and only what it brought.
  • IT permits suites or single bundles, and what it says of a bundle wins: permit the suite, prohibit one tool in it.
  • Prohibiting a suite stays with the suite. Its tools can still be permitted one by one.
suite add ยท validate ยท publish ยท attest
Apps made of parts

Thirty archives, one signature.

# the app's package.json, written by CI: "bundle": { "parts": [ { "package": "@ordner.fyi/auth", "url": "auth.nzip", "hash": "sha256:3f1aโ€ฆ" }, โ€ฆ ] }
$ bundle install --system https://ordner.fyi/bundles/suite/suite.nzip suite installed with 29 parts $ bundle update ~ @ordner.fyi/auth updated, a part of suite
  • The app pins every part by hash. Sign the app, and you've signed exactly those bytes.
  • Parts ship beside it. Installed as its plugins, with no signature of their own.
  • All of them or none. An update never leaves half the services on the old release.
  • It runs them: runPlugin(), and workers just work.
bundle parts ยท install --system ยท runPlugin
In your pipeline

Release from CI, with no secrets.

# .github/workflows/release.yml (the gist) permissions: id-token: write # sigstore signs as this workflow contents: write steps: - run: npm ci - run: npx bundle create --base . --files app.manifest --launcher --output app.unsigned.nzip - run: npx bundle audit --check app.unsigned.nzip # the gate - run: npx bundle sign app.unsigned.nzip # -> app.nzip - run: gh release upload "$TAG" app.nzip

No key to store

The workflow's own identity signs, and users can require exactly that workflow.

A gate you choose

audit --check refuses unless a clean verdict covers these exact bytes.

Reviewed by Claude, or you

bundle skill installs the review procedure.

create โ†’ audit โ†’ sign โ†’ release
Where it fits

Compared with what you use today.

npm i -gDockercompiled binarybundle
Needs a registry at installyesyesnono
Runs code to installcannonono
Same bytes everywherenoyesyesyes
Open it, see what's insidesort ofwith effortnounzip
Checked before it runsnoif you set it uprarelyyes, signed whole
Sizesmallan OSa runtimetiny, or a runtime
not a replacement for npm; a way to ship what npm built
Honestly

What this is not.

  • Not stable yet. It needs Node 26.11+ with --experimental-vfs, and both sides are experimental.
  • Not a sandbox. Checked code runs with full access to your machine, like any Node program.
  • Not proof of safety. A signature says who built it, not that it's harmless. That's what review and attestations are for.
  • Not checked when run by name. Use bundle run or a standalone executable when you want the check every time.
experimental, and saying so
Try it tonight

Five commands.

$ curl -LO https://github.com/pipobscure/bundles/releases/latest/download/bundle.nzip $ node --experimental-vfs --vfs-load ./bundle.nzip install $ BUNDLE_MANIFEST=app.manifest node --experimental-vfs -r @pipobscure/bundle/record --vfs-load=./app $ bundle create --base ./app --files app.manifest --launcher --output app.unsigned.nzip $ bundle sign app.unsigned.nzip && ./app.nzip

Code & docs

github.com/pipobscure/bundles

Package

npm: @pipobscure/bundle

Say hi

@pipobscure.com

feedback very welcome, it's experimental

Questions?

One file. Signed. Run by plain node.

github.com/pipobscure/bundles
0:00