mikser-io-drive 0.12.0 → 0.14.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/index.js +20 -1
- package/lib/files.js +20 -1
- package/package.json +2 -2
- package/test/files.test.js +58 -0
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
|
|
3
|
-
import { registerRoute, resolveAuth, reachabilityOf, registerJunk } from 'mikser-io'
|
|
3
|
+
import { registerRoute, resolveAuth, reachabilityOf, registerJunk, registerCapability } from 'mikser-io'
|
|
4
4
|
import { MikserAuthenticator } from './lib/authenticator.js'
|
|
5
5
|
import { registerFileTools } from './lib/files.js'
|
|
6
6
|
import { withStagedWrites, stageWrites } from './lib/staged-writes.js'
|
|
@@ -127,6 +127,25 @@ export function drive(options = {}) {
|
|
|
127
127
|
const root = resolve(ep.folder)
|
|
128
128
|
const mountPath = `${base}/${name}`
|
|
129
129
|
|
|
130
|
+
// Say what these capabilities protect, so a session can be
|
|
131
|
+
// told what it may reach in terms of the SITE rather than in
|
|
132
|
+
// terms of verbs. This is the only place that knows the folder
|
|
133
|
+
// behind an endpoint, and `summary` is the operator's own
|
|
134
|
+
// description of what lives there — both are what an agent
|
|
135
|
+
// needs to reason about where it is working.
|
|
136
|
+
registerCapability(readCapability(name), {
|
|
137
|
+
plugin: 'drive',
|
|
138
|
+
grants: 'read',
|
|
139
|
+
resource: { kind: 'collection', name, folder: ep.folder, summary: ep.summary },
|
|
140
|
+
})
|
|
141
|
+
if (!(ep.readOnly === true)) {
|
|
142
|
+
registerCapability(writeCapability(name), {
|
|
143
|
+
plugin: 'drive',
|
|
144
|
+
grants: 'write',
|
|
145
|
+
resource: { kind: 'collection', name, folder: ep.folder, summary: ep.summary },
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
130
149
|
// Same seam and the same one difference as api/mcp/forms: a
|
|
131
150
|
// plain token keeps the trusted-local-host model, a real
|
|
132
151
|
// verifier does not.
|
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 } from 'mikser-io'
|
|
33
|
+
import { looksTextual, recordChangeSetWrite, explainRefusal, actingRole } 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,
|
|
@@ -472,6 +472,25 @@ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeC
|
|
|
472
472
|
const found = await locate(pathish, endpoints, workingFolder())
|
|
473
473
|
if (found.error) return fail(found.error)
|
|
474
474
|
|
|
475
|
+
// Reads are capability-gated, the same as the WebDAV mount, which
|
|
476
|
+
// has always checked `drive:<name>` before serving a byte. This
|
|
477
|
+
// tool did not, so the two surfaces disagreed about the same
|
|
478
|
+
// endpoint — and a `readOnly` list derived from capabilities was
|
|
479
|
+
// describing a rule only half the transports enforced.
|
|
480
|
+
//
|
|
481
|
+
// A credential that is not capability-scoped is unaffected: holds()
|
|
482
|
+
// returns true for it, and the endpoint's own gate still applies.
|
|
483
|
+
const principal = mcp.principal?.() ?? null
|
|
484
|
+
if (found.endpoint && !holds(principal, capabilityOf(found.endpoint))) {
|
|
485
|
+
return fail(explainRefusal({
|
|
486
|
+
capability: capabilityOf(found.endpoint),
|
|
487
|
+
role: actingRole(principal?.roles ?? [], rt.options?.roles?.catalogue ?? {}),
|
|
488
|
+
target: pathish,
|
|
489
|
+
catalogue: rt.options?.roles?.catalogue ?? {},
|
|
490
|
+
summaries: rt.options?.roles?.summaries ?? {},
|
|
491
|
+
}))
|
|
492
|
+
}
|
|
493
|
+
|
|
475
494
|
let info
|
|
476
495
|
try {
|
|
477
496
|
info = await stat(found.file)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-drive",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"homepage": "https://github.com/almero-digital-marketing/mikser-io-drive#readme",
|
|
27
27
|
"peerDependencies": {
|
|
28
|
-
"mikser-io": "^9.
|
|
28
|
+
"mikser-io": "^9.52.0",
|
|
29
29
|
"zod": "^4.0.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
package/test/files.test.js
CHANGED
|
@@ -438,3 +438,61 @@ describe('move', () => {
|
|
|
438
438
|
assert.match(r.content[0].text, /readOnly/)
|
|
439
439
|
})
|
|
440
440
|
})
|
|
441
|
+
|
|
442
|
+
// Reads are capability-gated, the same as the WebDAV mount.
|
|
443
|
+
//
|
|
444
|
+
// The mount has always checked `drive:<name>` before serving a byte; this tool
|
|
445
|
+
// did not, so the two surfaces disagreed about the same endpoint and a
|
|
446
|
+
// `readOnly` list derived from capabilities described a rule only half the
|
|
447
|
+
// transports enforced.
|
|
448
|
+
describe('read capability', () => {
|
|
449
|
+
const toolsFor = async (principal) => {
|
|
450
|
+
const substrate = fakeSubstrate(principal)
|
|
451
|
+
registerFileTools({
|
|
452
|
+
runtime: {
|
|
453
|
+
options: {
|
|
454
|
+
...runtime.options, mcp: substrate,
|
|
455
|
+
roles: {
|
|
456
|
+
catalogue: {
|
|
457
|
+
editors: ['drive:media', 'drive:media:write', 'drive:secrets'],
|
|
458
|
+
developers: ['drive:media', 'drive:media:write', 'drive:secrets', 'drive:secrets:write'],
|
|
459
|
+
},
|
|
460
|
+
summaries: { developers: 'Everything an editor has, plus the code.' },
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
refs: runtime.refs,
|
|
464
|
+
},
|
|
465
|
+
endpoints: { media: { folder: 'media' }, secrets: { folder: 'locked' } },
|
|
466
|
+
capabilityOf: readCapability, writeCapabilityOf: writeCapability,
|
|
467
|
+
})
|
|
468
|
+
return substrate
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
it('serves a file the principal may read', async () => {
|
|
472
|
+
await call('mikser_drive_add', { endpoint: 'media', files: [{ name: 'readable.txt', base64: b64('hello') }] })
|
|
473
|
+
const substrate = await toolsFor({ subject: 'ed', roles: ['editors'],
|
|
474
|
+
capabilities: ['drive:media', 'drive:media:write'] })
|
|
475
|
+
const r = await substrate.call('mikser_drive_read', { path: 'media/readable.txt' })
|
|
476
|
+
assert.equal(JSON.parse(r.content.find(c => c.type === 'text').text).content, 'hello')
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('refuses one it may not, naming the role and who can', async () => {
|
|
480
|
+
const substrate = await toolsFor({ subject: 'ed', roles: ['editors'],
|
|
481
|
+
capabilities: ['drive:media', 'drive:media:write'] })
|
|
482
|
+
const r = await substrate.call('mikser_drive_read', { path: 'secrets/anything.txt' })
|
|
483
|
+
assert.equal(r.isError, true)
|
|
484
|
+
const message = r.content.find(c => c.type === 'text').text
|
|
485
|
+
assert.match(message, /Connected as editors/, 'the refusal names the acting role')
|
|
486
|
+
assert.match(message, /drive:secrets/, 'and the capability it lacks')
|
|
487
|
+
assert.match(message, /developers/, 'and who carries it')
|
|
488
|
+
assert.doesNotMatch(message, /request|escalat|retry/i, 'and never suggests obtaining it')
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('does not narrow a credential that is not capability-scoped', async () => {
|
|
492
|
+
// A static token or a loopback caller. The endpoint's own gate still
|
|
493
|
+
// applies; this must not guess a narrower answer.
|
|
494
|
+
const substrate = await toolsFor({ subject: 'static', capabilities: null })
|
|
495
|
+
const r = await substrate.call('mikser_drive_read', { path: 'media/readable.txt' })
|
|
496
|
+
assert.notEqual(r.isError, true)
|
|
497
|
+
})
|
|
498
|
+
})
|