A persistent virtual file system for any JavaScript app.

jjfs.js is a zero-dependency JavaScript library that gives any app a virtual file system backed by plain JSON. Works in any browser or Node.js project — including static hosts like Netlify where you can't store data server-side.

563 lines
Total library size
37 exports
Full API surface
0 deps
Zero dependencies
runs in-browser
no server required

The Problem

Many hosting environments — Netlify, Vercel, GitHub Pages, and similar static hosts — don't give you a place to store and update data on the fly. You need to read and write structured data, but you have no database, no writable server filesystem, and no control over the backend.

WorkaroundThe catch
Spin up a dedicated backendExtra infrastructure, cost, and maintenance
Use a third-party database serviceVendor lock-in, schema constraints, pricing tiers
Stuff everything in localStorageNo structure, no permissions, no cross-client access
Push data to a git repo on every changeRate limits, latency, and a polluted commit history
JJFS is a different answer: a structured virtual file system backed by plain JSON, with permissions, timestamps, symlinks, and extended attributes — deployable anywhere, including directly in the browser with no server at all.

What it looks like

JSONworkspace structure
{
  "default": {
    "README.md": "# My Project",
    "src": {
      "index.js": "console.log('hello');",
      "utils.js": "export function add(a, b) { return a + b; }"
    }
  }
}

A workspace is a named root. A file is a string value. A directory is a nested object. That is the entire data model for the file tree.

Data Model

1. Workspace map — wsForKey

All file operations take a wsForKey object as their first argument.

JS
const wsForKey = {
  default: {
    'hello.txt': 'Hello, world!',
    src: { 'index.js': 'console.log("hi");' },
  },
  docs: { 'guide.md': '# Guide' },
};

A file is a string value. A directory is a plain object. A workspace is a named entry in wsForKey.

2. Metadata stores

Permissions, timestamps, symlinks, and extended attributes are stored in separate flat-keyed objects:

JS
const fsPerms      = {}; // { email: { "ws:/path": { mode, owner } } }
const fsTimestamps = {}; // { email: { "ws:/path": { birthtime, mtime, ctime } } }
const fsSymlinks   = {}; // { email: { "ws:/path": "/target/path" } }
const fsXattrs     = {}; // { email: { "ws:/path": { "user.key": "value" } } }

All metadata functions take their store as the first parameter. No globals. Persistence is the caller's responsibility.

Everything you need, nothing you don't

Zero dependencies

No imports. No Node.js. Copy jjfs.js into your project and import it. That's it.

Universal

Runs in any modern browser as an ES module, or in any Node.js / Deno / Bun project.

🔒

POSIX-inspired permissions

Full chmod/chown system with ACL objects, octal modes, sticky bit, and inheritance.

🕐

Timestamps, symlinks, xattrs

POSIX-style metadata tracked in parallel stores alongside the file tree.

Get Started

JSbrowser-only usage
import {
  jjfsRead, jjfsWrite, jjfsEdit, jjfsDelete, jjfsMove, jjfsCopy,
  jjfsWriteBinary, jjfsReadBinary,
  jjfsNavigate, parseTarget, countFiles, normalizePath,
  jjfsChmod, jjfsChown,
  getEffectivePermission, getPermBitsForKey,
  checkReadAccess, checkWriteAccess, checkOwnerAccess, checkStickyBit,
  setPermission, removePermissionsUnder,
  isValidMode, parseOctalBits, getStickyBit,
  touchTimestamps, getTimestamps, removeTimestampsUnder,
  jjfsSetSymlink, resolveSymlink, getSymlinksInDir, removeSymlinksUnder,
  jjfsSetXattr, getXattrs, removeXattrsUnder, XATTR_NAME_RE,
  hashPermForResponse,
} from './jjfs.js';

// Create stores
const wsForKey     = { default: {} };
const fsPerms      = {};
const fsTimestamps = {};
const fsSymlinks   = {};
const fsXattrs     = {};

// Write a file
jjfsWrite(wsForKey, 'default', '/hello.txt', 'Hello, world!');
touchTimestamps(fsTimestamps, 'user@example.com', 'default', '/hello.txt',
  ['birthtime', 'mtime', 'ctime']);

