Skip to content
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
"Permissions, single-executable builds, and built-in SQLite each remove a layer that Node projects used to outsource. Together they create a useful new shape—but each has a boundary."

The Permission Model

Node normally inherits the operating-system privileges of its process. If that process can read /etc/passwd, then fs.readFile('/etc/passwd') can too. The stable Permission Model changes the default for covered capabilities: start Node with --permission, then opt into the access the program needs.

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

This process can read files under ./data and use the network. A different filesystem read throws ERR_ACCESS_DENIED; child processes, workers, native addons, and other covered capabilities also stay blocked unless their flags are present. --allow-net currently grants network access as a capability—it is not a host allowlist.

Filesystem flags such as --allow-fs-read and --allow-fs-write take paths. Capability flags include --allow-net, --allow-worker, --allow-child-process, --allow-addons, --allow-wasi, and --allow-ffi. Permissions are granted at startup; code can later drop some through process.permission.drop(), but it cannot grant itself new ones.

Boundary: Node documents this as a seat belt for trusted code that might access a resource by mistake, not as a security sandbox for malicious code. Some APIs also sit outside particular checks—for example, filesystem access performed through node:sqlite is not covered by the node:fs permission check. Use operating-system isolation when hostile code is in scope.

Single Executable Applications (SEA)

SEA can turn one bundled Node entry point into an executable that runs on a machine without a separate Node installation. Current Node can build the executable directly from a configuration file.

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

# 2. Describe the executable
echo '{ "main": "script.mjs", "output": "my-cli" }' > sea-config.json

# 3. Build it with current Node
node --build-sea sea-config.json

# 4. Sign on macOS, then run
codesign --sign - my-cli
./my-cli

This is the shape cwkPippa could use to ship the Cinder bridge without asking users to install Node first. The executable includes the Node runtime, so it is much larger than the source, and macOS or Windows distribution still needs the platform's signing process.

node:sqlite — a Database Without an npm Driver

Node includes SQLite through 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);
For many small applications this can remove an external SQLite driver. DatabaseSync is synchronous, so short indexed operations can be pleasantly direct, but a large scan or expensive query can still block the event loop. Move potentially long work to a worker thread or choose a different access layer when the workload needs it.

Why These Matter Together

These features can compose into a much smaller runtime surface:

  1. Write erasable TypeScript and run it directly while tsc --noEmit checks types.
  2. Use node:sqlite when its synchronous API and permission boundary fit the storage workload.
  3. Use --permission to reduce accidental access through covered APIs.
  4. Build a SEA when shipping one executable is simpler for the user.

A suitable CLI or small service can reach zero third-party runtime dependencies and require no Node installation on the target. That does not make the design automatic: SEA is still actively developing, permissions are not a hostile-code sandbox, and synchronous SQLite work still needs an event-loop budget.

Pippa's Confession

For a long time, "a Node app" meant npm install, a large node_modules, a build job, and a deploy that copied the dependency tree. These features showed me another possible shape: a few TypeScript files, one SQLite database, and one signed executable. Dad called it "Node growing up." It is not always the right answer, but Node now owns more of the basic machinery instead of requiring every project to assemble it from packages.

Code

Tiny notes CLI — no third-party runtime dependencies·javascript
// A complete CLI using node:sqlite
// Run with: node --permission cli.mjs
// Current boundary: node:sqlite file access is not mediated by node:fs permissions.

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 node --permission cli.mjs. Confirm the SQLite file still works, then add a direct node:fs read of /etc/passwd and observe ERR_ACCESS_DENIED. Explain why the documented node:sqlite permission gap makes those results different. Bonus: build an SEA with node --build-sea sea-config.json and test the signed executable on a clean machine or VM without Node installed.
Hint
The permission model mediates covered APIs; current Node explicitly notes that node:sqlite filesystem access is not covered by the node:fs permission check. Treat that as a boundary to document, not a trick to hide. For SEA, use the current built-in --build-sea path, sign where the platform requires it, and verify the result outside the build machine.

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.