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 +27 -17
- package/package.json +4 -4
- package/test/files.test.js +81 -0
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
|
|
801
|
-
|
|
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.
|
|
4
|
-
"description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity
|
|
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.
|
|
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.
|
|
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",
|
package/test/files.test.js
CHANGED
|
@@ -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
|
+
})
|