// Read it back
const r = jjfsRead(wsForKey, 'default', '/hello.txt');
console.log(r.result); // 'Hello, world!'
No build step. No npm install. Persistence is up to you — wire localStorage, IndexedDB, or a remote API.
JSNode.js server with persistence
import fs from 'fs';
import {
  jjfsRead, jjfsWrite, jjfsEdit, jjfsDelete, jjfsMove, jjfsCopy,
  jjfsChmod, jjfsChown, jjfsSetSymlink, jjfsSetXattr, getXattrs,
  parseTarget, touchTimestamps,
  removePermissionsUnder, removeTimestampsUnder,
  removeSymlinksUnder, removeXattrsUnder,
} from './jjfs.js';

// Load five stores on startup
function load(file) {
  try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return {}; }
}

const wsForKey     = load('./workspaces.json');
const fsPerms      = load('./permissions.json');
const fsTimestamps = load('./timestamps.json');
const fsSymlinks   = load('./symlinks.json');
const fsXattrs     = load('./xattrs.json');
if (!wsForKey.default) wsForKey.default = {};

function save() {
  fs.writeFileSync('./workspaces.json',  JSON.stringify(wsForKey,     null, 2));
  fs.writeFileSync('./permissions.json', JSON.stringify(fsPerms,      null, 2));
  fs.writeFileSync('./timestamps.json',  JSON.stringify(fsTimestamps, null, 2));
  fs.writeFileSync('./symlinks.json',    JSON.stringify(fsSymlinks,   null, 2));
  fs.writeFileSync('./xattrs.json',      JSON.stringify(fsXattrs,     null, 2));
}

// POST /api/fs/execute
app.post('/api/fs/execute', (req, res) => {
  const { type, target, content } = req.body;
  const email    = req.user.email;
  const callerId = req.headers['x-api-key'] || null;
  const { wsName, filePath, startLine, endLine } =
    parseTarget(target, type === 'JJFS_READ');

  let result;
  switch (type) {
    case 'JJFS_READ':
      result = jjfsRead(wsForKey, wsName, filePath, startLine, endLine);
      break;
    case 'JJFS_WRITE':
      result = jjfsWrite(wsForKey, wsName, filePath, content);
      if (result.success) {
        touchTimestamps(fsTimestamps, email, wsName, filePath,
          ['birthtime', 'mtime', 'ctime']);
        save();
      }
      break;
    case 'JJFS_EDIT':
      const { search, replace } = JSON.parse(content);
      result = jjfsEdit(wsForKey, wsName, filePath, search, replace);
      if (result.success) {
        touchTimestamps(fsTimestamps, email, wsName, filePath, ['mtime', 'ctime']);
        save();
      }
      break;
    case 'JJFS_DELETE':
      result = jjfsDelete(wsForKey, wsName, filePath);
      if (result.success) {
        removePermissionsUnder(fsPerms,      email, wsName, filePath);
        removeTimestampsUnder(fsTimestamps,  email, wsName, filePath);
        removeSymlinksUnder(fsSymlinks,      email, wsName, filePath);
        removeXattrsUnder(fsXattrs,          email, wsName, filePath);
        save();
      }
      break;
    case 'JJFS_MOVE':
      result = jjfsMove(wsForKey, wsName, filePath, content);
      if (result.success) {
        removePermissionsUnder(fsPerms,      email, wsName, filePath);
        removeTimestampsUnder(fsTimestamps,  email, wsName, filePath);
        removeSymlinksUnder(fsSymlinks,      email, wsName, filePath);
        removeXattrsUnder(fsXattrs,          email, wsName, filePath);
        touchTimestamps(fsTimestamps, email, wsName, content, ['mtime', 'ctime']);
        save();
      }
      break;
    case 'JJFS_COPY':
      result = jjfsCopy(wsForKey, wsName, filePath, content);
      if (result.success) {
        touchTimestamps(fsTimestamps, email, wsName, content,
          ['birthtime', 'mtime', 'ctime']);
        save();
      }
      break;
    case 'JJFS_CHMOD':
      result = jjfsChmod(fsPerms, email, wsName, filePath, content, callerId);
      if (result.success) { touchTimestamps(fsTimestamps, email, wsName, filePath, ['ctime']); save(); }
      break;
    case 'JJFS_CHOWN':
      const validOwners = req.account.apiKeys.map(k => k.key);
      result = jjfsChown(fsPerms, email, wsName, filePath,
        content, validOwners, callerId);
      if (result.success) { touchTimestamps(fsTimestamps, email, wsName, filePath, ['ctime']); save(); }
      break;
    case 'JJFS_SYMLINK':
      result = jjfsSetSymlink(fsSymlinks, email, wsName, filePath, content);
      if (result.success) { touchTimestamps(fsTimestamps, email, wsName, filePath, ['ctime']); save(); }
      break;
    case 'JJFS_GETXATTR':
      const attrs = getXattrs(fsXattrs, email, wsName, filePath);
      result = { success: true, result: JSON.stringify(attrs) };
      break;
    case 'JJFS_SETXATTR':
      result = jjfsSetXattr(fsXattrs, email, wsName, filePath,
        typeof content === 'string' ? JSON.parse(content) : content);
      if (result.success) { touchTimestamps(fsTimestamps, email, wsName, filePath, ['ctime']); save(); }
      break;
    default:
      result = { success: false, result: `Unknown operation: ${type}` };
  }
  res.json(result);
});
bashusing the hosted API
# Write a file
curl -X POST https://data2l.ink/api/fs/execute \
  -H "X-API-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"JJFS_WRITE","target":"default:/hello.txt","content":"Hello!"}'

