mikser-io-drive
v11.4.0
Published
WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity — so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.
Maintainers
Readme
mikser-io-drive
Your content folders, as a drive. Mount them and edit; the site rebuilds. Or let an agent put files there without giving it a route to your server.
What you get
Edit content the way you already work. Mount an endpoint in Finder or Explorer and the folder is just a folder — drag a photograph in, rename a directory, open a document in whatever app you like. Every change is a content change: the watcher sees it and the site rebuilds. No upload form, no admin UI to learn, no second copy of your content to keep in step.
Give an agent file access that costs almost nothing. Four MCP tools carry bytes over the connection an agent already has, so it works from a sandbox with no network route to your host and from a desktop client with no shell. Ten photographs land in one call and one rebuild, each coming back with the string to paste into a page.
Deletes and moves that know what they would break. Removing a file that
three documents still point at is refused, and it names all three — entity and
field, including the one buried at cta.cycle[6]. Moving one can repoint them
for you. The alternative is a green build and three broken images nobody sees
until a customer does.
Nothing disappears quietly. An existing file is refused rather than overwritten unless you say so. A delete moves to trash rather than unlinking, so a wrong one is a move back. A batch that has one bad file in it writes none of them, so you are never left guessing what landed.
Everyone sees only their own folders. Access is per endpoint and per
person: a reviewer reads content and cannot write it; an editor writes
content and cannot touch layouts. The same grants apply whether someone is
in Finder, a script, or an agent.
Set it up
import { drive } from 'mikser-io-drive'
import { auth } from 'mikser-io-auth'
const identity = auth({
capabilities: {
editors: ['drive:content', 'drive:content:write'],
reviewers: ['drive:content'], // read-only, by grant
},
})
export default async () => ({
plugins: [
identity,
drive({
endpoints: {
content: { folder: 'documents' },
media: { folder: 'files/media' },
data: { folder: 'data', readOnly: true },
},
auth: identity,
}),
],
})Then Finder → Go → Connect to Server → https://cms.example.com/drive/content,
or point rclone or any DAV client at the same URL. Capabilities are derived from
the endpoint name — drive:<name> to read it, drive:<name>:write to write —
so the endpoint list above is the only place a folder is named.
For an agent
mikser_drive_add({ endpoint, files: [{ name, base64, mime }], folder?, overwrite?, dryRun? })
mikser_drive_read({ path })
mikser_drive_move({ moves: [{ from, to }], rewriteRefs?, dryRun? })
mikser_drive_delete({ paths: [path], force?, dryRun? })Every writing tool takes a whole batch, because a build cycle is the unit of cost: three files sent together are one cycle and one rebuild, where three calls are three of each. A batch is all-or-nothing — if any entry cannot be applied, none are, and every reason is listed.
add returns for each file the reference to paste into a document plus any
derived variants a preset produced. read gives back an image an agent can
actually look at. move and delete refuse while something still references
the file.
Bytes cost roughly 1.4 tokens each, so a page of photographs is cheap and a video is not: per file 2MB, per batch 8MB, above which they refuse and point at a mount, where the same folders are reachable and the bytes cost nothing.
Page text stays on mikser_update_entity — it is checksum-guarded, previews
what an edit would invalidate, and returns the build report. These tools never
write documents.
How it works
A Nephele WebDAV server per endpoint,
mounted on mikser's Express app, serving directories from the working folder.
Because those directories are mikser sources, a PUT is a content change —
which is the whole point, and also where the sharp edges are.
One server per endpoint at <base>/<name>, the same shape as api/mcp/
forms. Nephele can multi-mount several adapters under one server with a
virtual root and the URLs come out identical, but a per-endpoint server keeps
each mount's auth, capabilities and read-only flag independent.
Working-folder hazards
These are properties of exposing live sources over a network filesystem, not bugs, but they will bite if nobody said them out loud.
- Expose sources, never the output folder. DAV locks are advisory and mikser does not honour them, so a locked file the renderer rewrites makes the lock a lie.
- File-manager litter is filtered by the engine, from mikser-io 9.6.0.
This was measured, and the earlier claim here was wrong in an instructive
way: the macOS files (
.DS_Store,._*) were already invisible, because globby defaults todot: falseand the watcher ignores leading dots. The Windows ones are not dotfiles —Thumbs.dbanddesktop.iniwere both scanned and watched, and became entities. Core now filters a conservative OS/file-manager list on both paths;junk: falsein config turns it off. - Upload size is bounded by the request timeout. Node caps a request at 5
minutes, which for uploads is a size limit expressed in seconds. Raise it
with
server.requestTimeoutinmikser.config.js(mikser-io 9.5.0+).
Client compliance, measured
Tested against webdav, a
third-party client that builds its own PROPFIND bodies and parses its own
multistatus responses — so it disagrees where the implementation is wrong
rather than where the tests are.
Three protocol facts worth knowing, all asserted in test/protocol.test.js:
| | |
| --- | --- |
| default (emulate) | DAV: 1, 3, 2 — class 2, which macOS requires for a read-write mount |
| locks: 'disallow' | drops class 2 and LOCK from Allow. Finder will refuse a read-write mount. A trap, because it is invisible until someone tries |
| emulate vs meta-files | both are real locking now (11.4.0): the <activelock> is in the LOCK body and in a later PROPFIND, and a held resource answers 423. They differ in where the lock lives — memory, or a sidecar |
Until 11.4.0 emulate stored no lock at all — a token in the header and an
empty <lockdiscovery/>, which this table used to describe as survivable
because "clients read the header". The Windows WebDAV redirector does not: it
needs the <activelock> RFC 4918 §9.10.1 requires, and without it a mapped
drive lists and reads perfectly and cannot be written to. Every save fails
with "The parameter is incorrect" and leaves a zero-byte file, because Windows
PUTs an empty file, LOCKs it, PROPFINDs it, finds no lock, and loops until it
gives up. It reads like a permissions problem and is not.
emulate now keeps locks in memory, so the folder stays free of sidecars and
the locks are real. They do not survive a restart — the same observable as
every lock timing out at once, which is why locks carry timeouts — and there is
no second process to share them with, since one mikser instance owns a working
folder.
Choose meta-files if you need dead properties to persist. That is the one
thing emulate still does not do, deliberately: a PROPPATCH answers 207 and
the property is gone by the next PROPFIND (measured). Properties are meant to
last, and an in-memory store would lose them on restart while a same-session
read made them look durable — not storing is the honest answer. In practice
what Windows PROPPATCHes is its Win32 timestamps, so on emulate a file keeps
the server's mtime rather than the client's. It does not affect the write: the
PUT has already succeeded by then.
One more, because it surprises people: LOCK on a path that does not exist
creates an empty file (RFC 4918 §9.10.4). A client that locks before writing
— Finder's Save As does — leaves an empty document behind even if the write
never arrives.
A domain of its own, and what Windows needs
Explorer cannot map a share that lives at <base>/<name> on a site whose / is
a static site. The Microsoft WebDAV redirector establishes its session against
the server root before it touches the path: it asks /, gets the site's
answer, and stops — the mount being correct makes no difference. Measured on a
live host, OPTIONS /drive/SkinCheck/ answered DAV: 1, 3, 2 while
dir \\host@SSL\DavWWWRoot\drive\SkinCheck\ still failed, and it failed at
the root.
Give the drive a domain and / is the drive:
drive({
host: 'drive.example.com',
auth: identity,
endpoints: {
SkinCheck: { folder: 'skincheck' },
Reports: { folder: 'reports' },
},
})\\drive.example.com@SSL\ now mounts, and / is a collection whose children
are the endpoints that caller may read — filtered by the same
drive:<name> capability that guards the endpoint itself, so two people
mapping the same drive see different folders in it. Entering one lands on that
endpoint's own server with its own authenticator; the root is a listing, not a
proxy.
OPTIONS / is answered without credentials, because that is what the
redirector asks before it has any to offer — it discloses that a DAV server is
here and nothing about what is in it. The listing is the gated part.
Scoped to the Host header, and that is not optional: mounted at / for every
host, a drive would shadow every page on the site. Requests for any other host
fall straight through.
The <base>/<name> surface is unchanged and still answers on every host. This
adds a way in; it does not move the old one.
What the folder is called
The endpoint key is the name, and the mount reports it as displayname:
drive({
endpoints: {
SkinCheck: { folder: 'skincheck' }, // shows as "SkinCheck"
reports: { folder: 'data', displayName: 'Q3 reports' },
},
})Without it the label is whatever the client derives from the URL, and that is
not controllable: Express matches routes case-insensitively, so
/drive/SkinCheck and /drive/skincheck reach the same mount and the casing
shown is whichever the client asked for first — an endpoint renamed to
SkinCheck kept reading skincheck in Explorer, and remapping did not change
it. @nephele/adapter-file-system does not implement displayname (it is
commented out of its live-property list with a TODO), so a PROPFIND for it
answered 404 The displayname property was not found and the client had nothing
to go on.
The default is the key, not summary: displayname is a name — RFC 4918
§15.2, "suitable for presentation to a user" — and the key is already the URL
segment and what the mount log prints. summary is a sentence and reads badly
as a folder label. displayName overrides the key where it is not presentable.
Only the mount root is named. Everything below it is a real file or directory whose name the client reads from the path.
When a client refuses and the server is right
WebDAV clients cache their discovery verdict — not the file listing, the answer to "is this a WebDAV share at all". Fix the server and the client can go on refusing, with nothing wrong at either end and nothing in any log to say so.
This endpoint answers Cache-Control: no-cache on OPTIONS so its own answer
is never the stale one. The client's memory of a PREVIOUS answer is not
something the server can reach, and flushing it is not guessable:
| | |
| --- | --- |
| Windows | Restart-Service WebClient -Force (elevated) |
| macOS | unmount and remount; Finder caches per session |
| Linux | gio mount -u <url>, or restart gvfs-dav |
Windows is the one that bites, because the Microsoft redirector decides from the
DAV: header whether a URL is a share at all, and reports a cached refusal as
0x80070043 — "The network name cannot be found". It fails before prompting
for credentials, so it reads as a broken URL rather than a cached verdict. An
afternoon was lost to exactly that: curl verified the server correct while
Explorer kept refusing.
The default adapter advertises max-age=604800 here. Seven days is a strange
thing to promise about capabilities that are configuration: locks: 'disallow'
drops class 2 and LOCK from Allow, and a client can keep acting on the old
answer for a week. So this plugin overrides it. One cheap request per client
session buys the ability to change a mount and be believed.
readOnly is not an example of that, though it reads like one: measured, a
read-only mount reports the same DAV: 1, 3, 2 and the same Allow list as a
writable one — PUT and DELETE included — and refuses the write when it arrives.
nephele's read-only plugin gates requests, not the discovery response.
OPTIONS /drive and OPTIONS / answer a plain CORS 204 with no DAV:
header, and that is correct. There is no resource at the base path: one
Nephele server per endpoint, no virtual root, so PROPFIND /drive/ is a 404 by
design and the site root is a static site, not a share. Only
/drive/<endpoint>/ is a share. Noted because those 204s look like the bug
fixed in 11.0.4 and are not.
litmus compliance
Scored with litmus 0.13, the WebDAV compliance suite from the neon project. Two endpoints, because the shipped default deliberately does not store what it is asked to store:
| suite | emulate (default) | meta-files |
| --- | --- | --- |
| basic | 16/16 | 16/16 |
| copymove | 11/13 | 11/13 |
| props | 20/30 | 27/30 |
| locks | 9/13 | 37/41 |
| http | 4/4 | 4/4 |
The emulate column is the trade working as intended: it reports success for
dead properties and locks without storing them, so litmus reads them back and
finds nothing. Choose meta-files if compliance matters more than a clean
content folder.
The meta-files column is the fair measure of the dependency, and it has four
real gaps — all upstream in nephele, all pinned in test/protocol.test.js so
an upgrade that fixes them fails loudly rather than changing behaviour quietly:
COPY/MOVEwithOverwrite: Freturns207, not412(RFC 4918 §9.8.5). The safe half holds — the destination is not clobbered — but the client is told the operation succeeded. Measured with a real client:copyFile(src, dst, { overwrite: false })resolves, and the destination is unchanged. Scripts that copy-if-absent and then read the destination expecting the source's content will be wrong.- A malformed PROPFIND body answers
500, not400. propgetloses a dead property in a foreign namespace.UNLOCKaccepts a bogus lock token, so one client can release another's lock. DAV locks are advisory here anyway — mikser's renderer does not honour them — but it means locking is not a concurrency control you can lean on.
Two warnings litmus raises that are worth knowing rather than fixing: DELETE
with a fragment in the Request-URI removes the collection, and COPY into a
non-existent collection answers 404 where 409 is specified.
Why writes are staged
Measured, not assumed. @nephele/adapter-file-system writes like this:
const handle = await fsp.open(this.absolutePath, 'w') // truncates NOW
input.pipe(handle.createWriteStream())Against a 512KB upload delivered in eight slow chunks:
| | adapter as-is | staged |
| --- | --- | --- |
| sizes seen at the destination mid-upload | 65536, 131072, … 524288 | never exists |
| a 1600-byte file whose overwrite is interrupted | 196608 bytes of the new content | 1600 bytes, unchanged |
The first matters because these folders are mikser sources — the watcher can import a half-written file and render a truncated page. The second is data loss: not the old file, not an error, a corrupted file and no indication.
Staging to a sibling .part file and renaming fixes both, because rename(2)
within a directory is atomic — the file appears complete or not at all, and a
failed upload never opens the original. A sibling rather than the OS temp
directory, because rename is only atomic within one filesystem and /tmp is
usually a different mount.
Both rows above are asserted in test/atomic-writes.test.js.
Why Nephele, and not the alternative
The Node WebDAV server field is two live projects. Both were scored with the same litmus suites, each on a fresh server:
| suite | Nephele 1.0.0-alpha.67 | webdav-server 2.6.3 |
| --- | --- | --- |
| basic | 16/16 | 15/16 |
| copymove | 11/13 | 12/13 |
| props | 27/30 | 7/30 |
| locks | 37/41 | 11/24 (aborted) |
| http | 4/4 | 4/4 |
webdav-server has more stars (282 vs 108), is Express-mountable, and has a
path-based privilege manager that would map neatly onto per-folder access. It
also woke up on 2026-08-04 after six and a half years of silence — dropping
v1, adding unit tests — so it is reviving rather than dead. And it gets the
Overwrite: F case right, which is Nephele's most client-visible gap.
But 7/30 on properties and a locks run that aborts partway is a different
class of problem from Nephele's four known gaps, and it carries 65 open issues
against Nephele's zero. Nephele is also Apache-2.0 and load-bearing for its
author's own product, which is the maintenance signal that matters most.
Writing it here was considered and is not close. RFC 4918 is about 4,650 lines in Nephele — 2,994 of method handlers and 1,662 of machinery — and the weight is not where it looks:
| | lines | |
| --- | --- | --- |
| Method.js | 930 | the base class every method inherits |
| — checkIfHeader | 234 | the If: header alone |
| — checkConditionalHeaders | 77 | |
| LOCK + UNLOCK | 442 | plus 81 lock references threaded through Method.js |
| MOVE + COPY | 438 | Depth and Overwrite semantics |
| PROPFIND | 276 | |
| GET/HEAD | 286 | ranges, etags |
Listing files is the cheap part. If: is a grammar rather than a value —
tagged and untagged lists, Not, lock tokens and etags mixed, evaluated
against resources other than the request target — and getting it wrong means
Finder's Save As either walks over somebody's lock or refuses forever. Locking
is not isolated in LOCK.js either: provisional locks, timeouts, per-user lock
permission and token extraction run on every write method.
And the litmus column above is what would be given up: 95 of 104 on
meta-files, with four known gaps named and pinned. A hand-written server
starts at zero and earns each point by a client breaking in someone's hands.
The read-only case is genuinely different — OPTIONS + PROPFIND + GET with
no locking and no conditionals is a few hundred lines, and the drive root in
lib/root-listing.js is already that shape. It is not the case anyone is
asking for.
The sharper version of the question is the ADAPTER, not the engine. Three
things have been worked around here, and all three are in
@nephele/adapter-file-system rather than in the protocol: displayname
commented out of its live-property list with a TODO, a flat max-age=604800 on
the OPTIONS response, and a read-only mount advertising PUT in Allow. The
adapter answers "what is at this path and what are its properties", which is
the thin half. If it keeps costing, replace it and keep the 4,650 lines that
matter.
Staying on Nephele. If its gaps ever become the binding constraint, the
serious alternative is not another npm package — it is Apache mod_dav,
the implementation litmus was written to test, which reads htpasswd and
htgroup natively with AuthUserFile / AuthGroupFile / Require group.
That would cost the Express integration, the capability model, per-request
read-only, atomic staged writes and the route inventory — a separate process
serving the same folder, with its own idea of who may do what. Worth it only
if strict compliance outranks all of that.
Nephele is pre-1.0
[email protected]. It implements RFC 4918 fully, but the version is
what it says, so the dependency is pinned exactly. This is precisely the
cadence argument in mikser's ADR-0006 for shipping as a plugin rather than in
the engine.
One thing worth knowing if you extend this: Nephele's conditional-plugins hook
cannot see the authenticated user. createServer mounts loadPlugins
before authenticate, so response.locals.user is always undefined there —
including in the README example that tests it. Per-user decisions have to
happen in the authenticator, which is where the write gate lives.
