@nodeholder/pdata
v0.8.0
Published
PData service for secure file management
Readme
@nodeholder/pdata
A Plan 9-inspired multi-tenant file management system with capability-based security, virtual filesystem namespaces, and audit logging. It gives an Express app secure, isolated per-user workspaces via a token-based capability model, with no database — everything is CSV + filesystem.
The "P" is for Provisional. PData is a pragmatic, file-based identity and storage layer meant for rapid deployment, not a permanent identity authority. It intentionally does not know about roles, tiers, sessions, or any particular host framework beyond what it needs to hold files and check capabilities. A host application is expected to own the durable identity/role model and treat PData's own user/role tables as a local, provisional default — see TECHNICAL_DESCRIPTION.md for the full design rationale.
Two things, two names: the vault and the mount
PData is two jobs behind one constructor, and it pays to name them:
- The vault — the account store at rest. A directory carrying
pdata.json+users.csv+roles.csv: a login verifier and a role per user, journaled and atomically written, pure filesystem. No secret. Open it by path withUserManager.open(dir); steward it from the terminal withpdata-usermgr(orpdata usersin tetra). One of many, portable (cp -rreproduces it whole), sealable at rest. - The mount — the vault bound into a running host with the namespace
engine on top: virtual mounts, capability globs, HMAC session tokens. That
is
new PData(...), and that is what needsPDATA_SECRET.
Vault and mount are two states of one store, the way a disk image is either sealed on disk or mounted live. If stewarding users ever demands a token-signing secret, the two have been fused again.
Using just the vault (no secret, no server)
import { UserManager } from '@nodeholder/pdata/users'; // or from the package root
const vault = UserManager.open('/abs/path/to/vault', { bootstrap: true }); // bootstrap: create if absent
await vault.addUser('alice', 'a-good-password', ['admin']);
vault.validateUser('alice', 'a-good-password'); // true
vault.listUsersWithRoles(); // { alice: ['admin'] }
UserManager.describe('/abs/path/to/vault'); // { isVault, marker, files, users, roles }Absent is not empty: opening a directory with no users.csv throws unless
you pass bootstrap: true (or set PDATA_BOOTSTRAP=1). A store is created
by an explicit act, never as a side effect of failing to read one.
From the terminal:
export PD_DIR=/abs/path/to/vault
pdata-usermgr init # create an empty vault (marker + tables)
pdata-usermgr add alice admin # prompts for the password (no echo); never on argv
pdata-usermgr list
pdata-usermgr passwd alice
pdata-usermgr setrole alice user dev
pdata-usermgr delete alice # prior record journaled in users.journal.jsonl
pdata-usermgr statusA password arrives on $PDATA_PASS_FD (a descriptor — how tetra's
pdata users hands over tetra_read_pass input), or at a muted tty prompt.
A piped password is refused: a pipe is not a person.
Sealing a vault at rest (encryption under an operator-held passphrase) is a
ceremony, not a library concern — pdata vault seal|open|verify in tetra
(tetra/bash/pdata), reusing the escrow cipher and the one passphrase
prompt. A running mount reads plaintext, protected by 0600/0700.
Install
npm install @nodeholder/pdataPeer dependencies (express, and optionally @aws-sdk/client-s3 +
@aws-sdk/s3-request-presigner for S3-backed storage) are not bundled —
install what your app needs.
Binding a second vault into a running mount (mount, 0.8.0)
A running host can bind an additional vault by path, under the alias
~<name>, without restarting with different constructor config:
const rec = pdata.mount('arcade', '/abs/path/to/other-vault', {
roles: ['admin'], // who gets the alias in their token (default: admin)
access: 'ro', // 'ro' = list+read, 'rw' adds write (default: ro)
});
// rec = { name, alias: '~arcade', root, roles, access, boundAt, users, marker }
pdata.listMounts(); // every bound mount
pdata.unmount('arcade'); // reverses containment, mountManager, and the recordmount refuses anything that is not a vault (UserManager.describe(dir).isVault
— the pdata.json marker or a users.csv), a reserved system alias, a
malformed name, a relative path, an unknown role, and the host's own root
(that is already ~system). The alias and its caps are grafted into tokens
minted after the bind for the roles named; a token minted before is a
snapshot and never learns of it, and a principal outside those roles has no
alias at all — the path cannot even resolve. What a bind exposes is an
account store, so the default is admin-only, read-only.
The binding is runtime-only: mount is to constructor hostMounts
what mount(8) is to fstab. A restart forgets it. Over HTTP the same three
verbs are GET|POST /api/admin/mounts and DELETE /api/admin/mounts/:name
in pbase, and pdata mount <path> in tetra is their CLI face.
Quick start
import { PData, createPDataRoutes } from '@nodeholder/pdata';
import express from 'express';
const pdata = new PData({
// systemRoots: {}, // optional additional mount points
// audit: { enabled: true, logLevel: 'info' },
});
// Authentication
const token = await pdata.createToken('alice', 'password123');
const payload = pdata.validateToken(token);
// User management
await pdata.addUser('bob', 'secret', ['user']);
await pdata.setUserRoles('bob', ['user', 'dev']);
// File operations, scoped to the caller's mount namespace
await pdata.writeFile('alice', 'file.txt', 'content');
const content = await pdata.readFile('alice', 'file.txt');
await pdata.listDirectory('alice', '~/data/users/alice');
await pdata.deleteFile('alice', 'file.txt');
// Mount as HTTP routes (requires Passport authentication middleware upstream)
const app = express();
app.use('/api/pdata', createPDataRoutes(pdata));Environment variables
| Variable | Required | Purpose |
|---|---|---|
| PD_DIR | yes | Absolute path to PData's data root (the vault) |
| PDATA_SECRET (or SESSION_SECRET) | mount only | Secret key for HMAC-SHA256 token signing — needed by new PData(...), never by UserManager.open |
| PDATA_BOOTSTRAP | no | 1 = create an empty store if absent (the explicit act; same as open(dir,{bootstrap:true})) |
| PDATA_PASS_FD | no | pdata-usermgr only: a descriptor to read a password from (never argv, never a pipe) |
| PDATA_AUDIT | no | Set false to disable audit logging |
| PDATA_AUDIT_LEVEL | no | debug/info/warn/error |
| PDATA_AUDIT_CONSOLE | no | Set false to disable console audit output |
| PDATA_AUDIT_LOG | no | Custom audit log path |
| TSM_LOGS_DIR | no | Tetra TSM log directory, for LogAdapter integration |
Core components
- PData — orchestrator; wires up the managers below and owns token lifecycle
- UserManager — PBKDF2-SHA512 auth, CSV-backed users/roles
- FileManager — virtual path resolution, symlinks, capability-gated CRUD
- CapabilityManager — CSV-defined role → capability expansion (
r:/w:/d:/l:/x:glob expressions) - AuthSrv — HMAC-signed token issuance/validation, mount resolution
- AuditLogger — Redux-style event hooks (console, file, or custom sinks) with a query interface
- MountManager — Plan 9-style three-tier mount namespaces (
~data/~log/~cache, per-user~/data/users/<name>)
Security notes
Protects against path traversal, null-byte injection, token forgery/expiry,
capability escalation, cross-user namespace access, and CSV/username
injection. It does not provide rate limiting, timing-attack-safe password
comparison, or protection if PDATA_SECRET itself leaks — treat that secret
and PD_DIR's permissions (700) as required deployment hardening, and put
PData behind TLS in production.
Testing
npm test # full suite
npm run test:watch
npm run test:coverage
npm run test:api # API routes onlyMore
Full architecture, threat model, virtual filesystem layout, and every environment/API detail live in TECHNICAL_DESCRIPTION.md. See CHANGELOG.md for release history.
License
ISC