# Read it back
curl -X POST https://data2l.ink/api/fs/execute \
  -H "X-API-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"JJFS_READ","target":"default:/hello.txt"}'

No server needed

Sign up at data2l.ink, generate an API key, start using the API.

  • Multi-tenant: each API key has its own isolated partition.
  • Automatic default workspace provisioned for every new key.
  • Both requests return { success: true, result: "..." }.

AI System Prompt

Copy this into your model's system prompt to give it JJFS capabilities.

system prompt template
You have access to JJFS (JavaScript Journaling File System) — a persistent virtual
file system backed by plain JSON. The full jjfs.js library is available to you.
Embed response actions in your text; the system parses and executes them automatically.

All mutating functions modify their first data argument in-place.
Core file operations return { success, result }. Permission/xattr mutators (jjfsChmod, jjfsChown, jjfsSetXattr) also include a numeric status. Cascade helpers (remove*Under, touchTimestamps, setPermission) return void.
All functions are pure JS: no globals, no Node.js required.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DATA SHAPES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

wsForKey     — { [wsName]: workspaceTree }
workspace    — nested plain object; leaf strings = file content, nested objects = dirs
fsPerms      — { [email]: { "wsName:/path": { mode, owner } } }
fsTimestamps — { [email]: { "wsName:/path": { birthtime, mtime, ctime } } }
fsSymlinks   — { [email]: { "wsName:/path": "/target/path" } }
fsXattrs     — { [email]: { "wsName:/path": { "user.key": "value" } } }

Paths are POSIX-style. Leading slash is optional (/src/app.js = src/app.js).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SUBSYSTEM 1 — CORE NAVIGATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

jjfsNavigate(workspace, pathStr)
  → { parent, name } | { error }
  Walk a workspace tree to the parent node and final segment for any POSIX path.

parseTarget(target, forRead?)
  → { wsName, filePath } | { wsName, filePath, startLine, endLine } | { error }
  Parse "wsName:/path" target strings (or "wsName:/path:start:end" when forRead=true).

countFiles(node)
  → number
  Count all leaf files (string values) under any workspace tree node.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SUBSYSTEM 2 — FILE OPERATIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

jjfsRead(wsForKey, wsName, filePath, startLine?, endLine?)
  → { success, result }
  Read a file or list a directory. Path "/" or "" returns workspace root listing.
  Directory entries: subdirs shown with trailing "/". Line range is 1-based inclusive.

jjfsWrite(wsForKey, wsName, filePath, content)
  → { success, result }
  Create or overwrite a file. Creates all intermediate directories automatically.
  content may be a string (file) or object (directory subtree).

jjfsEdit(wsForKey, wsName, filePath, searchStr, replaceStr)
  → { success, result }
  Surgical search-and-replace within a file.
  searchStr must appear EXACTLY ONCE — errors if 0 or 2+ matches.
  Pass "" as replaceStr to delete the matched section.

jjfsDelete(wsForKey, wsName, filePath)
  → { success, result }
  Remove a file or directory (recursive). Cannot delete workspace root.

jjfsMove(wsForKey, wsName, srcPath, destPath)
  → { success, result }
  Move a file or directory. Creates dest parent dirs automatically.

jjfsCopy(wsForKey, wsName, srcPath, destPath)
  → { success, result }
  Deep-clone a file or directory to a new path.

jjfsWriteBinary(wsForKey, wsName, filePath, bytes)
  → { success, result }
  Write binary data (Uint8Array, Buffer, or 0–255 array-like) to a file.
  Encodes as base64 automatically. Use for images, fonts, compiled assets, etc.

