mikser-io-drive 0.14.0 → 0.16.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/.github/workflows/publish.yml +73 -0
- package/lib/authenticator.js +7 -4
- package/lib/files.js +21 -10
- package/package.json +9 -5
- package/test/derived.test.js +49 -33
- package/test/drive.test.js +27 -4
- package/test/files.test.js +7 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Publish to npm without a stored token.
|
|
2
|
+
#
|
|
3
|
+
# npm is retiring tokens that skip the second factor — its own token page says
|
|
4
|
+
# "Publish new versions directly (deprecated — ends January 2027)". This is the
|
|
5
|
+
# replacement: the job proves who it is at publish time with a short-lived
|
|
6
|
+
# credential GitHub issues and npm verifies, so npm trusts "the publish.yml
|
|
7
|
+
# workflow in this repository" rather than a string.
|
|
8
|
+
#
|
|
9
|
+
# There is no NODE_AUTH_TOKEN and no secret to configure. Nothing to leak,
|
|
10
|
+
# expire or rotate — the two ways publishing broke in September 2026.
|
|
11
|
+
#
|
|
12
|
+
# Link it once on npm: the package → Settings → Trusted Publisher → GitHub
|
|
13
|
+
# Actions, naming this repository and this filename. Until that exists the
|
|
14
|
+
# publish step fails rather than falling back to something weaker.
|
|
15
|
+
|
|
16
|
+
name: Publish
|
|
17
|
+
|
|
18
|
+
on:
|
|
19
|
+
push:
|
|
20
|
+
tags: ['v*']
|
|
21
|
+
workflow_dispatch:
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
publish:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
permissions:
|
|
27
|
+
contents: read
|
|
28
|
+
# The whole mechanism. Without it npm has nothing to verify.
|
|
29
|
+
id-token: write
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v4
|
|
32
|
+
|
|
33
|
+
- uses: actions/setup-node@v4
|
|
34
|
+
with:
|
|
35
|
+
node-version: '24'
|
|
36
|
+
registry-url: 'https://registry.npmjs.org'
|
|
37
|
+
|
|
38
|
+
# Trusted publishing landed in npm 11.5.1.
|
|
39
|
+
- name: Use an npm that can do trusted publishing
|
|
40
|
+
run: npm install -g npm@latest
|
|
41
|
+
|
|
42
|
+
# `npm install`, not `npm ci`: these packages are developed in a
|
|
43
|
+
# workspace, so dependencies hoist to its root and each package's own
|
|
44
|
+
# lockfile is never read locally. They drift silently, and CI is the only
|
|
45
|
+
# thing that would ever read them.
|
|
46
|
+
- run: npm install --no-audit --no-fund
|
|
47
|
+
env:
|
|
48
|
+
# puppeteer downloads a browser on install; nothing in the release
|
|
49
|
+
# path renders a page.
|
|
50
|
+
PUPPETEER_SKIP_DOWNLOAD: '1'
|
|
51
|
+
|
|
52
|
+
# The tag is the release. A tag that disagrees with package.json would
|
|
53
|
+
# publish a version nobody asked for.
|
|
54
|
+
- name: Tag must match package.json
|
|
55
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
56
|
+
run: |
|
|
57
|
+
TAG="${GITHUB_REF_NAME#v}"
|
|
58
|
+
PKG="$(node -p 'require("./package.json").version')"
|
|
59
|
+
if [ "$TAG" != "$PKG" ]; then
|
|
60
|
+
echo "tag v$TAG does not match package.json $PKG"
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
echo "publishing $PKG"
|
|
64
|
+
|
|
65
|
+
# The suite runs here now. It did not before: these packages declared
|
|
66
|
+
# mikser-io only as a peerDependency (npm does not install a package's
|
|
67
|
+
# own peers) or as file:../mikser-io (a path that exists on a laptop and
|
|
68
|
+
# nowhere else), so a standalone clone could not resolve the engine it
|
|
69
|
+
# tests against. Both are now real devDependencies with version ranges,
|
|
70
|
+
# which npm still satisfies from the workspace locally.
|
|
71
|
+
- run: npm test
|
|
72
|
+
|
|
73
|
+
- run: npm publish --access public
|
package/lib/authenticator.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { UnauthorizedError, ForbiddenError } from 'nephele'
|
|
2
2
|
import { authorize } from 'mikser-io'
|
|
3
|
+
// The one implementation of "does this principal hold it", including the
|
|
4
|
+
// `*` wildcard — checked here rather than re-derived, because two gates in
|
|
5
|
+
// this file and one in mikser-io-auth each had their own `.includes` and a
|
|
6
|
+
// wildcard added to the shared function reached none of them.
|
|
7
|
+
import { hasCapability } from 'mikser-io'
|
|
3
8
|
|
|
4
9
|
// Bridges Nephele's Authenticator contract onto the engine's verifier seam
|
|
5
10
|
// (ADR-0012), so WebDAV authenticates against the same htpasswd identity as
|
|
@@ -80,8 +85,7 @@ export class MikserAuthenticator {
|
|
|
80
85
|
// Capability gate for this endpoint. `capabilities: null` means the
|
|
81
86
|
// credential is not capability-scoped (no map configured, or a bare
|
|
82
87
|
// static token), which passes — same rule as every other surface.
|
|
83
|
-
if (this.#capability && principal
|
|
84
|
-
!principal.capabilities.includes(this.#capability)) {
|
|
88
|
+
if (this.#capability && !hasCapability(principal, this.#capability)) {
|
|
85
89
|
this.#logger?.debug?.('drive: %j lacks %j', principal.subject, this.#capability)
|
|
86
90
|
throw new ForbiddenError(`Your credential does not carry '${this.#capability}'.`)
|
|
87
91
|
}
|
|
@@ -94,8 +98,7 @@ export class MikserAuthenticator {
|
|
|
94
98
|
// therefore never means what it looks like it means.) The authenticator
|
|
95
99
|
// is the earliest place that holds both the principal and the method.
|
|
96
100
|
if (this.#writeCapability && WRITE_METHODS.has(request.method) &&
|
|
97
|
-
principal
|
|
98
|
-
!principal.capabilities.includes(this.#writeCapability)) {
|
|
101
|
+
!hasCapability(principal, this.#writeCapability)) {
|
|
99
102
|
this.#logger?.debug?.('drive: %j may not %s — lacks %j',
|
|
100
103
|
principal.subject, request.method, this.#writeCapability)
|
|
101
104
|
throw new ForbiddenError(`Your credential does not carry '${this.#writeCapability}'.`)
|
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 } from 'mikser-io'
|
|
33
|
+
import { looksTextual, recordChangeSetWrite, explainRefusal, actingRole, useService } 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,
|
|
@@ -246,11 +246,22 @@ async function settledCycle(cycleId) {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
export function registerFileTools({ runtime
|
|
250
|
-
|
|
249
|
+
export function registerFileTools({ runtime, endpoints, capabilityOf, writeCapabilityOf, logger }) {
|
|
250
|
+
// Asked of core rather than read off runtime.options.mcp, so this package
|
|
251
|
+
// no longer reaches into a sibling's namespace and no longer depends on
|
|
252
|
+
// where the mcp plugin sits in the plugins array.
|
|
253
|
+
//
|
|
254
|
+
// Registered through the SUBSTRATE, not the engine registry, on purpose:
|
|
255
|
+
// these tools declare `mutates`, and the substrate is what gives a
|
|
256
|
+
// mutating tool its changeSet parameters and runs its handler inside the
|
|
257
|
+
// change set. Registering them against the engine would leave their
|
|
258
|
+
// writes unattributable — the one failure nothing downstream can repair.
|
|
259
|
+
// They still reach the CLI, because the substrate mirrors every
|
|
260
|
+
// registration into the engine registry with the wrapped handler intact.
|
|
261
|
+
const mcp = useService('mcp')
|
|
251
262
|
if (!mcp?.registerTool) return
|
|
252
263
|
|
|
253
|
-
const workingFolder = () =>
|
|
264
|
+
const workingFolder = () => runtime.options.workingFolder
|
|
254
265
|
const folderOf = (name) => {
|
|
255
266
|
const ep = endpoints[name]
|
|
256
267
|
if (!ep) return null
|
|
@@ -484,10 +495,10 @@ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeC
|
|
|
484
495
|
if (found.endpoint && !holds(principal, capabilityOf(found.endpoint))) {
|
|
485
496
|
return fail(explainRefusal({
|
|
486
497
|
capability: capabilityOf(found.endpoint),
|
|
487
|
-
role: actingRole(principal?.roles ?? [],
|
|
498
|
+
role: actingRole(principal?.roles ?? [], runtime.options?.roles?.catalogue ?? {}),
|
|
488
499
|
target: pathish,
|
|
489
|
-
catalogue:
|
|
490
|
-
summaries:
|
|
500
|
+
catalogue: runtime.options?.roles?.catalogue ?? {},
|
|
501
|
+
summaries: runtime.options?.roles?.summaries ?? {},
|
|
491
502
|
}))
|
|
492
503
|
}
|
|
493
504
|
|
|
@@ -641,7 +652,7 @@ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeC
|
|
|
641
652
|
// Who breaks. The reference is the served URL where there is
|
|
642
653
|
// one, because that is the string documents actually carry.
|
|
643
654
|
const reference = found.entity?.meta?.url ?? found.entity?.id ?? null
|
|
644
|
-
const referrers = reference &&
|
|
655
|
+
const referrers = reference && runtime.refs?.inboundFor ? runtime.refs.inboundFor(reference) : []
|
|
645
656
|
staged.push({ input: pathish, file: found.file, entity: found.entity, owning, reference, referrers })
|
|
646
657
|
}
|
|
647
658
|
|
|
@@ -695,7 +706,7 @@ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeC
|
|
|
695
706
|
// file at a new URL and, where git sync is on, commit it. One stamp
|
|
696
707
|
// for the batch, so undoing it is one move back.
|
|
697
708
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
698
|
-
const trashRoot = path.join(
|
|
709
|
+
const trashRoot = path.join(runtime.options.runtimeFolder ?? path.join(workingFolder(), 'runtime'),
|
|
699
710
|
'trash', stamp)
|
|
700
711
|
const results = []
|
|
701
712
|
for (const s of staged) {
|
|
@@ -827,7 +838,7 @@ export function registerFileTools({ runtime: rt, endpoints, capabilityOf, writeC
|
|
|
827
838
|
// URLs differently is not second-guessed.
|
|
828
839
|
for (const s of staged) {
|
|
829
840
|
s.oldRef = s.entity?.meta?.url ?? null
|
|
830
|
-
s.referrers = s.oldRef &&
|
|
841
|
+
s.referrers = s.oldRef && runtime.refs?.inboundFor ? runtime.refs.inboundFor(s.oldRef) : []
|
|
831
842
|
s.newRef = s.oldRef
|
|
832
843
|
? s.oldRef.slice(0, s.oldRef.length - path.relative(folderOf(s.owning), s.file).length) + s.dest
|
|
833
844
|
: null
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
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.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.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"allowScripts": {
|
|
8
|
+
"better-sqlite3": true
|
|
9
|
+
},
|
|
7
10
|
"scripts": {
|
|
8
11
|
"test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
|
|
9
12
|
},
|
|
@@ -25,7 +28,7 @@
|
|
|
25
28
|
},
|
|
26
29
|
"homepage": "https://github.com/almero-digital-marketing/mikser-io-drive#readme",
|
|
27
30
|
"peerDependencies": {
|
|
28
|
-
"mikser-io": "^
|
|
31
|
+
"mikser-io": "^10.0.0",
|
|
29
32
|
"zod": "^4.0.0"
|
|
30
33
|
},
|
|
31
34
|
"dependencies": {
|
|
@@ -38,8 +41,9 @@
|
|
|
38
41
|
"bcryptjs": "^3.0.0",
|
|
39
42
|
"express": "^5.2.1",
|
|
40
43
|
"globby": "^15.0.0",
|
|
41
|
-
"mikser-io": "
|
|
42
|
-
"mikser-io-auth": "
|
|
44
|
+
"mikser-io": "^10.0.0",
|
|
45
|
+
"mikser-io-auth": "^0.13.0",
|
|
46
|
+
"mikser-io-mcp": "^2.0.0",
|
|
43
47
|
"webdav": "^5.10.0",
|
|
44
48
|
"zod": "^4.0.0"
|
|
45
49
|
}
|
package/test/derived.test.js
CHANGED
|
@@ -3,18 +3,21 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { spawn } from 'node:child_process'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
6
7
|
import path from 'node:path'
|
|
7
8
|
import { createRequire } from 'node:module'
|
|
8
9
|
|
|
9
|
-
// `derived` is
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// the whole pipeline — files → assets → renderPreset → sharp — against a live
|
|
13
|
-
// server and asks the tool.
|
|
10
|
+
// `derived` is `entity.meta.presets`, spread onto the add response. What this
|
|
11
|
+
// package owns is that passthrough: the field it reads and the field it
|
|
12
|
+
// writes, over a live server through the real tool.
|
|
14
13
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// the
|
|
14
|
+
// It used to produce a real derivative to get there — files → assets →
|
|
15
|
+
// renderPreset → sharp — which bought two assertions beyond the passthrough
|
|
16
|
+
// (a .webp suffix, a URL unlike the source) at the price of a hard dependency
|
|
17
|
+
// on mikser-io-assets, a live image encode, and a `skip` whenever sharp was
|
|
18
|
+
// absent, so on many machines it never ran at all. Those two assertions are
|
|
19
|
+
// presetUrl's behaviour and are unit-tested in mikser-io-assets. A five-line
|
|
20
|
+
// plugin stamps the field on the same hook instead.
|
|
18
21
|
//
|
|
19
22
|
// Two mistakes made while building it, both of which produce SILENCE rather
|
|
20
23
|
// than an error and are worth knowing:
|
|
@@ -27,15 +30,7 @@ import { createRequire } from 'node:module'
|
|
|
27
30
|
// (/media/**) matches nothing, produces no variant, and reports only that
|
|
28
31
|
// none matched.
|
|
29
32
|
|
|
30
|
-
// sharp is mikser-io's dependency, not this package's — the fixture needs it
|
|
31
|
-
// because a preset does the actual image work. Resolved from there rather than
|
|
32
|
-
// from here, and the fixture's node_modules is built from mikser-io's so the
|
|
33
|
-
// spawned build finds it too.
|
|
34
|
-
const require_ = createRequire(import.meta.url)
|
|
35
33
|
const ENGINE = path.resolve(import.meta.dirname, '..', '..', 'mikser-io')
|
|
36
|
-
const hasSharp = (() => {
|
|
37
|
-
try { require_.resolve('sharp', { paths: [ENGINE] }); return true } catch { return false }
|
|
38
|
-
})()
|
|
39
34
|
|
|
40
35
|
const PORT = 4211
|
|
41
36
|
const PNG = Buffer.from(
|
|
@@ -60,10 +55,8 @@ async function rpc(token, method, params, session) {
|
|
|
60
55
|
}
|
|
61
56
|
|
|
62
57
|
before(async (t) => {
|
|
63
|
-
if (!hasSharp) return
|
|
64
58
|
dir = await mkdtemp(path.join(tmpdir(), 'mikser-derived-'))
|
|
65
59
|
await mkdir(path.join(dir, 'media'), { recursive: true })
|
|
66
|
-
await mkdir(path.join(dir, 'presets'), { recursive: true })
|
|
67
60
|
await mkdir(path.join(dir, 'documents'), { recursive: true })
|
|
68
61
|
|
|
69
62
|
// node_modules by symlink, so the fixture resolves the working copies.
|
|
@@ -77,33 +70,56 @@ before(async (t) => {
|
|
|
77
70
|
await symlink(path.join(donor, entry), path.join(dir, 'node_modules', entry)).catch(() => {})
|
|
78
71
|
}
|
|
79
72
|
}
|
|
73
|
+
// Point at the WORKING COPY of each sibling when there is one — the whole
|
|
74
|
+
// point is to exercise this checkout, not the last release. In CI there is
|
|
75
|
+
// no workspace: the repository is cloned alone and the siblings come from
|
|
76
|
+
// npm, already symlinked by the donor loop above. Replacing them there
|
|
77
|
+
// removed the installed copy and linked to a path that does not exist, so
|
|
78
|
+
// the fixture had no engine at all and the server never started. Both
|
|
79
|
+
// steps failed silently — the rm succeeded, the symlink was swallowed by
|
|
80
|
+
// .catch — which is why it read as "Cannot find module .../app.js".
|
|
80
81
|
for (const pkg of ['mikser-io', 'mikser-io-mcp', 'mikser-io-drive']) {
|
|
82
|
+
const sibling = path.resolve(here, '..', pkg)
|
|
83
|
+
if (!existsSync(sibling)) continue
|
|
81
84
|
await rm(path.join(dir, 'node_modules', pkg), { recursive: true, force: true })
|
|
82
|
-
await symlink(
|
|
85
|
+
await symlink(sibling, path.join(dir, 'node_modules', pkg)).catch(() => {})
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
// A preset is a module whose DEFAULT export does the work.
|
|
86
|
-
await writeFile(path.join(dir, 'presets/web.js'),
|
|
87
|
-
"import sharp from 'sharp'\n"
|
|
88
|
-
+ 'export const revision = 1\n'
|
|
89
|
-
+ "export const format = 'webp'\n"
|
|
90
|
-
+ 'export const options = { width: 8 }\n'
|
|
91
|
-
+ 'export default ({ entity: { source, destination }, options }) =>\n'
|
|
92
|
-
+ ' sharp(source).resize(options).webp().toFile(destination)\n')
|
|
93
|
-
|
|
94
88
|
// `match` against the ID shape, which the files plugin builds from the path
|
|
95
89
|
// inside filesFolder — NOT the URL shape.
|
|
90
|
+
// A five-line plugin in place of the assets pipeline.
|
|
91
|
+
//
|
|
92
|
+
// `derived` is `entity.meta.presets`, spread onto the response. What drive
|
|
93
|
+
// owns is that passthrough — the field name it reads and the field name it
|
|
94
|
+
// writes. Producing a real derivative to test it meant a live sharp
|
|
95
|
+
// encode, a preset module on disk, and a hard dependency on
|
|
96
|
+
// mikser-io-assets for a package that does not otherwise use it; the
|
|
97
|
+
// assertions it bought beyond the passthrough (a .webp suffix, a URL
|
|
98
|
+
// unlike the source) are presetUrl's behaviour and are unit-tested in
|
|
99
|
+
// mikser-io-assets, where changing them would be noticed.
|
|
100
|
+
//
|
|
101
|
+
// Stamped in onProcessed, the same hook and the same field the assets
|
|
102
|
+
// plugin writes.
|
|
96
103
|
await writeFile(path.join(dir, 'mikser.config.js'),
|
|
97
|
-
"import { files
|
|
104
|
+
"import { files } from 'mikser-io'\n"
|
|
98
105
|
+ "import { mcp } from 'mikser-io-mcp'\n"
|
|
99
106
|
+ "import { drive } from 'mikser-io-drive'\n"
|
|
107
|
+
// onProcessed is a PHASE hook — it receives the abort signal, not an
|
|
108
|
+
// entity — so the stamp walks the journal, which is the same shape and
|
|
109
|
+
// the same hook the assets plugin uses to write this field.
|
|
110
|
+
+ 'const stampPresets = () => ({ onProcessed, useJournal, updateEntity, constants: { OPERATION } }) =>\n'
|
|
111
|
+
+ ' onProcessed(async (signal) => {\n'
|
|
112
|
+
+ " for await (const { entity } of useJournal('Stamp presets', [OPERATION.CREATE, OPERATION.UPDATE], signal)) {\n"
|
|
113
|
+
+ " if (entity.collection !== 'files' || !entity.id.endsWith('.png')) continue\n"
|
|
114
|
+
+ " entity.meta = { ...entity.meta, presets: { web: '/derived/web/shots/hero.webp' } }\n"
|
|
115
|
+
+ ' await updateEntity(entity)\n'
|
|
116
|
+
+ ' }\n'
|
|
117
|
+
+ '})\n'
|
|
100
118
|
+ 'export default async () => ({\n'
|
|
101
119
|
+ ' plugins: [\n'
|
|
102
120
|
+ " mcp({ base: '' }),\n"
|
|
103
121
|
+ " files({ filesFolder: 'media', outputFolder: 'media' }),\n"
|
|
104
|
-
+
|
|
105
|
-
+ " presets: { web: { match: ['/files/**/*.png'] } } }),\n"
|
|
106
|
-
+ ' renderPreset(),\n'
|
|
122
|
+
+ ' stampPresets(),\n'
|
|
107
123
|
+ " drive({ allowRemote: true, endpoints: { media: { folder: 'media' } } }),\n"
|
|
108
124
|
+ ' ],\n'
|
|
109
125
|
+ '})\n')
|
|
@@ -144,7 +160,7 @@ after(async () => {
|
|
|
144
160
|
if (dir) await rm(dir, { recursive: true, force: true })
|
|
145
161
|
})
|
|
146
162
|
|
|
147
|
-
describe('derived — the preset passthrough',
|
|
163
|
+
describe('derived — the preset passthrough', () => {
|
|
148
164
|
it('reports the variant a preset actually produced', async () => {
|
|
149
165
|
const session = { id: null, n: 1 }
|
|
150
166
|
await rpc(null, 'initialize',
|
package/test/drive.test.js
CHANGED
|
@@ -27,7 +27,7 @@ before(async () => {
|
|
|
27
27
|
await writeFile(path.join(dir, 'users.htpasswd'), ['alice', 'bob', 'carol']
|
|
28
28
|
.map(u => `${u}:${bcrypt.hashSync(`${u}-pw`, 10)}`).join('\n') + '\n')
|
|
29
29
|
await writeFile(path.join(dir, 'groups.htgroup'),
|
|
30
|
-
'editors: alice\nreviewers: bob\n')
|
|
30
|
+
'editors: alice\nreviewers: bob\nowners: carol\n')
|
|
31
31
|
|
|
32
32
|
const { default: express } = await import('express')
|
|
33
33
|
const app = express()
|
|
@@ -43,6 +43,10 @@ before(async () => {
|
|
|
43
43
|
// why `editors` has to name `drive:locked` too.
|
|
44
44
|
editors: ['drive:content', 'drive:content:write', 'drive:locked'],
|
|
45
45
|
reviewers: ['drive:content'], // read, no write
|
|
46
|
+
// `*` is a real wildcard. It was written in grants long before it
|
|
47
|
+
// meant anything, and matched nothing — every gate was an exact
|
|
48
|
+
// includes, including the two in this package.
|
|
49
|
+
owners: ['*'],
|
|
46
50
|
},
|
|
47
51
|
})
|
|
48
52
|
|
|
@@ -103,11 +107,30 @@ describe('auth', () => {
|
|
|
103
107
|
})
|
|
104
108
|
|
|
105
109
|
it('403s an authenticated user who lacks the endpoint capability', async () => {
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
const res = await dav('PROPFIND', '/drive/
|
|
110
|
+
// bob is a reviewer: he holds drive:content but was never granted
|
|
111
|
+
// drive:locked, so that endpoint is not his to open.
|
|
112
|
+
const res = await dav('PROPFIND', '/drive/locked/', { headers: { depth: '0', ...as('bob') } })
|
|
109
113
|
assert.equal(res.status, 403)
|
|
110
114
|
})
|
|
115
|
+
|
|
116
|
+
it('opens an endpoint for a wildcard holder who was never granted it', async () => {
|
|
117
|
+
// carol holds `*` and nothing else. The gate here checked
|
|
118
|
+
// `capabilities.includes(...)` directly rather than asking
|
|
119
|
+
// hasCapability, so a wildcard reached api and mcp — which declare no
|
|
120
|
+
// capability and so pass the unscoped check — and stopped at the
|
|
121
|
+
// drive, which declares one. A role whose summary said "everything"
|
|
122
|
+
// could not open a folder, and its printed reach was empty.
|
|
123
|
+
const res = await dav('PROPFIND', '/drive/content/', { headers: { depth: '0', ...as('carol') } })
|
|
124
|
+
assert.equal(res.status, 207, 'a wildcard holder may mount and read')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('lets a wildcard holder write, without naming the write capability', async () => {
|
|
128
|
+
const res = await dav('PUT', '/drive/content/wildcard.md', {
|
|
129
|
+
headers: { ...as('carol'), 'content-type': 'text/markdown' },
|
|
130
|
+
body: '# by a wildcard holder\n',
|
|
131
|
+
})
|
|
132
|
+
assert.ok([201, 204].includes(res.status), `expected a write, got ${res.status}`)
|
|
133
|
+
})
|
|
111
134
|
})
|
|
112
135
|
|
|
113
136
|
describe('read and write', () => {
|
package/test/files.test.js
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdtemp, rm, mkdir, writeFile, readFile, stat } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
|
|
7
|
-
import { runtime } from 'mikser-io'
|
|
7
|
+
import { runtime, provideService, resetServices } from 'mikser-io'
|
|
8
8
|
import { registerFileTools } from '../lib/files.js'
|
|
9
9
|
import { readCapability, writeCapability } from '../index.js'
|
|
10
10
|
|
|
@@ -41,8 +41,10 @@ before(async () => {
|
|
|
41
41
|
runtime.options = { ...runtime.options, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
|
|
42
42
|
|
|
43
43
|
mcp = fakeSubstrate({ subject: 'alice', capabilities: ['drive:media', 'drive:media:write'] })
|
|
44
|
+
resetServices()
|
|
45
|
+
provideService('mcp', mcp)
|
|
44
46
|
registerFileTools({
|
|
45
|
-
runtime: { options: { ...runtime.options
|
|
47
|
+
runtime: { options: { ...runtime.options }, refs: runtime.refs },
|
|
46
48
|
endpoints: { media: { folder: 'media' }, locked: { folder: 'locked', readOnly: true } },
|
|
47
49
|
capabilityOf: readCapability, writeCapabilityOf: writeCapability,
|
|
48
50
|
})
|
|
@@ -448,10 +450,12 @@ describe('move', () => {
|
|
|
448
450
|
describe('read capability', () => {
|
|
449
451
|
const toolsFor = async (principal) => {
|
|
450
452
|
const substrate = fakeSubstrate(principal)
|
|
453
|
+
resetServices()
|
|
454
|
+
provideService('mcp', substrate)
|
|
451
455
|
registerFileTools({
|
|
452
456
|
runtime: {
|
|
453
457
|
options: {
|
|
454
|
-
...runtime.options,
|
|
458
|
+
...runtime.options,
|
|
455
459
|
roles: {
|
|
456
460
|
catalogue: {
|
|
457
461
|
editors: ['drive:media', 'drive:media:write', 'drive:secrets'],
|