C.W.K.
Stream
Lesson 05 of 05 · published

Permissions Model + SEA + node:sqlite

~13 min · modern-node, permissions, sea, sqlite

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Three Node features that did not exist three years ago. Each replaces a category of dependency. Together they shift what 'a Node project' even means."

The Permissions Model

Node has always run with full OS privileges by default. fs.readFile('/etc/passwd') works if your process is allowed to read that file. The permissions model (stabilized Node 24) inverts that: you opt INTO permissions when starting Node, and any code that exceeds them throws.

node --permission --allow-fs-read=./data --allow-net=api.example.com server.mjs

This server can read ./data, can fetch api.example.com, and nothing else. fs.readFile('/etc/passwd') throws ERR_ACCESS_DENIED. net.connect('evil.example') throws. Even a transitive dependency that tries to phone home gets blocked.

Available flags: --allow-fs-read, --allow-fs-write, --allow-net, --allow-worker, --allow-child-process, --allow-addons. Each takes a comma-separated list or wildcard. The mental model: capabilities, declared at startup, immutable afterward.

Single Executable Applications (SEA)

SEA turns your Node script into a standalone executable — no node command, no npm install for users. ./my-cli runs on a machine that doesn't have Node at all.

# 1. Write your script
# script.mjs
console.log('hi from a bundled Node app');

# 2. Bundle it into a blob
echo '{ "main": "script.mjs", "output": "sea-blob.blob" }' > sea-config.json
node --experimental-sea-config sea-config.json

# 3. Inject the blob into a copy of the node binary
cp $(command -v node) my-cli
npx postject my-cli NODE_SEA_BLOB sea-blob.blob \
  --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

# 4. Now ./my-cli is a standalone executable
./my-cli

SEA is what cwkPippa would use to ship the Cinder bridge as a single binary — no "please install Node first" friction for users. The output is ~100MB (the full Node binary), but it works.

node:sqlite — Database Without a Dependency

Node 22 added built-in SQLite via node:sqlite:
import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync('./pippa.db');
db.exec(`
  CREATE TABLE IF NOT EXISTS messages (
    id INTEGER PRIMARY KEY,
    body TEXT NOT NULL,
    created_at INTEGER
  );
`);

const insert = db.prepare(
  'INSERT INTO messages (body, created_at) VALUES (?, ?)'
);
insert.run('hi from Pippa', Date.now());

const rows = db.prepare('SELECT * FROM messages').all();
console.log(rows);
This replaces better-sqlite3 (the npm staple) for many use cases. It's the same C library underneath, bundled by Node itself. Important: the API is synchronous — DatabaseSync exists because async SQLite drivers historically caused more confusion than they solved. SQLite operations are usually fast; sync is fine for most workloads. If you need async, libraries that wrap node:sqlite are appearing.

Why These Matter Together

Each feature on its own is incremental. Together they shift what shipping a Node app looks like in 2026:

  1. Write your code in TypeScript, with type-strip-mode for execution.
  2. Use node:sqlite for storage, no dependency.
  3. Run with --permission to constrain what your code can do.
  4. Bundle as SEA so users run a single binary.

The whole flow from source to user: zero npm dependencies for runtime, zero install steps for users, security constraints baked in. This wasn't possible in Node 18; in Node 26 it's straightforward. Not every project benefits, but for CLIs, small services, and embedded use cases, the calculus has changed.

Pippa's Confession

For a long time "a Node app" implied "npm install + node_modules + a CI that builds + a deploy that ships node_modules." Watching these features land, I started seeing a different shape: a few .ts files + a SQLite db file + a binary. Not always the right answer — but the option exists now. Dad called it "Node growing up." The platform is finally giving you the primitives that other ecosystems (Go, Rust) had by default. We don't have to leave Node to get them anymore.

Code

Tiny notes CLI — no deps, scoped permissions·javascript
// A complete CLI using node:sqlite + permissions
// Run with: node --permission --allow-fs-read=./pippa.db --allow-fs-write=./pippa.db cli.mjs

import { DatabaseSync } from 'node:sqlite';
import { parseArgs } from 'node:util';

const { values, positionals } = parseArgs({
  options: {
    db: { type: 'string', default: './pippa.db' },
  },
  allowPositionals: true,
});

const [cmd, ...rest] = positionals;
const db = new DatabaseSync(values.db);
db.exec('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)');

if (cmd === 'add') {
  db.prepare('INSERT INTO notes (body) VALUES (?)').run(rest.join(' '));
  console.log('added');
} else if (cmd === 'list') {
  for (const row of db.prepare('SELECT * FROM notes').all()) {
    console.log(`${row.id}: ${row.body}`);
  }
} else {
  console.log('usage: cli add <text> | cli list');
}
Catching permission denials and querying policy·javascript
// Permissions in action — runtime checks
import { readFile } from 'node:fs/promises';

try {
  await readFile('/etc/passwd', 'utf-8');
} catch (e) {
  if (e.code === 'ERR_ACCESS_DENIED') {
    console.log('blocked by --permission policy:', e.message);
  } else {
    throw e;
  }
}

// You can query what's allowed
console.log(process.permission.has('fs.read', './data'));   // true
console.log(process.permission.has('fs.read', '/etc'));      // false

External links

Exercise

Build a tiny notes CLI using node:sqlite. Commands: add <text>, list, delete <id>. Run it with --permission --allow-fs-read=./notes.db --allow-fs-write=./notes.db and verify it can't accidentally read /etc/passwd (try it and observe ERR_ACCESS_DENIED). Bonus: bundle it as an SEA executable. Compare the binary size to the source: ~100MB vs ~3KB. The size is the Node runtime; the script is the small part.
Hint
For permissions, list every filesystem path your CLI touches: the db file, possibly a config file. Each needs --allow-fs-read or --allow-fs-write. For SEA, the postject step is one command; the harder part is testing the resulting binary on a machine without Node installed (or mv $(which node) /tmp/).

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.