jjfsReadBinary(wsForKey, wsName, filePath)
  → { success, result: Buffer | Uint8Array }
  Read a binary file written with jjfsWriteBinary. Decodes base64 back to bytes.
  Returns a Buffer in Node.js or a Uint8Array in the browser.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SUBSYSTEM 3 — PERMISSIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Mode values (accepted by all permission functions):
  "ro"            read-only for everyone
  "rw"            read-write for everyone (default — no stored entry)
  "644" / "0644"  3- or 4-digit octal string (owner / group / other)
  "1755"          leading digit encodes sticky bit (1 = sticky)
  { key: "ro"|"rw"|"[0-7]", "*": ... }  per-caller ACL object; "*" = fallback

normalizePath(p)
  → "/normalized/path"
  Resolve "." and ".." segments; always returns an absolute path string.

isValidMode(mode)
  → boolean

parseOctalBits(digit)
  → { read, write, execute }
  Decode one octal digit into rwx booleans.

getStickyBit(mode)
  → boolean
  True when mode is a 4-digit octal string with the sticky bit set (1xxx).

getEffectivePermission(fsPerms, email, wsName, filePath)
  → { mode, owner, effectivePath, inherited } | null
  Walk from filePath up to "/" and return the most-specific matching entry, or null.

getPermBitsForKey(perm, callerId)
  → { read, write, execute }
  Decode rwx bits for a specific callerId from a permission entry.

checkWriteAccess(fsPerms, email, wsName, filePath, callerId)
  → { allowed: true } | { allowed: false, error }
  callerId = null (session auth) always passes — no access check performed.

checkReadAccess(fsPerms, email, wsName, filePath, callerId)
  → { allowed: true } | { allowed: false, error }
  callerId = null always passes.

checkOwnerAccess(fsPerms, email, wsName, filePath, callerId)
  → { allowed: true } | { allowed: false, error }
  Checks exact path only (not inherited). Paths with no owner allow anyone.
  callerId = null always passes.

checkStickyBit(fsPerms, email, wsName, filePath, callerId)
  → { allowed: true } | { allowed: false, error }
  When a directory has the sticky bit set, only the file or directory owner
  may delete or rename files inside it.
  callerId = null always passes.

setPermission(fsPerms, email, wsName, filePath, updates)
  → void  (mutates fsPerms)
  Upsert a permission entry. String mode replaces entirely; object mode merges
  (null ACL values remove individual keys). Removes the entry when it is a no-op.

removePermissionsUnder(fsPerms, email, wsName, filePath)
  → void
  Remove all permission entries for filePath and every path under it.
  Must be called after jjfsDelete or jjfsMove.

jjfsChmod(fsPerms, email, wsName, filePath, mode, callerId)
  → { success, status, result }
  Validate mode and check owner access, then call setPermission.
  Does NOT persist — caller must save fsPerms and update ctime.

jjfsChown(fsPerms, email, wsName, filePath, owner, validOwners, callerId)
  → { success, status, result }
  owner must be null or a string/array from validOwners.
  Does NOT persist — caller must save fsPerms and update ctime.

hashPermForResponse(perm, hashFn)
  → hashed perm object | null
  Replace raw owner/ACL callerId strings with opaque tokens via hashFn before
  returning permission data to API clients. Pass (k => k) to skip hashing.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SUBSYSTEM 4 — TIMESTAMPS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Timestamps are ISO-8601 strings stored in fsTimestamps (not in the workspace tree).
  birthtime — set once on creation
  mtime     — updated on content change (write, edit)
  ctime     — updated on any metadata change (chmod, chown, rename, xattr)

touchTimestamps(fsTimestamps, email, wsName, filePath, fields)
  → void  (mutates fsTimestamps)
  Set fields (array of "birthtime"|"mtime"|"ctime") to the current ISO timestamp.

getTimestamps(fsTimestamps, email, wsName, filePath)
  → { birthtime, mtime, ctime } | null

removeTimestampsUnder(fsTimestamps, email, wsName, filePath)
  → void
  Remove all timestamp entries for filePath and every path under it.
  Must be called after jjfsDelete or jjfsMove.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SUBSYSTEM 5 — SYMBOLIC LINKS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Symlinks are stored in fsSymlinks (not in the workspace tree).
