mikser-io-drive 0.16.0 → 0.17.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/lib/files.js CHANGED
@@ -30,7 +30,7 @@ import { lookup as mimeLookup } from 'mime-types'
30
30
  // Drive writes with fs directly rather than through a collection handle, so
31
31
  // it records its own paths. Without this a media replacement — the change most
32
32
  // worth taking back — falls into the unattributed sweep.
33
- import { looksTextual, recordChangeSetWrite, explainRefusal, actingRole, useService } from 'mikser-io'
33
+ import { looksTextual, recordChangeSetWrite, explainRefusal, actingRole, useService, hasCapability, missingCapability } from 'mikser-io'
34
34
  import { readFile, writeFile, mkdir, rename, stat, unlink, open } from 'node:fs/promises'
35
35
  import {
36
36
  runtime, readEntity, findEntities, nextCycleId, whenCycleCompletes, checksum,
@@ -267,14 +267,25 @@ export function registerFileTools({ runtime, endpoints, capabilityOf, writeCapab
267
267
  if (!ep) return null
268
268
  return path.isAbsolute(ep.folder) ? ep.folder : path.join(workingFolder(), ep.folder)
269
269
  }
270
- const holds = (principal, capability) => {
271
- const held = principal?.capabilities
272
- // Not capability-scoped — a static token or a loopback caller. The
273
- // endpoint's own gate still applies at the transport; this cannot
274
- // narrow further without guessing.
275
- if (held == null) return true
276
- return held.includes(capability)
277
- }
270
+ // Both from the engine. Neither the "does this principal hold it" rule nor
271
+ // the "which of these is missing" rule belongs to this plugin: a private
272
+ // copy of the first is how the `*` wildcard came to work on the WebDAV
273
+ // mount and not on the tools over the same endpoint.
274
+ //
275
+ // Writing needs the pair the mount has always required:
276
+ //
277
+ // drive:<name> may mount and read it
278
+ // drive:<name>:write may ALSO write to it
279
+ //
280
+ // "also" means the base is a prerequisite, not an alternative. These tools
281
+ // checked only the second, so a grant carrying `:write` without the base
282
+ // was refused a PUT and allowed the identical write through here. That
283
+ // configuration is not hypothetical — it is what a deployment has when its
284
+ // editors hold `drive:documents:write` and nothing carries
285
+ // `drive:documents`.
286
+ const holds = (principal, capability) => hasCapability(principal, capability)
287
+ const missingForWrite = (principal, endpoint) =>
288
+ missingCapability(principal, [capabilityOf(endpoint), writeCapabilityOf(endpoint)])
278
289
 
279
290
  // ── add ──────────────────────────────────────────────────────────────
280
291
  mcp.registerTool(
@@ -325,8 +336,9 @@ export function registerFileTools({ runtime, endpoints, capabilityOf, writeCapab
325
336
  if (endpoints[endpoint].readOnly === true) {
326
337
  return fail(`${endpoint} is configured readOnly, so nothing can be stored there.`)
327
338
  }
328
- if (!holds(principal, writeCapabilityOf(endpoint))) {
329
- return fail(`Refused: you do not hold ${writeCapabilityOf(endpoint)}. Nothing was written.`)
339
+ const missingToAdd = missingForWrite(principal, endpoint)
340
+ if (missingToAdd) {
341
+ return fail(`Refused: you do not hold ${missingToAdd}. Nothing was written.`)
330
342
  }
331
343
  if (!Array.isArray(files) || !files.length) return fail('files must be a non-empty array')
332
344
 
@@ -634,9 +646,8 @@ export function registerFileTools({ runtime, endpoints, capabilityOf, writeCapab
634
646
  if (endpoints[owning].readOnly === true) {
635
647
  refuse(`${owning} is configured readOnly, so nothing can be removed from it.`); continue
636
648
  }
637
- if (!holds(principal, writeCapabilityOf(owning))) {
638
- refuse(`You do not hold ${writeCapabilityOf(owning)}.`); continue
639
- }
649
+ const missingToDelete = missingForWrite(principal, owning)
650
+ if (missingToDelete) { refuse(`You do not hold ${missingToDelete}.`); continue }
640
651
  try { await stat(found.file) } catch {
641
652
  refuse(`Nothing on disk at ${path.relative(workingFolder(), found.file)}.`); continue
642
653
  }
@@ -797,9 +808,8 @@ export function registerFileTools({ runtime, endpoints, capabilityOf, writeCapab
797
808
  if (endpoints[owning].readOnly === true) {
798
809
  refuse(`${owning} is configured readOnly, so nothing can be moved within it.`); continue
799
810
  }
800
- if (!holds(principal, writeCapabilityOf(owning))) {
801
- refuse(`You do not hold ${writeCapabilityOf(owning)}.`); continue
802
- }
811
+ const missingToMove = missingForWrite(principal, owning)
812
+ if (missingToMove) { refuse(`You do not hold ${missingToMove}.`); continue }
803
813
  const dest = safeSegment(to, 'to')
804
814
  if (dest.error) { refuse(dest.error); continue }
805
815
  try { await stat(found.file) } catch {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mikser-io-drive",
3
- "version": "0.16.0",
4
- "description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity \u2014 so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.",
3
+ "version": "0.17.0",
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",
7
7
  "allowScripts": {
@@ -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": "^10.0.0",
31
+ "mikser-io": "^10.14.0",
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": "^10.0.0",
44
+ "mikser-io": "^10.14.0",
45
45
  "mikser-io-auth": "^0.13.0",
46
46
  "mikser-io-mcp": "^2.0.0",
47
47
  "webdav": "^5.10.0",
@@ -500,3 +500,84 @@ describe('read capability', () => {
500
500
  assert.notEqual(r.isError, true)
501
501
  })
502
502
  })
503
+
504
+ // Who may write, decided once in the engine rather than per plugin.
505
+ //
506
+ // Two bugs lived here, and both were invisible because nothing exercised the
507
+ // tool surface against a credential the WebDAV mount would have judged
508
+ // differently.
509
+ //
510
+ // The first: `holds` was a private `capabilities.includes(...)`, so the `*`
511
+ // wildcard reached the mount — which asks the engine — and stopped at these
512
+ // tools. One endpoint, two surfaces, opposite answers.
513
+ //
514
+ // The second: writing checked only `drive:<name>:write`, while the mount has
515
+ // always required `drive:<name>` to reach the endpoint at all. "may ALSO
516
+ // write" makes the base a prerequisite, not an alternative — so a grant
517
+ // carrying `:write` alone was refused a PUT and allowed the identical write
518
+ // through the tool. That is not a hypothetical configuration; it is what a
519
+ // deployment has when its editors hold `drive:documents:write` and nothing
520
+ // carries `drive:documents`.
521
+ describe('writing is gated the way the mount gates it', () => {
522
+ const write = (substrate, name) => substrate.call('mikser_drive_add', {
523
+ endpoint: 'media', files: [{ name, base64: b64('x') }],
524
+ })
525
+ const refusal = (r) => r?.content?.find(c => c.type === 'text')?.text ?? JSON.stringify(r)
526
+
527
+ const toolsAs = async (capabilities) => {
528
+ const substrate = fakeSubstrate({ subject: 'u', roles: [], capabilities })
529
+ resetServices()
530
+ provideService('mcp', substrate)
531
+ registerFileTools({
532
+ runtime: { options: { ...runtime.options }, refs: runtime.refs },
533
+ endpoints: { media: { folder: 'media' } },
534
+ capabilityOf: readCapability, writeCapabilityOf: writeCapability,
535
+ })
536
+ return substrate
537
+ }
538
+
539
+ it('lets a wildcard holder write, as the mount already did', async () => {
540
+ const substrate = await toolsAs(['*'])
541
+ const r = await write(substrate, 'wildcard.txt')
542
+ assert.doesNotMatch(refusal(r), /do not hold/, refusal(r))
543
+ })
544
+
545
+ it('refuses `:write` held without the base, and names the base', async () => {
546
+ const substrate = await toolsAs(['drive:media:write'])
547
+ const r = await write(substrate, 'half.txt')
548
+ assert.match(refusal(r), /do not hold drive:media\b/,
549
+ 'the missing capability is the base, and the refusal must say which')
550
+ assert.doesNotMatch(refusal(r), /drive:media:write/,
551
+ 'naming the one they DO hold sends them to fix the wrong grant')
552
+ })
553
+
554
+ it('allows the pair, which is what the mount requires', async () => {
555
+ const substrate = await toolsAs(['drive:media', 'drive:media:write'])
556
+ const r = await write(substrate, 'both.txt')
557
+ assert.doesNotMatch(refusal(r), /do not hold/, refusal(r))
558
+ })
559
+
560
+ it('still refuses a reader', async () => {
561
+ const substrate = await toolsAs(['drive:media'])
562
+ assert.match(refusal(await write(substrate, 'reader.txt')), /do not hold drive:media:write/)
563
+ })
564
+
565
+ it('lets a wildcard holder READ, which is the other gate', async () => {
566
+ // The read tool asks `holds`; writing asks the engine's
567
+ // missingCapability. They are different call paths, and a test that
568
+ // only writes leaves the read gate uncovered — which is how a private
569
+ // `includes` survived there while the mount had already been fixed.
570
+ const seeded = await toolsAs(['drive:media', 'drive:media:write'])
571
+ await write(seeded, 'readable-by-wildcard.txt')
572
+ const substrate = await toolsAs(['*'])
573
+ const r = await substrate.call('mikser_drive_read', { path: 'media/readable-by-wildcard.txt' })
574
+ assert.doesNotMatch(refusal(r), /lacks|do not hold/, refusal(r))
575
+ })
576
+
577
+ it('leaves an unscoped credential alone', async () => {
578
+ // A bare static token declares nothing; the endpoint's own gate is
579
+ // what bounds it. That is a different rule and stays different.
580
+ const substrate = await toolsAs(null)
581
+ assert.doesNotMatch(refusal(await write(substrate, 'token.txt')), /do not hold/)
582
+ })
583
+ })