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.
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.
| Workaround | The catch |
|---|---|
| Spin up a dedicated backend | Extra infrastructure, cost, and maintenance |
| Use a third-party database service | Vendor lock-in, schema constraints, pricing tiers |
| Stuff everything in localStorage | No structure, no permissions, no cross-client access |
| Push data to a git repo on every change | Rate limits, latency, and a polluted commit history |
{ "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.
All file operations take a wsForKey object as their first argument.
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.
Permissions, timestamps, symlinks, and extended attributes are stored in separate flat-keyed objects:
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.
No imports. No Node.js. Copy jjfs.js into your project and import it. That's it.
Runs in any modern browser as an ES module, or in any Node.js / Deno / Bun project.
Full chmod/chown system with ACL objects, octal modes, sticky bit, and inheritance.
POSIX-style metadata tracked in parallel stores alongside the file tree.
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!'
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); });
# 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"}'
Sign up at data2l.ink, generate an API key, start using the API.
Copy this into your model's system prompt to give it JJFS capabilities.
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.
37 exports across 6 subsystems
| Function | Signature | Returns |
|---|---|---|
| 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 } |
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.
| Function | Signature | Returns |
|---|---|---|
| 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 |
POSIX-style timestamps: birthtime (first creation), mtime (content change), ctime (any metadata change). All timestamps are ISO-8601 strings.
| Function | Signature | Returns |
|---|---|---|
| touchTimestamps | (fsTimestamps, email, wsName, filePath, fields) | void |
| getTimestamps | (fsTimestamps, email, wsName, filePath) | { birthtime, mtime, ctime } or null |
| removeTimestampsUnder | (fsTimestamps, email, wsName, filePath) | void |
fields is an array of 'birthtime', 'mtime', 'ctime'.
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).
| Function | Signature | Returns |
|---|---|---|
| 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 |
Extended attributes are arbitrary key-value string pairs. Names must match user.* or trusted.* (alphanumeric, ., _, -).
| Function / Constant | Signature | Returns |
|---|---|---|
| XATTR_NAME_RE | — | RegExp (/^(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 |
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.
Any delete or move must clean up all four metadata stores.
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:
// 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']);
| type | target | content |
|---|---|---|
| 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":[...]} |
| Method | Endpoint | Description |
|---|---|---|
| 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 } |
| Method | Endpoint | Notes |
|---|---|---|
| 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":[...] } } |
| X-JJFS-Permission | JSON effective permission (owner IDs are SHA-256 hashed) |
| X-JJFS-Mode | The raw mode string |
| X-JJFS-Owner | Comma-separated SHA-256 hashes of owner IDs |
| X-JJFS-Timestamps | JSON { birthtime, mtime, ctime } |
Parse JJFS action tags from the AI response stream and call /api/fs/execute for each one.
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(); }