Target is an absolute path within the same workspace.
Max chain depth: 8 hops (Linux MAXSYMLINKS default).

resolveSymlink(fsSymlinks, email, wsName, filePath, depth?)
  → { path } | { error }
  Follow a symlink chain to its final real path, or error if broken or too deep.

getSymlinksInDir(fsSymlinks, email, wsName, dirPath)
  → { [name]: "/target/path" }
  Return all symlinks that are direct children of dirPath (one level only).

removeSymlinksUnder(fsSymlinks, email, wsName, filePath)
  → void
  Remove all symlink entries for filePath and every path under it.
  Must be called after jjfsDelete or jjfsMove.

jjfsSetSymlink(fsSymlinks, email, wsName, filePath, target)
  → { success, result }
  Create a symlink at filePath pointing to target (absolute path).
  Pass null or "" as target to remove the symlink.
  Does NOT persist — caller must save fsSymlinks and update ctime.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SUBSYSTEM 6 — EXTENDED ATTRIBUTES (xattrs)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Only "user.*" and "trusted.*" namespaces are supported. All values are strings.
Valid name regex: /^(user|trusted)\.[a-zA-Z0-9._-]+$/  (exported as XATTR_NAME_RE)

getXattrs(fsXattrs, email, wsName, filePath)
  → { "user.key": "value", ... }  (empty object if none set)

removeXattrsUnder(fsXattrs, email, wsName, filePath)
  → void
  Remove all xattr entries for filePath and every path under it.
  Must be called after jjfsDelete or jjfsMove.

jjfsSetXattr(fsXattrs, email, wsName, filePath, op)
  → { success, status, result }
  op = { set?: { "user.key": "value", ... }, remove?: string | string[] }
  set adds/updates keys; remove deletes keys by name. Validates against XATTR_NAME_RE.
  Does NOT persist — caller must save fsXattrs and update ctime.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RESPONSE ACTIONS (embed in your response text to operate on files)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

LIST workspace root or a directory:
  [ACTION:JJFS_READ]workspace:/[/ACTION:JJFS_READ]
  [ACTION:JJFS_READ]workspace:/some/dir[/ACTION:JJFS_READ]

READ a file:
  [ACTION:JJFS_READ]workspace:/path/to/file.txt[/ACTION:JJFS_READ]

READ a line range (1-based, inclusive):
  [ACTION:JJFS_READ]workspace:/path/to/file.txt:10:40[/ACTION:JJFS_READ]

