mikser-io-drive 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,306 @@
1
+ # mikser-io-drive
2
+
3
+ > WebDAV over the working folder, authenticated through
4
+ > [`mikser-io-auth`](https://github.com/almero-digital-marketing/mikser-io-auth).
5
+ > Edit content from Finder, Explorer or any DAV client; the build picks the
6
+ > change up.
7
+
8
+ ## What it is
9
+
10
+ A [Nephele](https://github.com/sciactive/nephele) WebDAV server per endpoint,
11
+ mounted on mikser's Express app, serving directories from the working folder.
12
+ Because those directories are mikser *sources*, a `PUT` is a content change —
13
+ the watcher sees it and the site rebuilds. That is the whole point, and also
14
+ where the sharp edges are (see **Working-folder hazards**).
15
+
16
+ **Design choices, and why:**
17
+
18
+ - **One server per endpoint, at `<base>/<name>`.** The same shape as
19
+ `api`/`mcp`/`forms`. Nephele can multi-mount several adapters under one
20
+ server with a virtual root, and the URLs come out identical — but that
21
+ needs `@nephele/adapter-virtual`, keeps a fake directory tree in sync with
22
+ the mount keys, and puts per-endpoint auth behind a shared root. What is
23
+ given up is browsing the endpoint list over DAV, which `registerRoute`
24
+ already answers better.
25
+
26
+ - **`base` is not optional.** Plugin routes match before mikser's static
27
+ handler, so an endpoint at `/content` would silently shadow a real
28
+ `/content/` page in the built site.
29
+
30
+ - **Capabilities are derived from the endpoint name**, not configured.
31
+ `webdav:<name>` to mount and read it, `webdav:<name>:write` to write. A
32
+ group holding the first and not the second gets a read-only mount with no
33
+ flag involved.
34
+
35
+ - **Sidecar meta-files are off by default**, and safe if you turn them on.
36
+ Nephele defaults `properties` and `locks` to `'meta-files'`, which writes
37
+ sidecars *into the folder being served*. The shape is not what it looks
38
+ like: a collection's is `.nephelemeta` (dot-prefixed, already invisible to
39
+ mikser), but a file's is `page.md.nephelemeta` — **not** dot-prefixed, and
40
+ measurably imported as its own entity. The plugin declares
41
+ `*.nephelemeta` to the engine via `registerJunk`, so either mode is safe.
42
+ `'emulate'` stays the default for a plainer reason: a content folder people
43
+ browse and commit should not fill up with sidecars.
44
+
45
+ - **Writes are staged and renamed.** The adapter opens the destination with
46
+ `'w'` and streams into it, which was measured to expose a growing partial
47
+ file *and* destroy the previous contents on an interrupted upload. Writes go
48
+ to a sibling `.part` file and `rename(2)` on success. `atomicWrites: false`
49
+ restores the adapter's behaviour.
50
+
51
+ - **Basic auth only, so HTTPS.** WebDAV clients speak Basic or Digest, and
52
+ Digest needs the plaintext password — impossible against bcrypt hashes. The
53
+ plugin warns when the configured URL is plain `http` and not loopback.
54
+
55
+ ## Use
56
+
57
+ ```js
58
+ import { webdav } from 'mikser-io-drive'
59
+ import { auth } from 'mikser-io-auth'
60
+
61
+ const identity = auth({
62
+ capabilities: {
63
+ editors: ['webdav:content', 'webdav:content:write'],
64
+ reviewers: ['webdav:content'], // read-only, by grant
65
+ },
66
+ })
67
+
68
+ export default async () => ({
69
+ plugins: [
70
+ identity,
71
+ webdav({
72
+ endpoints: {
73
+ content: { folder: 'documents' },
74
+ media: { folder: 'files/media' },
75
+ data: { folder: 'data', readOnly: true },
76
+ },
77
+ auth: identity,
78
+ }),
79
+ ],
80
+ })
81
+ ```
82
+
83
+ Mount `https://cms.example.com/webdav/content` in your file manager.
84
+
85
+ With no `capabilities` map at all, an authenticated user is unscoped and every
86
+ endpoint is fully writable — the ADR-0012 default, the same as a static token.
87
+ Capabilities only start refusing things once you have said what they mean.
88
+
89
+ ### Endpoint options
90
+
91
+ | | |
92
+ | --- | --- |
93
+ | `folder` | required; relative to the working folder, or absolute |
94
+ | `readOnly` | hard cap — nobody writes here, whatever they hold |
95
+ | `auth` / `token` | per-endpoint override of the plugin-level `auth` |
96
+ | `allowRemote` | reachable without a credential (see the engine's rule) |
97
+ | `properties` / `locks` | `'emulate'` (default), `'disallow'`, `'meta-files'` |
98
+
99
+ `readOnly: true` and "you lack `webdav:<name>:write`" are different
100
+ statements. The first is about the folder — a directory a build step owns, say
101
+ — and the second is about the person.
102
+
103
+ ## Working-folder hazards
104
+
105
+ These are properties of exposing live sources over a network filesystem, not
106
+ bugs, but they will bite if nobody said them out loud.
107
+
108
+ - **Expose sources, never the output folder.** DAV locks are advisory and
109
+ mikser does not honour them, so a locked file the renderer rewrites makes
110
+ the lock a lie.
111
+ - **File-manager litter is filtered by the engine**, from mikser-io 9.6.0.
112
+ This was measured, and the earlier claim here was wrong in an instructive
113
+ way: the macOS files (`.DS_Store`, `._*`) were already invisible, because
114
+ globby defaults to `dot: false` and the watcher ignores leading dots. The
115
+ **Windows** ones are not dotfiles — `Thumbs.db` and `desktop.ini` were both
116
+ scanned *and* watched, and became entities. Core now filters a conservative
117
+ OS/file-manager list on both paths; `junk: false` in config turns it off.
118
+ - **Upload size is bounded by the request timeout.** Node caps a request at 5
119
+ minutes, which for uploads is a size limit expressed in seconds. Raise it
120
+ with `server.requestTimeout` in `mikser.config.js` (mikser-io 9.5.0+).
121
+
122
+ ## Client compliance, measured
123
+
124
+ Tested against [`webdav`](https://github.com/perry-mitchell/webdav-client), a
125
+ third-party client that builds its own PROPFIND bodies and parses its own
126
+ multistatus responses — so it disagrees where the implementation is wrong
127
+ rather than where the tests are.
128
+
129
+ Three protocol facts worth knowing, all asserted in `test/protocol.test.js`:
130
+
131
+ | | |
132
+ | --- | --- |
133
+ | default (`emulate`) | `DAV: 1, 3, 2` — **class 2**, which macOS requires for a read-write mount |
134
+ | `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 |
135
+ | `emulate` vs `meta-files` | both return a valid `Lock-Token` header; `emulate` returns an empty `<lockdiscovery/>` where `meta-files` returns the full `<activelock>` |
136
+
137
+ The last is the real cost of the default: clients read the header, so this is
138
+ survivable, but a client that parses the body for the token finds nothing.
139
+ Choose `meta-files` if you need real dead properties or real locking — with
140
+ persisted locks a second `LOCK` on a held resource correctly answers `423`.
141
+
142
+ One more, because it surprises people: **`LOCK` on a path that does not exist
143
+ creates an empty file** (RFC 4918 §9.10.4). A client that locks before writing
144
+ — Finder's Save As does — leaves an empty document behind even if the write
145
+ never arrives.
146
+
147
+ ## litmus compliance
148
+
149
+ Scored with [litmus](https://github.com/tolsen/litmus) 0.13, the WebDAV
150
+ compliance suite from the neon project. Two endpoints, because the shipped
151
+ default deliberately does not store what it is asked to store:
152
+
153
+ | suite | `emulate` (default) | `meta-files` |
154
+ | --- | --- | --- |
155
+ | basic | **16/16** | **16/16** |
156
+ | copymove | 11/13 | 11/13 |
157
+ | props | 20/30 | **27/30** |
158
+ | locks | 9/13 | **37/41** |
159
+ | http | **4/4** | **4/4** |
160
+
161
+ The `emulate` column is the trade working as intended: it reports success for
162
+ dead properties and locks without storing them, so litmus reads them back and
163
+ finds nothing. Choose `meta-files` if compliance matters more than a clean
164
+ content folder.
165
+
166
+ The `meta-files` column is the fair measure of the dependency, and it has four
167
+ real gaps — all upstream in nephele, all pinned in `test/protocol.test.js` so
168
+ an upgrade that fixes them fails loudly rather than changing behaviour quietly:
169
+
170
+ - **`COPY`/`MOVE` with `Overwrite: F` returns `207`, not `412`** (RFC 4918
171
+ §9.8.5). The safe half holds — the destination is *not* clobbered — but the
172
+ client is told the operation succeeded. Measured with a real client:
173
+ `copyFile(src, dst, { overwrite: false })` **resolves**, and the destination
174
+ is unchanged. Scripts that copy-if-absent and then read the destination
175
+ expecting the source's content will be wrong.
176
+ - **A malformed PROPFIND body answers `500`, not `400`.**
177
+ - **`propget` loses a dead property in a foreign namespace.**
178
+ - **`UNLOCK` accepts a bogus lock token**, so one client can release another's
179
+ lock. DAV locks are advisory here anyway — mikser's renderer does not honour
180
+ them — but it means locking is not a concurrency control you can lean on.
181
+
182
+ Two warnings litmus raises that are worth knowing rather than fixing: `DELETE`
183
+ with a fragment in the Request-URI removes the collection, and `COPY` into a
184
+ non-existent collection answers `404` where `409` is specified.
185
+
186
+ ## Why writes are staged
187
+
188
+ Measured, not assumed. `@nephele/adapter-file-system` writes like this:
189
+
190
+ ```js
191
+ const handle = await fsp.open(this.absolutePath, 'w') // truncates NOW
192
+ input.pipe(handle.createWriteStream())
193
+ ```
194
+
195
+ Against a 512KB upload delivered in eight slow chunks:
196
+
197
+ | | adapter as-is | staged |
198
+ | --- | --- | --- |
199
+ | sizes seen at the destination mid-upload | `65536, 131072, … 524288` | never exists |
200
+ | a 1600-byte file whose overwrite is interrupted | **196608 bytes of the new content** | 1600 bytes, unchanged |
201
+
202
+ The first matters because these folders are mikser sources — the watcher can
203
+ import a half-written file and render a truncated page. The second is data
204
+ loss: not the old file, not an error, a corrupted file and no indication.
205
+
206
+ Staging to a sibling `.part` file and renaming fixes both, because `rename(2)`
207
+ within a directory is atomic — the file appears complete or not at all, and a
208
+ failed upload never opens the original. A sibling rather than the OS temp
209
+ directory, because rename is only atomic within one filesystem and `/tmp` is
210
+ usually a different mount.
211
+
212
+ Both rows above are asserted in `test/atomic-writes.test.js`.
213
+
214
+ ## Why Nephele, and not the alternative
215
+
216
+ The Node WebDAV server field is two live projects. Both were scored with the
217
+ same litmus suites, each on a fresh server:
218
+
219
+ | suite | Nephele `1.0.0-alpha.67` | webdav-server `2.6.3` |
220
+ | --- | --- | --- |
221
+ | basic | **16/16** | 15/16 |
222
+ | copymove | 11/13 | **12/13** |
223
+ | props | **27/30** | 7/30 |
224
+ | locks | **37/41** | 11/24 *(aborted)* |
225
+ | http | 4/4 | 4/4 |
226
+
227
+ `webdav-server` has more stars (282 vs 108), is Express-mountable, and has a
228
+ path-based privilege manager that would map neatly onto per-folder access. It
229
+ also woke up on 2026-08-04 after six and a half years of silence — dropping
230
+ v1, adding unit tests — so it is reviving rather than dead. And it gets the
231
+ `Overwrite: F` case right, which is Nephele's most client-visible gap.
232
+
233
+ But 7/30 on properties and a `locks` run that aborts partway is a different
234
+ class of problem from Nephele's four known gaps, and it carries 65 open issues
235
+ against Nephele's zero. Nephele is also Apache-2.0 and load-bearing for its
236
+ author's own product, which is the maintenance signal that matters most.
237
+
238
+ Staying on Nephele. If its gaps ever become the binding constraint, the
239
+ serious alternative is not another npm package — it is **Apache `mod_dav`**,
240
+ the implementation litmus was written to test, which reads `htpasswd` and
241
+ `htgroup` natively with `AuthUserFile` / `AuthGroupFile` / `Require group`.
242
+ That would cost the Express integration, the capability model, per-request
243
+ read-only, atomic staged writes and the route inventory — a separate process
244
+ serving the same folder, with its own idea of who may do what. Worth it only
245
+ if strict compliance outranks all of that.
246
+
247
+ ## Nephele is pre-1.0
248
+
249
+ `nephele@1.0.0-alpha.67`. It implements RFC 4918 fully, but the version is
250
+ what it says, so the dependency is pinned exactly. This is precisely the
251
+ cadence argument in mikser's ADR-0006 for shipping as a plugin rather than in
252
+ the engine.
253
+
254
+ One thing worth knowing if you extend this: Nephele's conditional-plugins hook
255
+ **cannot see the authenticated user.** `createServer` mounts `loadPlugins`
256
+ before `authenticate`, so `response.locals.user` is always `undefined` there —
257
+ including in the README example that tests it. Per-user decisions have to
258
+ happen in the authenticator, which is where the write gate lives.
259
+
260
+ ## File operations for an agent
261
+
262
+ Four tools carry bytes over the MCP connection itself, for a caller with no
263
+ route to the host — a sandbox with no egress, a desktop client with no shell.
264
+
265
+ ```
266
+ mikser_drive_add({ endpoint, files: [{ name, base64, mime }], folder?, overwrite?, dryRun? })
267
+ mikser_drive_read({ path })
268
+ mikser_drive_move({ from, to, rewriteRefs?, dryRun? })
269
+ mikser_drive_delete({ path, force?, dryRun? })
270
+ ```
271
+
272
+ Nothing here is media-specific. An endpoint is whatever the deployment
273
+ configured, and what a stored file becomes is whatever that deployment's
274
+ pipeline makes of it — the response reports what was actually stamped rather
275
+ than assuming.
276
+
277
+ **`add`** takes a whole batch in one call so a single build cycle picks it up:
278
+ one `cycleId`, not one rebuild per file. Each file comes back with the
279
+ `reference` to paste into a document — the served URL where the pipeline
280
+ stamped one, the catalog id where it did not — and the derived variants any
281
+ preset produced. It decodes and checks the whole batch before writing
282
+ anything, so a bad file means nothing landed rather than half of it.
283
+
284
+ **`read`** returns an image as an image you can look at, text as text, and
285
+ anything else described. It reads the SOURCE; `mikser_read_output` reads what
286
+ was built.
287
+
288
+ **`move`** and **`delete`** refuse while anything still references the file,
289
+ listing every (entity, field) that would break. `move` with `rewriteRefs: true`
290
+ repoints them — reference strings here are plain rooted paths, so it rewrites
291
+ the literal string and names every file it changed. `delete` moves to a trash
292
+ folder under the runtime directory rather than unlinking, so a wrong delete is
293
+ a move back.
294
+
295
+ Both see values in entity meta, including inside arrays — not body text, and
296
+ not links a layout builds at render time, which they say so an empty list is
297
+ not read as "nothing at all".
298
+
299
+ Bytes cost roughly 1.4 tokens each: ten typical images is cheap, a video is
300
+ not. Per file 2MB, per batch 8MB; above that they refuse and point at a WebDAV
301
+ mount, where the same folders are reachable with ordinary credentials and the
302
+ bytes cost nothing.
303
+
304
+ Page text stays on `mikser_update_entity`. These never write documents — a raw
305
+ write there would lose the checksum guard, the blast-radius preview, the build
306
+ report and the spec-locked advisory.
package/index.js ADDED
@@ -0,0 +1,219 @@
1
+ import path from 'node:path'
2
+
3
+ import { registerRoute, resolveAuth, reachabilityOf, registerJunk } from 'mikser-io'
4
+ import { MikserAuthenticator } from './lib/authenticator.js'
5
+ import { registerFileTools } from './lib/files.js'
6
+ import { withStagedWrites, stageWrites } from './lib/staged-writes.js'
7
+
8
+ export { MikserAuthenticator, withStagedWrites, stageWrites }
9
+
10
+ // Nephele's own sidecar files, declared to the engine so neither the scan nor
11
+ // the watcher imports them (mikser-io 9.7.0+).
12
+ //
13
+ // Measured, because the shape is not what it looks like. A collection's meta
14
+ // file is `<dir>/.nephelemeta` — dot-prefixed, so it was already invisible
15
+ // for the same accidental reason .DS_Store was. A file's is
16
+ // `<dir>/page.md.nephelemeta`, which is NOT dot-prefixed and was both scanned
17
+ // and watched: setting one dead property on one document produced a second
18
+ // entity for the sidecar.
19
+ //
20
+ // Registered unconditionally rather than only when `meta-files` is selected,
21
+ // so an operator who switches later is covered by the switch itself.
22
+ registerJunk({ ignore: ['**/*.nephelemeta'], match: /\.nephelemeta$/ })
23
+
24
+ // Capability names are derived from the endpoint name, not configured:
25
+ //
26
+ // drive:<name> may mount and read it
27
+ // drive:<name>:write may also write to it
28
+ //
29
+ // so an endpoint declares nothing and the grant reads for itself:
30
+ //
31
+ // capabilities: { editors: ['drive:content', 'drive:content:write'] }
32
+ //
33
+ // A group that holds the read capability and not the write one gets a
34
+ // read-only mount, which is the common case and needs no flag.
35
+ export const readCapability = (name) => `drive:${name}`
36
+ export const writeCapability = (name) => `drive:${name}:write`
37
+
38
+ /**
39
+ * WebDAV endpoints over working-folder directories (ADR-0012 for auth).
40
+ *
41
+ * drive({
42
+ * endpoints: {
43
+ * content: { folder: 'documents' },
44
+ * media: { folder: 'files/media' },
45
+ * data: { folder: 'data', readOnly: true },
46
+ * },
47
+ * auth: identity,
48
+ * })
49
+ *
50
+ * One Nephele server per endpoint, mounted at `<base>/<name>` — the same
51
+ * shape as api/mcp/forms. Deliberately not Nephele's multi-mount with a
52
+ * virtual root: identical URLs, but no virtual-adapter dependency, no
53
+ * name-sync between a fake directory tree and the mount keys, and per-
54
+ * endpoint auth stays plain instead of hanging off a shared root. What is
55
+ * given up is browsing the endpoint list over DAV, which registerRoute
56
+ * already answers better.
57
+ *
58
+ * `base` is NOT optional to drop: plugin routes match before the static
59
+ * handler (server.js), so an endpoint at `/content` would silently shadow a
60
+ * real page at /content/ in the built site.
61
+ */
62
+ export function drive(options = {}) {
63
+ const {
64
+ base = '/drive',
65
+ endpoints = {},
66
+ auth,
67
+ realm = 'mikser',
68
+ // Nephele defaults both to 'meta-files', which writes sidecars INTO
69
+ // the folder being served. The sidecars are filtered out of the
70
+ // catalog now (see registerJunk above), so the remaining reason to
71
+ // default to 'emulate' is a plain one: a content folder that people
72
+ // browse and commit should not fill up with page.md.nephelemeta
73
+ // files.
74
+ //
75
+ // The cost is measurable and small: 'emulate' returns a valid
76
+ // Lock-Token header but an EMPTY <lockdiscovery/> body, where
77
+ // 'meta-files' returns the full <activelock>. Clients read the
78
+ // header; one that parses the body for the token would not find it.
79
+ // Choose 'meta-files' if you need real dead properties or real
80
+ // locks, and accept the sidecars.
81
+ //
82
+ // Do NOT choose 'disallow' if macOS clients matter: it drops DAV
83
+ // compliance class 2 from the OPTIONS response, and Finder refuses a
84
+ // read-write mount without it.
85
+ properties = 'emulate',
86
+ locks = 'emulate',
87
+ } = options
88
+
89
+ return ({ runtime, onLoaded, useLogger }) => {
90
+ const names = Object.keys(endpoints)
91
+ if (!names.length) return // nothing configured → nothing mounted
92
+
93
+ onLoaded(async () => {
94
+ const logger = useLogger()
95
+ const app = runtime.options.app
96
+ if (!app) {
97
+ throw new Error(
98
+ 'drive plugin requires runtime.options.app — run mikser with --server, ' +
99
+ 'or pass { app: yourExpressInstance } to setup() before loading the plugin'
100
+ )
101
+ }
102
+
103
+ const { default: nepheleServer } = await import('nephele')
104
+ const { default: FileSystemAdapter } = await import('@nephele/adapter-file-system')
105
+ const { default: ReadOnlyPlugin } = await import('@nephele/plugin-read-only')
106
+
107
+ const workingFolder = runtime.options.workingFolder
108
+ const resolve = (folder) =>
109
+ path.isAbsolute(folder) ? folder : path.join(workingFolder, folder)
110
+
111
+ // Basic auth sends the password in a header, base64 and nothing
112
+ // more. Warn rather than refuse — a deployment behind a
113
+ // TLS-terminating proxy looks like plain http from here, and
114
+ // refusing to boot over a guess is the wrong trade.
115
+ if (auth && runtime.options.url?.startsWith('http://') &&
116
+ !/^http:\/\/(localhost|127\.|\[::1\])/.test(runtime.options.url)) {
117
+ logger.warn(
118
+ 'drive: %s is plain http and WebDAV clients authenticate with Basic — ' +
119
+ 'credentials travel base64-encoded, not encrypted. Serve over https, or ' +
120
+ 'terminate TLS in front of mikser.', runtime.options.url)
121
+ }
122
+
123
+ for (const [name, ep] of Object.entries(endpoints)) {
124
+ if (!ep.folder) {
125
+ throw new Error(`drive: endpoint ${JSON.stringify(name)} declares no folder`)
126
+ }
127
+ const root = resolve(ep.folder)
128
+ const mountPath = `${base}/${name}`
129
+
130
+ // Same seam and the same one difference as api/mcp/forms: a
131
+ // plain token keeps the trusted-local-host model, a real
132
+ // verifier does not.
133
+ const verifier = resolveAuth(ep.auth ?? auth ?? ep.token)
134
+ const trustLoopback = !(ep.auth ?? auth) && !!ep.token
135
+
136
+ const readOnly = ep.readOnly === true
137
+
138
+ const authenticator = new MikserAuthenticator({
139
+ verifier,
140
+ trustLoopback,
141
+ allowRemote: ep.allowRemote,
142
+ capability: readCapability(name),
143
+ // A readOnly endpoint needs no per-user write check —
144
+ // ReadOnlyPlugin refuses every mutation regardless.
145
+ writeCapability: readOnly ? null : writeCapability(name),
146
+ realm,
147
+ logger,
148
+ })
149
+
150
+ const fsAdapter = new FileSystemAdapter({
151
+ root,
152
+ properties: ep.properties ?? properties,
153
+ locks: ep.locks ?? locks,
154
+ })
155
+
156
+ app.use(mountPath, nepheleServer({
157
+ // Writes are staged to a sibling temp file and renamed.
158
+ // The adapter writes straight to the destination with
159
+ // open(path,'w'), which truncates immediately — so the
160
+ // watcher can import a half-written file, and an
161
+ // interrupted overwrite leaves the ORIGINAL destroyed.
162
+ // Both measured; see lib/staged-writes.js.
163
+ adapter: (ep.atomicWrites === false)
164
+ ? fsAdapter
165
+ : withStagedWrites(fsAdapter, {
166
+ onFailure: (err, file) => logger.warn(
167
+ 'drive: upload of %s failed, original left intact — %s',
168
+ path.basename(file), err.message),
169
+ }),
170
+ authenticator,
171
+ // `readOnly: true` is a hard cap — "nobody writes here",
172
+ // which is a different statement from "you may not write
173
+ // here". The per-user version of that question is answered
174
+ // in the authenticator, because Nephele resolves plugins
175
+ // BEFORE it authenticates and the hook cannot see the user.
176
+ plugins: readOnly ? [new ReadOnlyPlugin()] : [],
177
+ }))
178
+
179
+ registerRoute({
180
+ path: mountPath,
181
+ plugin: 'drive',
182
+ reachability: reachabilityOf({ auth: verifier, allowRemote: ep.allowRemote }),
183
+ // WebDAV GET/PUT stream file bodies, so a facade must not
184
+ // buffer this route.
185
+ streaming: true,
186
+ label: 'WebDAV',
187
+ detail: `(${ep.folder}${readOnly ? ', read-only' : ''})`,
188
+ authLabel: verifier ? (verifier.name ?? 'auth')
189
+ : (ep.allowRemote ? 'public, REMOTE OPEN' : 'loopback-only'),
190
+ })
191
+ }
192
+
193
+ logger.info('WebDAV mounted at %s (%s)', base, names.join(', '))
194
+
195
+ // File operations over MCP, for an agent with no route to the
196
+ // host — a sandbox with no egress, a desktop client with no shell.
197
+ // Bytes ride the MCP connection that already works.
198
+ registerFileTools({
199
+ runtime, endpoints, logger,
200
+ capabilityOf: readCapability,
201
+ writeCapabilityOf: writeCapability,
202
+ })
203
+
204
+ // Uploads are bounded by the server request timeout. The engine
205
+ // raises it automatically because this registers streaming routes
206
+ // (see registerRoute above) — Node's 5-minute default is an upload
207
+ // size limit expressed in seconds, and a large file over a slow
208
+ // link is indistinguishable from a stalled request.
209
+ const configured = runtime.config?.server?.requestTimeout
210
+ logger.debug('drive: uploads bounded by the server request timeout — %s',
211
+ configured == null
212
+ ? 'raised automatically for these streaming routes; override with config.server.requestTimeout'
213
+ : configured === 0 ? 'disabled by config.server.requestTimeout'
214
+ : `${configured}ms from config.server.requestTimeout`)
215
+ })
216
+ }
217
+ }
218
+
219
+ export default drive
@@ -0,0 +1,116 @@
1
+ import { UnauthorizedError, ForbiddenError } from 'nephele'
2
+ import { authorize } from 'mikser-io'
3
+
4
+ // Bridges Nephele's Authenticator contract onto the engine's verifier seam
5
+ // (ADR-0012), so WebDAV authenticates against the same htpasswd identity as
6
+ // every other surface — one users file, one groups file, one set of
7
+ // capabilities.
8
+ //
9
+ // Nephele's interface is two methods:
10
+ //
11
+ // authenticate(request, response) → User (throw to refuse)
12
+ // cleanAuthentication(request, response)
13
+ //
14
+ // Deliberately NOT @nephele/authenticator-htpasswd, which would re-read the
15
+ // file itself: that loses the mtime reload, the timing-equalised unknown-user
16
+ // path, and — the real reason — any knowledge of groups, so capabilities
17
+ // could not gate anything.
18
+ //
19
+ // Deliberately NOT @nephele/authenticator-custom either. Its getUser/authBasic
20
+ // pair is Basic-only by construction, while a verifier already understands
21
+ // every credential the deployment accepts, Bearer included (rclone and curl
22
+ // can send one; Finder cannot). Going through the seam means WebDAV gains
23
+ // whatever the seam gains.
24
+ // The WebDAV methods that change something. COPY and MOVE mutate the
25
+ // destination (MOVE the source too), and LOCK exists in order to write.
26
+ export const WRITE_METHODS = new Set([
27
+ 'PUT', 'POST', 'DELETE', 'MKCOL', 'COPY', 'MOVE', 'PROPPATCH', 'LOCK', 'UNLOCK',
28
+ ])
29
+
30
+ export class MikserAuthenticator {
31
+ #verifier
32
+ #trustLoopback
33
+ #allowRemote
34
+ #capability
35
+ #writeCapability
36
+ #realm
37
+ #logger
38
+
39
+ constructor({
40
+ verifier, trustLoopback = false, allowRemote = false,
41
+ capability, writeCapability, realm = 'mikser', logger,
42
+ } = {}) {
43
+ this.#verifier = verifier
44
+ this.#trustLoopback = trustLoopback
45
+ this.#allowRemote = allowRemote
46
+ this.#capability = capability
47
+ this.#writeCapability = writeCapability
48
+ this.#realm = realm
49
+ this.#logger = logger
50
+ }
51
+
52
+ async authenticate(request, response) {
53
+ let outcome
54
+ try {
55
+ outcome = await authorize(request, this.#verifier, {
56
+ allowRemote: this.#allowRemote,
57
+ trustLoopback: this.#trustLoopback,
58
+ })
59
+ } catch (err) {
60
+ this.#logger?.error?.('drive: verifier threw — %s', err.message)
61
+ throw new UnauthorizedError('Authentication failed.')
62
+ }
63
+
64
+ if (!outcome.ok) {
65
+ // Nephele turns UnauthorizedError into a bare 401. A DAV client
66
+ // will not prompt for credentials without being told how, so the
67
+ // challenge has to be set here — and it must offer Basic, because
68
+ // that is the only scheme Finder and Explorer speak.
69
+ if (outcome.status === 401) {
70
+ response.set('WWW-Authenticate', `Basic realm="${this.#realm}", charset="UTF-8"`)
71
+ throw new UnauthorizedError(outcome.error)
72
+ }
73
+ // 403: the credential was never the problem — the caller's origin
74
+ // was. Challenging would invite a retry that cannot help.
75
+ throw new ForbiddenError(outcome.error)
76
+ }
77
+
78
+ const principal = outcome.principal
79
+
80
+ // Capability gate for this endpoint. `capabilities: null` means the
81
+ // credential is not capability-scoped (no map configured, or a bare
82
+ // static token), which passes — same rule as every other surface.
83
+ if (this.#capability && principal.capabilities != null &&
84
+ !principal.capabilities.includes(this.#capability)) {
85
+ this.#logger?.debug?.('drive: %j lacks %j', principal.subject, this.#capability)
86
+ throw new ForbiddenError(`Your credential does not carry '${this.#capability}'.`)
87
+ }
88
+
89
+ // Write gating happens HERE rather than through Nephele's conditional-
90
+ // plugins hook, because that hook cannot see the user: createServer
91
+ // mounts loadPlugins before authenticate, so response.locals.user is
92
+ // always undefined when the plugins function runs. (Nephele's own
93
+ // README example tests `response.locals.user == null` there, which
94
+ // therefore never means what it looks like it means.) The authenticator
95
+ // is the earliest place that holds both the principal and the method.
96
+ if (this.#writeCapability && WRITE_METHODS.has(request.method) &&
97
+ principal.capabilities != null &&
98
+ !principal.capabilities.includes(this.#writeCapability)) {
99
+ this.#logger?.debug?.('drive: %j may not %s — lacks %j',
100
+ principal.subject, request.method, this.#writeCapability)
101
+ throw new ForbiddenError(`Your credential does not carry '${this.#writeCapability}'.`)
102
+ }
103
+
104
+ // Nephele only requires `username`. The principal rides along for
105
+ // anything downstream that wants it.
106
+ return { username: principal.subject ?? 'anonymous', principal }
107
+ }
108
+
109
+ async cleanAuthentication() {
110
+ // Nothing to tear down: every request re-verifies, and there is no
111
+ // session to invalidate. A revoked htgroup line takes effect on the
112
+ // next request rather than needing anything torn down here.
113
+ }
114
+ }
115
+
116
+ export default MikserAuthenticator