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 +306 -0
- package/index.js +219 -0
- package/lib/authenticator.js +116 -0
- package/lib/files.js +746 -0
- package/lib/staged-writes.js +95 -0
- package/litmus-server.mjs +74 -0
- package/package.json +45 -0
- package/test/atomic-writes.test.js +140 -0
- package/test/derived.test.js +164 -0
- package/test/drive.test.js +233 -0
- package/test/files.test.js +269 -0
- package/test/protocol.test.js +237 -0
- package/test/real-client.test.js +207 -0
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
|