mikser-io-drive 11.5.0 → 11.6.2

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 CHANGED
@@ -238,6 +238,23 @@ drive({ host: 'drive.example.com', anonymousDiscovery: true, … })
238
238
 
239
239
  That relaxes discovery only. `PROPFIND /` still requires credentials.
240
240
 
241
+ ### The Basic challenge follows your auth configuration
242
+
243
+ The drive writes its own `WWW-Authenticate: Basic` header rather than
244
+ delegating to the verifier — a composite verifier challenges with whichever
245
+ member can be satisfied interactively, and where OAuth discovery is configured
246
+ that is Bearer, which Explorer and Finder do not speak.
247
+
248
+ The `charset="UTF-8"` parameter is not a decision this package makes. It reads
249
+ `basicCharset` from the auth plugin, so a deployment sets it once:
250
+
251
+ ```js
252
+ auth({ basicCharset: false }) // on by default
253
+ ```
254
+
255
+ It briefly defaulted off on a theory about the Windows redirector that did not
256
+ survive measurement. See the auth package's README.
257
+
241
258
  ### Compliance classes at the root
242
259
 
243
260
  The root advertises `DAV: 1, 2, 3` — the same classes as the endpoints
@@ -257,6 +274,45 @@ a LOCK nothing sends, since clients lock the files they edit and those live
257
274
  under the endpoints, which support locking for real. `Allow` stays truthful, so
258
275
  a client that asks what the root itself permits gets the right answer.
259
276
 
277
+ ### When Windows cannot keep the mount
278
+
279
+ Everything above gets Explorer to mount the drive. Keeping it mounted across a
280
+ restart is a separate problem, and on a machine that has to run unattended it
281
+ is not solvable from this side.
282
+
283
+ The redirector reads saved credentials from the **Domain Password** store.
284
+ Basic credentials cannot be persisted there — [KB 2673544][kb] documents this
285
+ as by design, because WinHTTP cannot retrieve a saved Basic credential — so
286
+ "remember my credentials" quietly stores nothing the reconnect can use. It
287
+ looks like a server fault and is not one. Measured against a live endpoint,
288
+ a reconnect sent four requests, **none** of them carrying an `Authorization`
289
+ header, and gave up; the credential was sitting in Credential Manager the whole
290
+ time, in the `Generic` store that `cmdkey /generic:` writes and the redirector
291
+ never reads. Apache `mod_dav` on the same host, with the same credential,
292
+ failed identically — which is the useful part of the measurement.
293
+
294
+ So for a machine that exports documents on its own, do not mount the drive.
295
+ Have the application write to a local folder and push that folder up:
296
+
297
+ ```
298
+ wscript rclone-outbox.vbs "C:\SkinCheck" SkinCheck:Janus 120
299
+ ```
300
+
301
+ [`clients/windows/rclone-outbox.vbs`](clients/windows/rclone-outbox.vbs) is
302
+ that loop — an [rclone][rclone] `move` on an interval, run by `wscript.exe` so
303
+ no console window appears, and started by a shortcut in `shell:startup` so it
304
+ needs no elevation and no Scheduled Task. rclone authenticates from its own
305
+ config on every request and has no reconnect to lose, which is the whole reason
306
+ it works where the mount does not.
307
+
308
+ This trades a drive letter for a folder, and it is worth being clear about what
309
+ that costs: nobody browses the server from the machine any more. In exchange an
310
+ export can no longer fail because a mount dropped, and a server that is
311
+ unreachable delays the upload instead of losing the document.
312
+
313
+ [kb]: https://support.microsoft.com/help/2673544
314
+ [rclone]: https://rclone.org/webdav/
315
+
260
316
  ## What the folder is called
261
317
 
262
318
  The endpoint key is the name, and the mount reports it as `displayname`:
