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 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.12.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.46.0",
28
+ "mikser-io": "^9.52.0",
29
29
  "zod": "^4.0.0"
30
30
  },
31
31
  "dependencies": {
@@ -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
+ })