WRITE (create or overwrite) a file:
  [ACTION:JJFS_WRITE:workspace:/path/to/file.txt:full file content here[/ACTION]

EDIT (surgical search-and-replace):
  [ACTION:JJFS_EDIT:workspace:/path/to/file.txt:{"search":"exact text","replace":"new text"}[/ACTION]

DELETE a file or directory:
  [ACTION:JJFS_DELETE:workspace:/path[/ACTION]

MOVE a file or directory:
  [ACTION:JJFS_MOVE:workspace:/old/path:/new/path[/ACTION]

COPY a file or directory:
  [ACTION:JJFS_COPY:workspace:/src/path:/dest/path[/ACTION]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
RULES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. NEVER paste file content in your response prose. Use JJFS_WRITE or JJFS_EDIT.
2. PREFER JJFS_EDIT over JJFS_WRITE for existing files.
3. Always READ before EDIT — wait for the result, then copy the text exactly.
4. JJFS_EDIT: "search" must appear exactly once. Include enough surrounding
   context to be unique. Whitespace must match exactly. Use "" to delete.
5. After any delete or move, call removePermissionsUnder, removeTimestampsUnder,
   removeSymlinksUnder, and removeXattrsUnder for the affected path to keep metadata in sync.
6. Do not output code blocks in prose. All code belongs in JJFS files.

API Reference

37 exports across 6 subsystems

FunctionSignatureReturns
jjfsRead(wsForKey, wsName, filePath, startLine?, endLine?){ success, result }
jjfsWrite(wsForKey, wsName, filePath, content){ success, result }
jjfsEdit(wsForKey, wsName, filePath, searchStr, replaceStr){ success, result }
jjfsDelete(wsForKey, wsName, filePath){ success, result }
jjfsMove(wsForKey, wsName, srcPath, destPath){ success, result }
jjfsCopy(wsForKey, wsName, srcPath, destPath){ success, result }
jjfsWriteBinary(wsForKey, wsName, filePath, bytes){ success, result }
jjfsReadBinary(wsForKey, wsName, filePath){ success, result: Buffer | Uint8Array }

Behavior notes

  • jjfsWrite creates intermediate directories automatically. Returns 'Created:...' or 'Overwrote:...'.
  • jjfsEdit searchStr must appear exactly once. Fails safely — nothing is changed if the search text is not found or is ambiguous.
  • jjfsDelete cannot delete a workspace root. After delete, run the cascade helpers on all four metadata stores.
  • jjfsWriteBinary accepts a Uint8Array, Buffer, or any 0–255 array-like. Stores content as a base64 string. jjfsReadBinary decodes it back — returns a Buffer in Node.js or a Uint8Array in the browser.
FunctionSignatureReturns
jjfsNavigate(workspace, pathStr){ parent, name } or { error }
parseTarget(target, forRead?){ wsName, filePath, startLine?, endLine? } or { error }
countFiles(node)number
normalizePath(p)string (always starts with /)

Note on parseTarget

Parses "wsName:/path" target strings as used by the AI action format. Pass forRead=true to also parse a ":startLine:endLine" suffix for range reads.

Permission concepts

Permission entries have shape: { mode, owner }

"rw"Everyone can read and write (default — no entry needed)
"ro"Everyone can only read
"644"Owner: read+write, others: read-only
"755"Owner: read+write+execute, others: read+execute
"1755"Sticky bit + 755: only owner can delete files in this dir
{ "keyA": "ro", "*": "rw" }ACL: keyA read-only, everyone else read+write

callerId is an opaque string (typically an API key). Pass null to bypass all checks — trusted server-side callers always pass.

Permissions inherit down the tree. The most specific ancestor wins.

FunctionSignatureReturns
jjfsChmod(fsPerms, email, wsName, filePath, mode, callerId){ success, status, result }
jjfsChown(fsPerms, email, wsName, filePath, owner, validOwners, callerId){ success, status, result }
getEffectivePermission(fsPerms, email, wsName, filePath){ mode, owner, effectivePath, inherited } or null
getPermBitsForKey(perm, callerId){ read, write, execute }
checkReadAccess(fsPerms, email, wsName, filePath, callerId){ allowed } or { allowed, error }
checkWriteAccess(fsPerms, email, wsName, filePath, callerId){ allowed } or { allowed, error }
checkOwnerAccess(fsPerms, email, wsName, filePath, callerId){ allowed } or { allowed, error }
checkStickyBit(fsPerms, email, wsName, filePath, callerId){ allowed } or { allowed, error }
setPermission(fsPerms, email, wsName, filePath, updates)void
removePermissionsUnder(fsPerms, email, wsName, filePath)void
isValidMode(mode)boolean
parseOctalBits(digit){ read, write, execute }
getStickyBit(mode)boolean

Timestamp concepts

POSIX-style timestamps: birthtime (first creation), mtime (content change), ctime (any metadata change). All timestamps are ISO-8601 strings.

FunctionSignatureReturns
touchTimestamps(fsTimestamps, email, wsName, filePath, fields)void
getTimestamps(fsTimestamps, email, wsName, filePath){ birthtime, mtime, ctime } or null
removeTimestampsUnder(fsTimestamps, email, wsName, filePath)void

When to use each field

fields is an array of 'birthtime', 'mtime', 'ctime'.

  • Create — set all three: birthtime, mtime, ctime
  • Write / Edit — set mtime + ctime
  • chmod / chown / xattr — set ctime only

Symlink concepts

Symlinks are stored as metadata alongside the tree — the JJFS tree itself is never modified. A symlink maps one path to a target within the same workspace. Chains are followed up to 8 hops (Linux MAXSYMLINKS).

FunctionSignatureReturns
jjfsSetSymlink(fsSymlinks, email, wsName, filePath, target){ success, result }
resolveSymlink(fsSymlinks, email, wsName, filePath, depth?){ path } or { error }
getSymlinksInDir(fsSymlinks, email, wsName, dirPath){ name: "/target", ... }
removeSymlinksUnder(fsSymlinks, email, wsName, filePath)void
Note: Pass null or '' as target to jjfsSetSymlink to remove a symlink.

Extended attribute concepts

Extended attributes are arbitrary key-value string pairs. Names must match user.* or trusted.* (alphanumeric, ., _, -).

Function / ConstantSignatureReturns
XATTR_NAME_RERegExp (/^(user|trusted)\.[a-zA-Z0-9._-]+$/)
jjfsSetXattr(fsXattrs, email, wsName, filePath, op){ success, status, result }
getXattrs(fsXattrs, email, wsName, filePath){ "user.key": "value", ... }
removeXattrsUnder(fsXattrs, email, wsName, filePath)void
hashPermForResponse(perm, hashFn)Safe permission object or null

hashPermForResponse

Replaces raw API key values with opaque tokens via hashFn before sending permission data to clients. Use SHA-256 in Node.js; SubtleCrypto in browser; pass k => k to skip. Returns null when perm is null.

Callers identify themselves by computing hashFn(theirKey) and matching against hashed values in the response.

Cascade Pattern

Any delete or move must clean up all four metadata stores.

JSafter jjfsDelete or source side of jjfsMove
removePermissionsUnder(fsPerms,      email, wsName, path);
removeTimestampsUnder(fsTimestamps,  email, wsName, path);
removeSymlinksUnder(fsSymlinks,      email, wsName, path);
removeXattrsUnder(fsXattrs,          email, wsName, path);

Then set timestamps on the destination:

JSdestination timestamps
// After jjfsMove — destination gets mtime + ctime (not a new birthtime)
touchTimestamps(fsTimestamps, email, wsName, destPath, ['mtime', 'ctime']);

// After jjfsCopy — destination is brand new, gets all three
touchTimestamps(fsTimestamps, email, wsName, destPath, ['birthtime', 'mtime', 'ctime']);

HTTP API Reference

POST /api/fs/execute

typetargetcontent
JJFS_READ ws:/path or ws:/path:start:end not used
JJFS_WRITE ws:/path file content string
JJFS_EDIT ws:/path {"search":"...","replace":"..."}
JJFS_DELETE ws:/path not used
JJFS_MOVE ws:/srcpath /destpath string
JJFS_COPY ws:/srcpath /destpath string
JJFS_CHMOD ws:/path mode string or ACL object
JJFS_CHOWN ws:/path caller ID, array of IDs, or null
JJFS_SYMLINK ws:/path target path string
JJFS_GETXATTR ws:/path not used
JJFS_SETXATTR ws:/path {"set":{...},"remove":[...]}

Workspace Management

MethodEndpointDescription
GET /api/fs/workspaces Returns { workspaces: [{ name, fileCount }], count }
POST /api/fs/workspaces Body: { "name": "myworkspace" }{ success: true, name }
DELETE /api/fs/workspaces/:name Cannot delete "default" → { success: true }
GET /api/fs/browse?workspace=name&path=/dir[&all=1] Returns { success, workspace, path, type, entries|content, timestamps, permission, xattrs }

REST File Endpoints

MethodEndpointNotes
GET /api/fs/:workspace/*path Read file or list directory. Query: ?start=N&end=M, ?nofollow=1, ?all=1
PUT /api/fs/:workspace/*path Write file (raw body)
DELETE /api/fs/:workspace/*path Delete file or directory
POST /api/fs/:workspace/*path Move or copy. Body: { "op": "move"|"copy", "destination": "/path" }
PATCH /api/fs/:workspace/*path One of:
{ "search":"...","replace":"..." }
{ "chmod": mode }
{ "chown": owner }
{ "symlink": "/target"|null }
{ "xattr": { "set":{...}, "remove":[...] } }

Response headers on GET (file)

X-JJFS-PermissionJSON effective permission (owner IDs are SHA-256 hashed)
X-JJFS-ModeThe raw mode string
X-JJFS-OwnerComma-separated SHA-256 hashes of owner IDs
X-JJFS-TimestampsJSON { birthtime, mtime, ctime }

Frontend Action Parser

Parse JJFS action tags from the AI response stream and call /api/fs/execute for each one.

JSaction parser + execute helper
const ACTION_TYPES = 'JJFS_WRITE|JJFS_EDIT|JJFS_DELETE|JJFS_MOVE|JJFS_COPY';
const ACTION_RE = new RegExp(
  `\\[ACTION:(${ACTION_TYPES}):[\\s\\S]+?\\[/ACTION\\]`, 'g'
);
const READ_RE = /\[ACTION:JJFS_READ\]([\s\S]+?)\[\/ACTION:JJFS_READ\]/g;

function parseActions(text) {
  const actions = [];
  let m;
  while ((m = READ_RE.exec(text)) !== null)
    actions.push({ type: 'JJFS_READ', target: m[1].trim(), content: null });
  while ((m = ACTION_RE.exec(text)) !== null) {
    const [, type, rest] = m;
    if (type === 'JJFS_EDIT') {
      const jsonStart = rest.lastIndexOf(':{');
      if (jsonStart === -1) continue;
      actions.push({ type, target: rest.slice(0, jsonStart), content: rest.slice(jsonStart + 1) });
    } else if (type === 'JJFS_MOVE' || type === 'JJFS_COPY') {
      const lastColon = rest.lastIndexOf(':');
      actions.push({ type,
        target:  lastColon > -1 ? rest.slice(0, lastColon) : rest,
        content: lastColon > -1 ? rest.slice(lastColon + 1) : null });
    } else {
      const firstColon = rest.indexOf(':');
      actions.push({ type,
        target:  firstColon > -1 ? rest.slice(0, firstColon) : rest,
        content: firstColon > -1 ? rest.slice(firstColon + 1) : null });
    }
  }
  return actions;
}

async function executeAction(action, apiKey, baseUrl = '') {
  const resp = await fetch(`${baseUrl}/api/fs/execute`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
    body: JSON.stringify(action),
  });
  return resp.json();
}

Design Decisions

JJFS is designed for AI workloads, not high-throughput production traffic. Zero setup, fully inspectable state, trivial backup. For high-write scenarios, swap the persistence layer (SQLite, Redis) while keeping the same jjfs.js functions.
jjfs.js runs in the browser (offline-first apps), embeds in any JS environment without adaptation, and decouples persistence strategy from data logic.
Works with any model that follows instructions — including local models via Ollama and models without structured tool-call support. Actions are visible in the response stream immediately, improving streaming UX.
Writing an entire file to fix two lines is a blunt instrument. JJFS_EDIT makes intent explicit, limits blast radius (only the matched section is touched), and fails safely (nothing changed if search text is ambiguous).
No globals, no hidden dependencies. Testing is trivial: pass a plain object, check the result. The same pattern applies to all four metadata stores.
Raw API keys must never appear in responses. All external permission responses use hashPermForResponse(perm, hashFn). Callers identify themselves by computing hashFn(theirKey) and matching against hashed values. hashFn is provided by the caller to keep jjfs.js environment-agnostic.

Integration Checklist

Self-hosted

  • Copy jjfs.js into your project
  • Initialize all five stores from disk on startup: wsForKey, fsPerms, fsTimestamps, fsSymlinks, fsXattrs
  • Wire POST /api/fs/execute using the dispatch pattern above
  • Apply the cascade pattern after delete and move
  • Use touchTimestamps after every mutation with the appropriate fields
  • Use hashPermForResponse before returning any permission data to clients
  • Add workspace management endpoints as needed
  • Decide on persistence: JSON files for simple use, SQLite/Redis for multi-tenant

data2l.ink

  • Sign up at data2l.ink and generate an API key
  • Done — no server to run or maintain

Frequently Asked Questions

Yes — use jjfsWriteBinary and jjfsReadBinary. They base64-encode your data transparently, storing it as a plain string in the workspace. Both work in Node.js (returns a Buffer) and in the browser (returns a Uint8Array) without any configuration.
Yes — use the permission system. Set a mode on the workspace root (e.g. "ro") and grant write access to specific API keys via an ACL object. The sticky bit ("1755") on shared directories prevents users from deleting each other's files.
Last write wins. There is no locking. For single-user or low-concurrency applications this is fine. For multi-user production use, replace JSON file persistence with SQLite (WAL mode) or a key-value store with compare-and-swap.
Not by default — workspace creation is a management operation. If you want the AI to create workspaces, expose a WORKSPACE_CREATE action in your execute handler.
No. Minified files are typically a single long line, making a unique search string nearly impossible. Always keep non-minified source in JJFS workspaces.
Yes. Import it via <script type="module">, create your store objects in memory, and call the functions directly. Wire persistence to localStorage, IndexedDB, or a remote API as needed.
No. It is a synchronous, request-driven system. To notify your application when the AI writes a file, wrap the mutating functions with an event emitter or callback on your server layer.
All access-check functions (checkReadAccess, checkWriteAccess, checkOwnerAccess, checkStickyBit) treat null as a trusted caller that bypasses all permission checks. Use this for server-side or session-authenticated operations where no API key is involved.
Call getEffectivePermission(fsPerms, email, wsName, filePath). The result includes effectivePath (where the permission was set) and inherited: true if the permission came from an ancestor path.