@@ -0,0 +1,79 @@
1
+ ' rclone-outbox.vbs - hidden, looping uploader for a mikser-io-drive endpoint.
2
+ '
3
+ ' Windows cannot mount this drive unattended. The WebDAV redirector reads its
4
+ ' credentials from the Domain Password store and refuses to persist Basic
5
+ ' credentials there at all (KB 2673544), so a mapped drive re-prompts after
6
+ ' every restart and an export silently lands on a dead letter. Measured against
7
+ ' a live endpoint: four redirector requests during a reconnect, none of them
8
+ ' carrying an Authorization header, while rclone authenticated on its first.
9
+ '
10
+ ' So the export never touches the network. The exporting application writes to
11
+ ' a local folder and this moves the folder's contents up, which means an export
12
+ ' cannot fail because a mount dropped, and an unreachable server only delays
13
+ ' the upload instead of losing the file.
14
+ '
15
+ ' This is a .vbs and not a .cmd because wscript.exe runs with no console at
16
+ ' all. A .cmd on a two-minute timer flashes a window on the desktop every two
17
+ ' minutes, and on a shared machine someone eventually turns it off. The other
18
+ ' way to run a console tool quietly is a Scheduled Task, which needs elevation
19
+ ' to register in the root task folder - this needs none: put a shortcut to it
20
+ ' in shell:startup and it begins with the session.
21
+ '
22
+ ' Usage:
23
+ ' wscript rclone-outbox.vbs <outbox folder> <rclone remote> [interval seconds]
24
+ '
25
+ ' For example, against an endpoint declared as `SkinCheck: { folder: ... }`:
26
+ ' wscript rclone-outbox.vbs "C:\SkinCheck" SkinCheck:Janus 120
27
+
28
+ Option Explicit
29
+
30
+ Dim shell, arguments, outbox, remote, interval, rclone, logFile, command
31
+
32
+ Set shell = CreateObject("WScript.Shell")
33
+ Set arguments = WScript.Arguments
34
+
35
+ If arguments.Count < 2 Then
36
+ WScript.Echo "usage: wscript rclone-outbox.vbs <outbox folder> <remote> [interval seconds]"
37
+ WScript.Quit 1
38
+ End If
39
+
40
+ outbox = arguments(0)
41
+ remote = arguments(1)
42
+ If arguments.Count > 2 Then
43
+ interval = CLng(arguments(2))
44
+ Else
45
+ interval = 120
46
+ End If
47
+
48
+ rclone = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%\rclone\rclone.exe")
49
+ logFile = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%\rclone\outbox.log")
50
+
51
+ ' move, not copy: the server is the system of record, the same as the mapped
52
+ ' drive it replaces, and it keeps the exported documents off a shared desktop.
53
+ ' It also gives whoever works at the machine a correct indicator that needs no
54
+ ' explaining - an empty outbox means everything is up, and a failing upload
55
+ ' makes files visibly pile up instead of failing where nobody looks.
56
+ '
57
+ ' --min-age keeps a file that is still being written out of the transfer. The
58
+ ' exporting application is long finished with it by the time it ages in, and a
59
+ ' half-written document uploaded and then deleted locally is unrecoverable.
60
+ ' --ignore-times because the server reports no modification-time support
61
+ ' (rclone prints a Precision of 100 years for it), which leaves size as the
62
+ ' only thing rclone can compare. A re-export under the same name and the same
63
+ ' size - a corrected document for the same patient and date - would otherwise
64
+ ' be treated as already uploaded, skipped, and deleted locally. Measured: a
65
+ ' 3.773 MiB export was removed with "There was nothing to transfer".
66
+ '
67
+ ' It costs nothing here. move empties the outbox, so a document is offered
68
+ ' once and uploaded once; this only removes a skip decision that cannot be
69
+ ' made safely.
70
+ command = """" & rclone & """ move """ & outbox & """ " & remote & _
71
+ " --min-age 1m --ignore-times --transfers 2" & _
72
+ " --log-level INFO --log-file """ & logFile & """"
73
+
74
+ Do
75
+ ' 0 hides the window; True waits for the run to finish, so a slow upload
76
+ ' delays the next pass instead of overlapping with it.
77
+ shell.Run command, 0, True
78
+ WScript.Sleep interval * 1000
79
+ Loop
package/index.js CHANGED
@@ -68,6 +68,22 @@ export const writeCapability = (name) => `drive:${name}:write`
68
68
  * handler (server.js), so an endpoint at `/content` would silently shadow a
69
69
  * real page at /content/ in the built site.
70
70
  */
71
+ // Does this deployment's auth want charset="UTF-8" on the Basic challenge?
72
+ //
73
+ // Asked of the auth plugin rather than configured here, so the decision is
74
+ // made once per deployment. mikser-io-auth exposes `basicCharset` and
75
+ // defaults it ON; anything else — a bare static token, no auth at all — has
76
+ // no opinion, and falls back to the same default rather than to silence.
77
+ //
78
+ // Not delegated to the verifier's own `challenge()`, which would be the
79
+ // tidier shape: a composite verifier challenges with whichever member can be
80
+ // satisfied interactively, and where OAuth discovery is configured that is
81
+ // Bearer. Explorer and Finder speak Basic and nothing else, so the scheme
82
+ // here is not negotiable — only the parameter is.
83
+ function basicCharsetOf(auth) {
84
+ return auth?.basicCharset ?? true
85
+ }
86
+
71
87
  export function drive(options = {}) {
72
88
  const {
73
89
  base = '/drive',
@@ -87,6 +103,10 @@ export function drive(options = {}) {
87
103
  host,
88
104
  auth,
89
105
  realm = 'mikser',
106
+ // `charset="UTF-8"` on the Basic challenge, RFC 7617 §2.1, is not a
107
+ // knob of this package's own: it follows whatever the auth plugin was
108
+ // configured with, so a deployment decides it once. See
109
+ // basicChallenge in mikser-io for why the default is on.
90
110
  // Nephele defaults both to 'meta-files', which writes sidecars INTO
91
111
  // the folder being served. The sidecars are filtered out of the
92
112
  // catalog now (see registerJunk above), so the remaining reason to
@@ -225,6 +245,7 @@ export function drive(options = {}) {
225
245
  // ReadOnlyPlugin refuses every mutation regardless.
226
246
  writeCapability: readOnly ? null : writeCapability(name),
227
247
  realm,
248
+ charset: basicCharsetOf(auth),
228
249
  logger,
229
250
  })
230
251
 
@@ -409,6 +430,7 @@ export function drive(options = {}) {
409
430
  allowRemote: true,
410
431
  trustLoopback: !auth,
411
432
  realm,
433
+ charset: basicCharsetOf(auth),
412
434
  },
413
435
  logger,
414
436
  anonymousDiscovery,
@@ -1,5 +1,5 @@
1
1
  import { UnauthorizedError, ForbiddenError } from 'nephele'
2
- import { authorize } from 'mikser-io'
2
+ import { authorize, basicChallenge } from 'mikser-io'
3
3
  // The one implementation of "does this principal hold it", including the
4
4
  // `*` wildcard — checked here rather than re-derived, because two gates in
5
5
  // this file and one in mikser-io-auth each had their own `.includes` and a
@@ -39,11 +39,15 @@ export class MikserAuthenticator {
39
39
  #capability
40
40
  #writeCapability
41
41
  #realm
42
+ // Whether the Basic challenge carries charset="UTF-8". Follows the
43
+ // deployment's auth configuration rather than being decided here — see
44
+ // basicChallenge in mikser-io.
45
+ #charset
42
46
  #logger
43
47
 
44
48
  constructor({
45
49
  verifier, trustLoopback = false, allowRemote = false,
46
- capability, writeCapability, realm = 'mikser', logger,
50
+ capability, writeCapability, realm = 'mikser', charset = false, logger,
47
51
  } = {}) {
48
52
  this.#verifier = verifier
49
53
  this.#trustLoopback = trustLoopback
@@ -51,6 +55,7 @@ export class MikserAuthenticator {
51
55
  this.#capability = capability
52
56
  this.#writeCapability = writeCapability
53
57
  this.#realm = realm
58
+ this.#charset = charset
54
59
  this.#logger = logger
55
60
  }
56
61
 
@@ -72,7 +77,8 @@ export class MikserAuthenticator {
72
77
  // challenge has to be set here — and it must offer Basic, because
73
78
  // that is the only scheme Finder and Explorer speak.
74
79
  if (outcome.status === 401) {
75
- response.set('WWW-Authenticate', `Basic realm="${this.#realm}", charset="UTF-8"`)
80
+ response.set('WWW-Authenticate',
81
+ basicChallenge({ realm: this.#realm, charset: this.#charset }))
76
82
  throw new UnauthorizedError(outcome.error)
77
83
  }
78
84
  // 403: the credential was never the problem — the caller's origin
@@ -16,7 +16,7 @@
16
16
  // server with its own authenticator — so per-endpoint auth stays exactly where
17
17
  // it was rather than hanging off a shared root.
18
18
 
19
- import { authorize, hasCapability } from 'mikser-io'
19
+ import { authorize, hasCapability, basicChallenge } from 'mikser-io'
20
20
 
21
21
  const escape = (text) => String(text)
22
22
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
@@ -128,7 +128,7 @@ export function rootListing({ entries, rootName, auth, logger, anonymousDiscover
128
128
  } catch (err) {
129
129
  logger?.error?.('drive: verifier threw at the root — %s', err.message)
130
130
  return res.status(401)
131
- .set('WWW-Authenticate', `Basic realm="${auth.realm}", charset="UTF-8"`)
131
+ .set('WWW-Authenticate', basicChallenge({ realm: auth.realm, charset: auth.charset }))
132
132
  .end()
133
133
  }
134
134
 
@@ -137,7 +137,7 @@ export function rootListing({ entries, rootName, auth, logger, anonymousDiscover
137
137
  // and without the challenge a DAV client never prompts.
138
138
  if (outcome.status === 401) {
139
139
  return res.status(401)
140
- .set('WWW-Authenticate', `Basic realm="${auth.realm}", charset="UTF-8"`)
140
+ .set('WWW-Authenticate', basicChallenge({ realm: auth.realm, charset: auth.charset }))
141
141
  .end()
142
142
  }
143
143
  // Authenticated and not permitted. NEVER 401 here: a client that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io-drive",
3
- "version": "11.5.0",
3
+ "version": "11.6.2",
4
4
  "description": "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.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -8,7 +8,7 @@
8
8
  "better-sqlite3": true
9
9
  },
10
10
  "scripts": {
11
- "test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
11
+ "test": "node --no-warnings --test --test-timeout=120000 --test-reporter=spec 'test/**/*.test.js'"
12
12
  },
13
13
  "repository": {
14
14
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "homepage": "https://github.com/almero-digital-marketing/mikser-io-drive#readme",
30
30
  "peerDependencies": {
31
- "mikser-io": "^11.7.0",
31
+ "mikser-io": "^11.11.1",
32
32
  "zod": "^4.0.0"
33
33
  },
34
34
  "dependencies": {
@@ -41,7 +41,7 @@
41
41
  "bcryptjs": "^3.0.0",
42
42
  "express": "^5.2.1",
43
43
  "globby": "^15.0.0",
44
- "mikser-io": "^11.7.0",
44
+ "mikser-io": "^11.11.1",
45
45
  "mikser-io-auth": "^11.0.1",
46
46
  "mikser-io-mcp": "^11.0.1",
47
47
  "webdav": "^5.10.0",
@@ -124,11 +124,17 @@ before(async (t) => {
124
124
  + ' ],\n'
125
125
  + '})\n')
126
126
 
127
+ // `detached` so this gets its own process GROUP, which is what the
128
+ // teardown kills. mikser under --watch --server runs render workers, and
129
+ // they inherit the pipes below: signalling the parent alone can leave a
130
+ // worker holding the write end open, which keeps THIS process's read
131
+ // handles alive and the test run never ends. Killing the group takes the
132
+ // whole tree.
127
133
  server = spawn(process.execPath, [
128
134
  path.join(dir, 'node_modules/mikser-io/app.js'),
129
135
  '--working-folder', dir, '--output-folder', 'out', '--runtime-folder', 'runtime',
130
136
  '--watch', '--server', String(PORT), '--url', `http://127.0.0.1:${PORT}`,
131
- ], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] })
137
+ ], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
132
138
 
133
139
  // Kept so a server that never comes up says WHY. Throwing this away left
134
140
  // the failure as a bare ECONNREFUSED, which names the symptom and hides
@@ -155,6 +161,15 @@ before(async (t) => {
155
161
  await new Promise(r => setTimeout(r, 3000))
156
162
  })
157
163
 
164
+ // Signal the whole process GROUP, not just the child.
165
+ //
166
+ // `detached: true` above put the server in its own group, so a negative pid
167
+ // reaches it and everything it started. Falls back to the child alone if the
168
+ // group is already gone, which is the ordinary case and not an error.
169
+ function killTree(child, signal) {
170
+ try { process.kill(-child.pid, signal) } catch { try { child.kill(signal) } catch { /* already gone */ } }
171
+ }
172
+
158
173
  after(async () => {
159
174
  // WAIT for it to die, and escalate. `kill()` only sends the signal; the
160
175
  // child's stdout/stderr are pipes we hold, so until it actually exits
@@ -167,16 +182,30 @@ after(async () => {
167
182
  // close() does not come back.
168
183
  if (server && server.exitCode === null && server.signalCode === null) {
169
184
  const exited = new Promise(resolve => server.once('exit', resolve))
170
- server.kill()
185
+ killTree(server, 'SIGTERM')
171
186
  const inTime = await Promise.race([
172
187
  exited.then(() => true),
173
188
  new Promise(resolve => setTimeout(() => resolve(false), 5000)),
174
189
  ])
175
190
  if (!inTime) {
176
- server.kill('SIGKILL')
177
- await exited
191
+ killTree(server, 'SIGKILL')
192
+ // Bounded. `await exited` alone assumes the child is the only
193
+ // thing that can hold this up, and the whole failure being fixed
194
+ // here is that it is not.
195
+ await Promise.race([
196
+ exited,
197
+ new Promise(resolve => setTimeout(resolve, 5000)),
198
+ ])
178
199
  }
179
200
  }
201
+ // The pipes, explicitly, and this is the actual fix rather than belt and
202
+ // braces. A worker that inherited the write end keeps this side readable
203
+ // even once the server is gone, and a readable stream handle is enough to
204
+ // keep node alive: every test passes, nothing is left to run, and the
205
+ // process simply sits there. On CI that was fifteen minutes of silence
206
+ // ended by the job timeout and "Terminate orphan process", twice.
207
+ server?.stdout?.destroy()
208
+ server?.stderr?.destroy()
180
209
  if (dir) await rm(dir, { recursive: true, force: true })
181
210
  })
182
211
 
@@ -101,8 +101,11 @@ describe('auth', () => {
101
101
  it('challenges an anonymous request with Basic, so a DAV client prompts', async () => {
102
102
  const res = await dav('PROPFIND', '/drive/content/', { headers: { depth: '0' } })
103
103
  assert.equal(res.status, 401)
104
- assert.match(res.headers.get('www-authenticate'), /^Basic realm="mikser"/)
105
- assert.match(res.headers.get('www-authenticate'), /charset="UTF-8"/)
104
+ // Exact bytes: the drive follows the deployment's auth configuration
105
+ // rather than hardcoding the parameter, and this fixture leaves it at
106
+ // the default. See basicChallenge in mikser-io.
107
+ assert.equal(res.headers.get('www-authenticate'),
108
+ 'Basic realm="mikser", charset="UTF-8"')
106
109
  })
107
110
 
108
111
  it('refuses a wrong password', async () => {
@@ -48,6 +48,27 @@ const insufficient = {
48
48
  // Presented a credential, rejected, no refinement — the ordinary 401.
49
49
  const invalid = { verify: async () => false }
50
50
 
51
+ // A verifier that throws — a store that cannot be read, a hash library that
52
+ // blew up. The root answers 401 rather than 500, because a client that can
53
+ // retry with credentials is more use than a stack trace, and this branch has
54
+ // its own `res.set` call that a mutation can change independently of the
55
+ // ordinary one.
56
+ const throwing = { verify: async () => { throw new Error('store unreadable') } }
57
+
58
+ describe('when the verifier itself fails', () => {
59
+ for (const method of ['OPTIONS', 'PROPFIND']) {
60
+ it(`challenges with the configured bytes on ${method}`, async () => {
61
+ const res = fakeResponse()
62
+ await middleware(throwing)(request(method), res, () => {
63
+ assert.fail('the root must answer, not fall through')
64
+ })
65
+ assert.equal(res.statusCode, 401)
66
+ assert.equal(res.headers['www-authenticate'], 'Basic realm="mikser", charset="UTF-8"',
67
+ 'the error path must emit what the happy path emits, not its own header')
68
+ })
69
+ }
70
+ })
71
+
51
72
  describe('an authenticated caller who is not permitted', () => {
52
73
  for (const method of ['OPTIONS', 'PROPFIND']) {
53
74
  it(`gets 403 and no challenge on ${method}`, async () => {
@@ -209,6 +209,21 @@ describe('the drive root on its own host', () => {
209
209
  assert.equal(res.status, 401)
210
210
  })
211
211
 
212
+ it('challenges with the exact bytes this deployment configured', async () => {
213
+ // Exact, because the parameter that is NOT here is the finding:
214
+ // `charset="UTF-8"` is advisory (RFC 7617 §2.1) and the Windows
215
+ // WebDAV redirector appears not to parse it — a mapped drive carrying
216
+ // it never reconnects from stored credentials. The root follows the
217
+ // auth plugin's choice rather than carrying a knob of its own, and
218
+ // this fixture leaves it at the default.
219
+ for (const method of ['OPTIONS', 'PROPFIND']) {
220
+ const res = await request(method, '/', { headers: { depth: '1' } })
221
+ assert.equal(res.status, 401, `${method} must challenge`)
222
+ assert.equal(res.headers['www-authenticate'], 'Basic realm="mikser", charset="UTF-8"',
223
+ `${method} must carry what this deployment's auth configured`)
224
+ }
225
+ })
226
+
212
227
  it('accepts a bearer-carrying client at the root', async () => {
213
228
  // rclone and curl carry a token rather than Basic. Challenging
214
229
  // OPTIONS must not turn them away — the verifier already accepts