mikser-io 9.3.0 → 9.7.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/docs/configuration.md +2 -0
- package/docs/decisions/0012-auth-seam-in-core-identity-in-plugins.md +194 -0
- package/docs/decisions/README.md +1 -0
- package/index.js +1 -0
- package/package.json +3 -1
- package/src/auth.js +229 -0
- package/src/catalog.js +22 -0
- package/src/manager.js +8 -1
- package/src/manifest.js +31 -5
- package/src/plugins/api.js +102 -30
- package/src/server.js +31 -1
- package/src/source.js +6 -2
- package/src/subscriptions.js +25 -0
- package/src/utils.js +108 -1
package/docs/configuration.md
CHANGED
|
@@ -58,7 +58,9 @@ These options are part of `runtime.options` and apply to the engine itself.
|
|
|
58
58
|
| `trace` | `-t, --trace` | boolean | `false` | Enable trace-level logging (very verbose). |
|
|
59
59
|
| `threads` | — | number | `4` | Worker thread count for the Piscina pools (`renderWorkers`, `postprocessWorkers`). Both pools are lazy (`minThreads: 0` + `idleTimeout: 30_000`) so INLINE-only workloads spin up zero workers. |
|
|
60
60
|
| `server` | `-s, --server [port]` | number\|boolean | — | When set, the engine creates a shared Express app on `runtime.options.app` and listens on the given port (default `3001`) after all plugins have mounted their routes. Plugins like `api` attach to it instead of starting their own server. The `outputFolder` is also served as a static catch-all route at `/` (plugin routes match first; anything that doesn't match falls through to the rendered output). Requires `express` to be installed. |
|
|
61
|
+
| `junk` | — | array\|false | built-in list | OS and file-manager litter, filtered out of both the scan and the watcher. The dot-prefixed files (`.DS_Store`, `._*`) were already invisible — globby defaults to `dot: false` and the watcher ignores leading dots — but the Windows ones are **not** dotfiles: `Thumbs.db` and `desktop.ini` were measurably scanned *and* watched, and became entities. The list is deliberately conservative (OS/file-manager artifacts and application lock files only, no `*.tmp`, `*.bak` or editor backups), because a filter that silently drops content is worse than the litter it prevents. `false` disables it; an array replaces it. See `isJunkPath` / `JUNK_IGNORE` in `src/utils.js`. Plugins that write metadata next to content add their own patterns with `registerJunk({ ignore, match })` — the engine provides the mechanism and the plugin the knowledge of what its files are called (`mikser-io-webdav` registers `*.nephelemeta`). Plugin registrations survive an array override, since narrowing the OS list is not a request to start importing a library's sidecars. |
|
|
61
62
|
| `cors` / `no-cors` | `--cors` / `--no-cors` | boolean | — | Toggle CORS on the engine's shared Express app. See `src/server.js` for the extensible header arrays plugins push onto. |
|
|
63
|
+
| `server.requestTimeout` | — | number | node default (`300000`) | Milliseconds a single request may take, set on the underlying `http.Server`. Node's 5-minute default is effectively an **upload size limit expressed in seconds** — a large file over a slow link is indistinguishable from a stalled request, so it is cut off and the caller sees a truncated write rather than a readable error. Only reachable from the `http.Server`, which the engine owns, so a plugin that mounts an upload surface (`mikser-io-webdav`, `forms` with large attachments) cannot raise it for itself. `0` disables the cap: reasonable on a trusted-network build server, bad facing the internet, where it removes the only bound on how long a client can hold a connection open doing nothing. `headersTimeout` is clamped to stay at or below it. The server is also exposed as `runtime.options.httpServer`. |
|
|
62
64
|
| `url` | `-u, --url <url>` | string | — | Public URL where this mikser is reachable (e.g. `https://blog.me.com`). Validated, trailing slash stripped, stamped on `runtime.options.url`. Read by webhook-capable plugins for push-vs-poll gating (`url.startsWith('https://')`); used by anything that surfaces absolute URLs externally — MCP preview URLs returned to agents, forms share links, email tracking pixels. Plugins that just need internal URLs keep using `runtime.options.port`. |
|
|
63
65
|
|
|
64
66
|
## Engine Substrate
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# 12. Authentication is an engine seam; identity providers are plugins
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Mikser already authenticates requests. It does so in at least three places, by copy-paste, and the copies have drifted.
|
|
10
|
+
|
|
11
|
+
The rule every copy claims to implement — call it the *uniform rule* — is:
|
|
12
|
+
|
|
13
|
+
- token presented and valid → allow from anywhere
|
|
14
|
+
- token presented and invalid → reject
|
|
15
|
+
- token absent → require loopback, unless `allowRemote`
|
|
16
|
+
|
|
17
|
+
`src/plugins/api.js` and `mikser-io-mcp` implement that. `mikser-io-forms` does not, despite a comment saying it does:
|
|
18
|
+
|
|
19
|
+
| | token set, none presented | loopback denial | wrong token |
|
|
20
|
+
| --- | --- | --- | --- |
|
|
21
|
+
| `api.js` | falls back to loopback | `403` | `401` |
|
|
22
|
+
| `mikser-io-mcp` | falls back to loopback | `403` (JSON-RPC `-32001`) | `401` |
|
|
23
|
+
| `mikser-io-forms` | **rejects — no fallback** | **`401`** | `401` |
|
|
24
|
+
|
|
25
|
+
Neither behaviour is obviously wrong. Forms is arguably the safer reading. The problem is that nobody chose: three authors wrote the same paragraph of comments and three different policies, and no test can catch it because there is nothing shared to test.
|
|
26
|
+
|
|
27
|
+
The engine already exports the *primitive* (`isLoopback`) but not the *policy*. `src/utils.js` says so out loud: "For more nuanced policies (token gate + loopback fallback), plugins usually inline the check using `isLoopback()` directly." That was an accurate description of the status quo and is now the thing to fix.
|
|
28
|
+
|
|
29
|
+
Three further consequences follow from having no seam:
|
|
30
|
+
|
|
31
|
+
- **Token comparison is `presented !== expectedAuth`** — a plain string compare, not constant-time. Correct in every copy, and correct nowhere in particular.
|
|
32
|
+
- **There is no way to add a second credential type.** Supporting OAuth for remote MCP (the MCP spec's `WWW-Authenticate` + RFC 9728 challenge, which nothing in the tree implements today) currently means editing every plugin that mounts a route.
|
|
33
|
+
- **`registerRoute({ reachability })` already carries the vocabulary** — `'loopback' | 'token' | 'public'` — but each plugin recomputes it from its own config by hand, so the facade's view of exposure is only as accurate as the least careful copy.
|
|
34
|
+
|
|
35
|
+
Separately, and deliberately kept separate: outbound credentials (`GOOGLE_APPLICATION_CREDENTIALS`, `GH_TOKEN`, `DIRECTUS_TOKEN`) are each read ad hoc by their provider. That is a different problem with a different answer — see *Decision*, point 5.
|
|
36
|
+
|
|
37
|
+
## Decision
|
|
38
|
+
|
|
39
|
+
**1. The engine gains an authentication seam. It gains no identity model.**
|
|
40
|
+
|
|
41
|
+
A *verifier* is a provider-agnostic descriptor:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
{
|
|
45
|
+
name: 'bearer',
|
|
46
|
+
async verify(req) {}, // → null (no credential) | false (bad) | { subject, capabilities }
|
|
47
|
+
challenge? (res) {}, // optional: emit WWW-Authenticate for this scheme
|
|
48
|
+
authorizationServers?: [], // optional: OAuth discovery metadata
|
|
49
|
+
resource?, scopesSupported?, // consumed by mikser-io-mcp for RFC 9728
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Core ships exactly one verifier — `bearer({ token })`, constant-time via `crypto.timingSafeEqual` — plus:
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
resolveAuth(config) // string | fn | verifier | { token } | [ … ] → verifier | null
|
|
57
|
+
anyOf(...verifiers) // accept ANY of several credentials
|
|
58
|
+
authorize(req, verifier, opts) // the uniform rule, transport-agnostic
|
|
59
|
+
requireAuth(verifier, opts) // express middleware over authorize()
|
|
60
|
+
reachabilityOf({ auth, token, allowRemote }) // → 'loopback' | 'token' | 'public'
|
|
61
|
+
hasCapability(principal, capability) // endpoint ceiling ∩ principal grant
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Several credentials on one surface.** A deployment reaches for this almost
|
|
65
|
+
immediately: a handful of long-lived machine tokens (one per caller, so a leak
|
|
66
|
+
scopes its own blast radius) alongside a human's OAuth token on the same routes.
|
|
67
|
+
`anyOf` lives in the engine rather than in userland because the last project that
|
|
68
|
+
hand-rolled it shipped a silent auth *bypass* — a composed verifier the resolver
|
|
69
|
+
didn't recognise degraded to a pass-through `next()` and merely warned. The
|
|
70
|
+
failure mode of getting this wrong is not "denied", it is "wide open".
|
|
71
|
+
|
|
72
|
+
The three-valued merge is the whole subtlety: any verifier accepting wins; all
|
|
73
|
+
reporting "nothing presented" yields `null` so loopback policy may still apply;
|
|
74
|
+
**any** reporting "presented and wrong" yields `false`, which never falls back to
|
|
75
|
+
loopback. A caller who presented a credential has identified itself and must be
|
|
76
|
+
judged on it.
|
|
77
|
+
|
|
78
|
+
`verify()` returning a principal is the primitive; `requireAuth()` is the Express convenience built on it. Both are exported, because MCP must render its denial as a JSON-RPC error body rather than an HTTP JSON body and therefore cannot use a generic middleware.
|
|
79
|
+
|
|
80
|
+
Core takes no position on who a subject is, where it came from, or how it was proven. It knows only: a credential was presented or it wasn't, it verified or it didn't, and what capabilities it carries.
|
|
81
|
+
|
|
82
|
+
**2. The uniform rule becomes one implementation.**
|
|
83
|
+
|
|
84
|
+
The three copies are replaced by `requireAuth()`. `api.js`'s semantics win — loopback fallback, `403` for reachability denial, `401` for a bad credential — because two of three already implement it and because `403` is the honest code when the caller's *origin* is the problem rather than their credential. Forms changes behaviour; that is a deliberate, documented break, not an accident.
|
|
85
|
+
|
|
86
|
+
**3. Identity providers are plugins, on the same contract.**
|
|
87
|
+
|
|
88
|
+
An OAuth verifier — and, if wanted, a self-hosted authorization server — ships as `mikser-io-auth`, exporting a verifier that `resolveAuth()` accepts wherever a token string is accepted today:
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
api({ endpoints: { admin: { auth: oauth({ issuer, audience }) } } })
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This mirrors what WhiteBox already proved out: a seam in `server/src/auth.js`, a first-party authorization server in `server-plugin-oauth`, and an external provider in `whitebox-pro-auth-auth0` — all three interchangeable at the same seam. The one place mikser deliberately diverges is the user store: WhiteBox keeps users in Postgres because WhiteBox is a running product with members; mikser keeps them in files because mikser is a build tool with operators (see point 4).
|
|
95
|
+
|
|
96
|
+
**4. Capabilities ride the credential, not a user.**
|
|
97
|
+
|
|
98
|
+
`operations: ['list', 'update', 'delete', 'render']` is already a capability set; today it is bound to an *endpoint*. It becomes the verifier's output instead, so a static token is simply an identity whose capability set is fixed by config, and an OAuth token is one whose capability set is minted at login. Route gating (`allow(op)`) is unchanged and stays where it is.
|
|
99
|
+
|
|
100
|
+
**Where identity is stored: Apache-format files in the working folder.**
|
|
101
|
+
|
|
102
|
+
Mikser does not grow a user table. Users and groups live as `htpasswd` and `htgroup` files alongside the content they govern:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
<workingFolder>/
|
|
106
|
+
users.htpasswd alice:$2y$10$… (bcrypt, apr1, or sha1)
|
|
107
|
+
groups.htgroup editors: alice bob
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This is ADR-0002 applied to identity rather than content, and it buys the same things files buy everywhere else in mikser: reviewable in a pull request, diffable, deployable by copying a directory, editable with `htpasswd(1)` — a tool that predates every framework this project will outlive. It also means the *format* is not ours to design, version, or migrate.
|
|
111
|
+
|
|
112
|
+
Groups map to capabilities in config, so the files stay pure identity and the product decides what a group can do:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
auth({ groups: { editors: ['api:update', 'mcp:use'], viewers: ['api:list'] } })
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The obvious objection is that a file cannot be written safely by a running server under concurrent requests. That is a real constraint and it is also the point: this is a store you *provision*, not one the product mutates at runtime. Self-service signup, password reset, and invite flows are explicitly not in scope — a build tool has operators, not members.
|
|
119
|
+
|
|
120
|
+
**Row scope rides the credential too, opaquely.**
|
|
121
|
+
|
|
122
|
+
Capabilities are verbs (`api:delete`); they cannot express *which rows exist for
|
|
123
|
+
you*. A principal may therefore carry a `scope` — a value the **engine never
|
|
124
|
+
inspects**. Only the plugin whose surface issued the credential knows what it
|
|
125
|
+
means; for `api`/`vector`/`data` it is a sift filter, `$and`-ed with the
|
|
126
|
+
endpoint's own `query`. Keeping it opaque is what stops the engine from growing
|
|
127
|
+
an opinion about content.
|
|
128
|
+
|
|
129
|
+
Always `$and`, never `$or`: a credential may narrow what an endpoint exposes,
|
|
130
|
+
never widen it. Groups map to scopes the same way they map to capabilities, and
|
|
131
|
+
membership in several groups unions with `$or` — more groups must mean more
|
|
132
|
+
reach, or adding one would make a user less able.
|
|
133
|
+
|
|
134
|
+
Two consequences fell out of this and are load-bearing:
|
|
135
|
+
|
|
136
|
+
- **A principal-scoped response is never written to the query cache.** The cache
|
|
137
|
+
mirrors the request URL into the output folder, where nginx `try_files` serves
|
|
138
|
+
it without reaching the process. One caller's scoped rows would be handed to
|
|
139
|
+
every later caller of the same URL, unauthenticated. The key is
|
|
140
|
+
endpoint+querystring by design (it must match what nginx sees) so it cannot be
|
|
141
|
+
salted with the principal — the only safe answer is not to write.
|
|
142
|
+
- **A principal scope must be a sift object, never a function.** An object is
|
|
143
|
+
pushed into the WHERE clause; a function is applied post-fetch, the form that
|
|
144
|
+
pinned a production box at 111% CPU on 1,367 entities. A function is refused
|
|
145
|
+
rather than silently accepted.
|
|
146
|
+
|
|
147
|
+
The aggregated cross-plugin permission *catalog* WhiteBox uses stays **out of scope**. If it lands, it must be assembled during engine infrastructure setup, before any plugin hook runs (ADR-0005), or plugin load order becomes observable.
|
|
148
|
+
|
|
149
|
+
**5. Outbound credentials stay a convention, not core.**
|
|
150
|
+
|
|
151
|
+
A provider reading `process.env.GH_TOKEN` creates no god-plugin and forces no coordination — by the ADR-0006 bar it does not earn engine code. What it needs is a documented convention (`MIKSER_<PLUGIN>_<NAME>`, config-over-env precedence, redaction in logs), which is a docs change and a lint, not a subsystem.
|
|
152
|
+
|
|
153
|
+
## Why this clears ADR-0006
|
|
154
|
+
|
|
155
|
+
1. **Substrate, not domain.** The engine says *how* a credential is proven; plugins say *what* they gate. Core never learns what a route does.
|
|
156
|
+
2. **Strengthens ADR-0004.** Today's "shared" policy is shared by duplication — the exact failure that ADR is written against. This is a consolidation of a concern mikser already has, not a new one it is claiming.
|
|
157
|
+
3. **The plugin alternative is a god-plugin.** An auth plugin would have to know every other plugin's routes to gate them — the "express plugin" failure mode by name.
|
|
158
|
+
4. **Plugins compose independently.** A plugin calls `resolveAuth(options.auth)` and mounts the result. No ordering, no coordination, no collision. This is the test the *catalog* fails, which is why the catalog is deferred.
|
|
159
|
+
5. **Cadence.** The seam is ~80 lines whose shape has been stable in WhiteBox across its lifetime. OAuth is not stable — the MCP auth spec, RFC 9728 adoption, and the DCR debate all move monthly. Test 5 is what forces the split: **seam in core, OAuth in a plugin.**
|
|
160
|
+
|
|
161
|
+
## Consequences
|
|
162
|
+
|
|
163
|
+
**Easier.** One auth policy, one place, one test suite. Adding OAuth becomes a plugin nobody else has to know about. `reachability` is computed by the engine, so the facade's exposure map stops being a per-plugin promise. Constant-time comparison happens once, correctly.
|
|
164
|
+
|
|
165
|
+
**Harder.** Every route-mounting plugin gets a coordinated release: core first, then a mechanical edit in `api`, `mcp`, `forms`, `decap`, `vector`. Forms changes behaviour for anyone who configured a token *and* relied on loopback bypassing it. The engine takes on a security-sensitive surface, where being wrong is worse than being absent — which is the argument for keeping it at 80 lines that do one thing.
|
|
166
|
+
|
|
167
|
+
**Staging.** Core seam ships first and is purely additive; the existing inline checks keep working untouched. Plugins migrate one at a time. `mikser-io-auth` is independent of both and needed only when a remote MCP endpoint has to satisfy a spec-compliant client.
|
|
168
|
+
|
|
169
|
+
## Examples
|
|
170
|
+
|
|
171
|
+
- `src/auth.js` — the seam. `src/plugins/api.js` — the reference caller: auth, the
|
|
172
|
+
operation ceiling intersected with the principal's grant, and the per-request
|
|
173
|
+
scope combination. Note that every route lists `auth` **before** `allow(op)`;
|
|
174
|
+
the old order ran the capability check against an undefined principal, which
|
|
175
|
+
passes every check — a silent bypass, not a failure.
|
|
176
|
+
- `mikser-io-mcp/index.js` `mountEndpoint()` — why `authorize()` exists separately
|
|
177
|
+
from `requireAuth()`: MCP answers in JSON-RPC, so it shapes its own denials.
|
|
178
|
+
- `mikser-io-forms/index.js` — the drift that motivated this, now migrated. Its
|
|
179
|
+
behaviour changed: a token-gated form endpoint accepts loopback without the
|
|
180
|
+
token, and reachability denial answers 403 rather than 401, matching the others.
|
|
181
|
+
- `mikser-io-auth` — identity: htpasswd/htgroup, bcrypt, an ES256 key file, and
|
|
182
|
+
the Basic and JWT verifiers.
|
|
183
|
+
- `src/utils.js` `isLoopback` / `loopbackOnly` — the primitive that was already shared, and the comment that documented the missing half.
|
|
184
|
+
- WhiteBox `server/src/auth.js` + `server-plugin-oauth` + `whitebox-pro-auth-auth0` — the same split, already load-bearing in production.
|
|
185
|
+
|
|
186
|
+
## Watch for drift
|
|
187
|
+
|
|
188
|
+
The failure mode is the engine growing an opinion about *identity*. Drift looks like: a users table; a `role` field; a login route in core; a permission catalog added "while we're here"; the seam accepting a provider-specific option so one integration is easier.
|
|
189
|
+
|
|
190
|
+
For the identity files specifically, drift looks like the product writing to them — a signup route, a password-reset endpoint, a "create user" tool. The moment mikser writes an htpasswd file at runtime it has a concurrency problem, a locking problem, and a database it refuses to admit it has.
|
|
191
|
+
|
|
192
|
+
The counter-question is always the same: could `mikser-io-auth` be uninstalled, leaving a working token-and-loopback engine behind? If not, the concern crossed the seam.
|
|
193
|
+
|
|
194
|
+
The second, quieter drift is a plugin inlining its own check again because the seam didn't quite fit. That's a signal the seam is wrong, not a licence — the fix is to change `requireAuth()`, not to route around it.
|
package/docs/decisions/README.md
CHANGED
|
@@ -37,3 +37,4 @@ Decisions don't expire. They get **superseded** when we learn enough to change t
|
|
|
37
37
|
| [0009](./0009-database-engine-substrate.md) | Sqlite is the engine's persistence substrate. Single `runtime/mikser.sqlite` file holds catalog/refs/manifest/journal as `mikser_entities` / `mikser_refs` / `mikser_snapshots` / `mikser_journal` tables. Plugins register schemas via `registerSchema(name, sql)` + `useDatabase()`. Sift→SQL translator with indexed pushdown, LRU cache for `findById`, worker-side read-only sqlite for sync template helpers, chunked journal walks + `iterateEntities` streaming, `useJournal` auto-persist (mutate the yielded entity; no explicit `updateEntry` needed), `--resume` after interrupted cycles. Replaces `Map<id, entity>` + NDJSON across every engine subsystem. | Accepted |
|
|
38
38
|
| [0010](./0010-plugin-bundles-and-inline-options.md) | Plugin bundles + factory-call form + inline options. Plugins are imported by name and called as factories; `plugins: []` carries factory returns, never strings. Lifecycle plugins are `(options) => (core) => void`; renderers return `{name, options, load?, render?}`; postprocessors return `{name, options, output?, setup?, postprocess, teardown?}`. Per-plugin config moved off `runtime.config.<plugin>` — it arrives as the factory arg and is passed as `config` to `load`/`render`/`setup`/`postprocess`. | Accepted |
|
|
39
39
|
| [0011](./0011-served-entities-expose-deployed-urls.md) | File and resource entities expose deployed URLs. References to served files (image/video/PDF) are `$`-keyed **served paths** (`/img/X.jpg`, `/media/clip.mp4` — the path content authors, = the entity's `meta.url`), resolving through a new `refFilter` `{ 'meta.url': … }` clause backed by an indexed `meta_url` column (schema 9.0.1 → 9.0.2). No collection-prefixed ids leak into content. (Id-refs were tried and rejected — gpoint references content by served path and its `/media/**` `resources()` library means the entity only exists because content references `/media/…`.) The `files`/`resources` plugins stamp `meta.url`, the `assets` plugin stamps `meta.presets` — so expanding a ref yields the served entity's URL set instead of a string to reconstruct. Base-relative in the live catalog (host-agnostic; consumer holds `base`), absolute in static renders (baked from `runtime.options.url`, so logic-less consumers — email, RSS, foreign apps — read a whole URL); `lookupUrl` render helper resolves a ref to `meta.url` or a named preset. SDK collapses `assetUrl(source, preset, {ext})` into one `url(ref)` join + a dev-mode SPA-fallback detector. *("Served entity" — file/resource/preset — avoids colliding with the `assets` plugin's own "asset reference" term.)* | Accepted (proven against gpoint) |
|
|
40
|
+
| [0012](./0012-auth-seam-in-core-identity-in-plugins.md) | Authentication is an engine seam; identity providers are plugins. Core ships a provider-agnostic verifier contract (`{ name, verify(req), challenge? }`), a constant-time `bearer()`, `resolveAuth()`, `authorize()`/`requireAuth()` and `reachabilityOf()` — replacing the same token+loopback rule hand-copied into `api`, `mcp` and `forms`, where it had already drifted into three behaviours. A configured verifier gates every request (no loopback bypass, WhiteBox posture); a plain `token:` keeps the trusted-local-host model. OAuth, RFC 9728 discovery and identity ship as `mikser-io-auth`; users and groups are Apache-format `htpasswd`/`htgroup` files in the working folder (ADR-0002), never written at runtime. Credentials compose (`anyOf`) and may carry an opaque row `scope` the engine never inspects, `$and`-ed with the endpoint's own query; scoped responses are never cached. No user table, no roles, no permission catalog in core. | Accepted |
|
package/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { default as runtime } from './src/runtime.js'
|
|
2
2
|
export * as constants from './src/constants.js'
|
|
3
3
|
export * from './src/utils.js'
|
|
4
|
+
export * from './src/auth.js'
|
|
4
5
|
export * from './src/lifecycle.js'
|
|
5
6
|
export * from './src/database/index.js'
|
|
6
7
|
export * from './src/journal.js'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.7.0",
|
|
4
4
|
"description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -64,8 +64,10 @@
|
|
|
64
64
|
"@ai-sdk/openai": "^3.0.71",
|
|
65
65
|
"fluent-ffmpeg": "^2.1.3",
|
|
66
66
|
"mikser-io-decap": "file:../mikser-io-decap",
|
|
67
|
+
"mikser-io-layouts": "file:../mikser-io-layouts",
|
|
67
68
|
"mikser-io-post-mjml": "file:../mikser-io-post-mjml",
|
|
68
69
|
"mikser-io-post-pdf": "file:../mikser-io-post-pdf",
|
|
70
|
+
"mikser-io-provider-gdrive": "file:../mikser-io-provider-gdrive",
|
|
69
71
|
"mikser-io-render-eta": "file:../mikser-io-render-eta",
|
|
70
72
|
"mikser-io-render-liquid": "file:../mikser-io-render-liquid",
|
|
71
73
|
"mikser-io-render-markdown": "file:../mikser-io-render-markdown",
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { isLoopback } from './utils.js'
|
|
3
|
+
|
|
4
|
+
// Authentication seam (ADR-0012).
|
|
5
|
+
//
|
|
6
|
+
// The engine says HOW a credential is proven. Plugins say WHAT they gate.
|
|
7
|
+
// Nothing here knows about users, sessions, roles, or OAuth — a verifier
|
|
8
|
+
// that does is an external package (mikser-io-auth), plugged in at the
|
|
9
|
+
// same seam a bare token string plugs into.
|
|
10
|
+
//
|
|
11
|
+
// A verifier is a provider-agnostic descriptor:
|
|
12
|
+
//
|
|
13
|
+
// {
|
|
14
|
+
// name, // for logs: 'bearer', 'oauth', …
|
|
15
|
+
// async verify(req), // → null (no credential presented)
|
|
16
|
+
// // false (presented, rejected)
|
|
17
|
+
// // { subject, capabilities?, scope? }
|
|
18
|
+
// challenge?(req, res), // set WWW-Authenticate before a 401
|
|
19
|
+
// authorizationServers?, resource?, scopesSupported?, // RFC 9728
|
|
20
|
+
// }
|
|
21
|
+
//
|
|
22
|
+
// A principal carries three things, and the engine understands only two:
|
|
23
|
+
//
|
|
24
|
+
// subject who this is — a string, for logs and audit
|
|
25
|
+
// capabilities which verbs they hold, or null for "not verb-scoped"
|
|
26
|
+
// scope which ROWS they may see — an OPAQUE value the engine
|
|
27
|
+
// never inspects. The plugin that issued the credential's
|
|
28
|
+
// surface is the only thing that knows what it means (for
|
|
29
|
+
// api/vector/data it's a sift filter ANDed with the
|
|
30
|
+
// endpoint's own `query`). Keeping it opaque here is what
|
|
31
|
+
// stops the engine from growing an opinion about content.
|
|
32
|
+
//
|
|
33
|
+
// `verify()` is the primitive; requireAuth() is the Express convenience
|
|
34
|
+
// built on it. Both are exported because a surface whose denials aren't
|
|
35
|
+
// HTTP-shaped (MCP answers in JSON-RPC) needs the primitive directly.
|
|
36
|
+
|
|
37
|
+
// The static long-lived token verifier — mikser's default and, for a build
|
|
38
|
+
// tool driven by config files, usually the only one anybody needs.
|
|
39
|
+
export function bearer({ token, name = 'bearer', subject = 'token', capabilities = null, scope = null } = {}) {
|
|
40
|
+
if (!token) throw new Error('bearer({ token }) requires a token')
|
|
41
|
+
const expected = Buffer.from(`Bearer ${token}`, 'utf8')
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
name,
|
|
45
|
+
capabilitiesDeclared: capabilities,
|
|
46
|
+
async verify(req) {
|
|
47
|
+
const header = req.headers?.authorization ?? req.get?.('authorization')
|
|
48
|
+
if (!header) return null
|
|
49
|
+
const presented = Buffer.from(header, 'utf8')
|
|
50
|
+
// timingSafeEqual throws on a length mismatch, so the length
|
|
51
|
+
// check has to come first — it leaks length, which a Bearer
|
|
52
|
+
// header already does structurally.
|
|
53
|
+
const ok = presented.length === expected.length &&
|
|
54
|
+
crypto.timingSafeEqual(presented, expected)
|
|
55
|
+
return ok ? { subject, capabilities, scope } : false
|
|
56
|
+
},
|
|
57
|
+
challenge(req, res) {
|
|
58
|
+
res.set('WWW-Authenticate', 'Bearer')
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Normalize whatever a plugin's `auth:` option holds into a verifier, or
|
|
64
|
+
// null for "nothing configured". Accepts:
|
|
65
|
+
//
|
|
66
|
+
// undefined / null → null
|
|
67
|
+
// 'sekrit' → bearer({ token })
|
|
68
|
+
// { token: 'sekrit' } → bearer({ token }) (the ep.token shape)
|
|
69
|
+
// { verify: async fn } → used as-is (mikser-io-auth, custom verifiers)
|
|
70
|
+
// async (req) => {…} → wrapped as a bare verify function
|
|
71
|
+
//
|
|
72
|
+
// Returning null rather than throwing is deliberate: "no auth configured"
|
|
73
|
+
// is a legitimate, common state for a build tool running on localhost.
|
|
74
|
+
// What it MEANS is the caller's decision — see requireAuth's allowRemote.
|
|
75
|
+
export function resolveAuth(config) {
|
|
76
|
+
if (!config) return null
|
|
77
|
+
// An array is "any of these credentials will do" — several machine
|
|
78
|
+
// tokens, or machine tokens alongside a human's OAuth token. See anyOf().
|
|
79
|
+
if (Array.isArray(config)) {
|
|
80
|
+
const verifiers = config.map(resolveAuth).filter(Boolean)
|
|
81
|
+
if (!verifiers.length) return null
|
|
82
|
+
return verifiers.length === 1 ? verifiers[0] : anyOf(...verifiers)
|
|
83
|
+
}
|
|
84
|
+
if (typeof config === 'string') return bearer({ token: config })
|
|
85
|
+
// An object carrying verify() wins BEFORE the bare-function branch: a
|
|
86
|
+
// verifier may itself be a callable (mikser-io-auth's auth() is both the
|
|
87
|
+
// plugin and the verifier), and treating that as a raw verify function
|
|
88
|
+
// would call the plugin with a request.
|
|
89
|
+
if (typeof config.verify === 'function') return config
|
|
90
|
+
if (typeof config === 'function') return { name: 'custom', verify: config }
|
|
91
|
+
if (config.token) return bearer({ token: config.token })
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// How exposed is a surface? The vocabulary registerRoute() already uses.
|
|
96
|
+
// Computed here so a facade's view of exposure stops being a per-plugin
|
|
97
|
+
// promise (three plugins, three hand-rolled copies — see ADR-0012).
|
|
98
|
+
export function reachabilityOf({ auth, token, allowRemote } = {}) {
|
|
99
|
+
if (auth || token) return 'token'
|
|
100
|
+
return allowRemote ? 'public' : 'loopback'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The uniform rule, in one place.
|
|
104
|
+
//
|
|
105
|
+
// verifier configured:
|
|
106
|
+
// credential valid → allow, from anywhere
|
|
107
|
+
// credential presented, invalid → 401
|
|
108
|
+
// credential absent → 401 (loopback does NOT bypass a
|
|
109
|
+
// configured verifier — same posture
|
|
110
|
+
// as WhiteBox), unless trustLoopback
|
|
111
|
+
// no verifier:
|
|
112
|
+
// loopback, or allowRemote → allow
|
|
113
|
+
// otherwise → 403
|
|
114
|
+
//
|
|
115
|
+
// `trustLoopback: true` restores the older mikser behaviour where a
|
|
116
|
+
// token-gated endpoint stayed open to localhost. It exists so the api and
|
|
117
|
+
// mcp plugins can keep their documented semantics for a plain `token:`
|
|
118
|
+
// while a real verifier gets the stricter default.
|
|
119
|
+
export async function authorize(req, verifier, { allowRemote = false, trustLoopback = false } = {}) {
|
|
120
|
+
const local = isLoopback(req.ip)
|
|
121
|
+
|
|
122
|
+
if (verifier) {
|
|
123
|
+
const result = await verifier.verify(req)
|
|
124
|
+
if (result) return { ok: true, principal: result }
|
|
125
|
+
if (result === false) {
|
|
126
|
+
return { ok: false, status: 401, reason: 'invalid',
|
|
127
|
+
error: 'Invalid credential' }
|
|
128
|
+
}
|
|
129
|
+
// Nothing presented.
|
|
130
|
+
if (trustLoopback && local) return { ok: true, principal: { subject: 'loopback' } }
|
|
131
|
+
return { ok: false, status: 401, reason: 'missing',
|
|
132
|
+
error: 'Authentication required' }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (allowRemote || local) return { ok: true, principal: { subject: 'anonymous' } }
|
|
136
|
+
return {
|
|
137
|
+
ok: false, status: 403, reason: 'reachability',
|
|
138
|
+
error: 'Endpoint accepts loopback connections only — configure a token or set allowRemote: true to enable remote access',
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Express flavour of the same rule.
|
|
143
|
+
export function requireAuth(verifier, options = {}) {
|
|
144
|
+
return async (req, res, next) => {
|
|
145
|
+
let outcome
|
|
146
|
+
try {
|
|
147
|
+
outcome = await authorize(req, verifier, options)
|
|
148
|
+
} catch (err) {
|
|
149
|
+
options.logger?.error?.('auth: verifier threw — %s', err.message)
|
|
150
|
+
return res.status(500).json({ error: 'Authentication failed' })
|
|
151
|
+
}
|
|
152
|
+
if (outcome.ok) {
|
|
153
|
+
req.principal = outcome.principal
|
|
154
|
+
return next()
|
|
155
|
+
}
|
|
156
|
+
if (outcome.status === 401) verifier?.challenge?.(req, res)
|
|
157
|
+
res.status(outcome.status).json({ error: outcome.error })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Accept ANY of several credentials on one surface.
|
|
162
|
+
//
|
|
163
|
+
// This is the shape a real deployment reaches for almost immediately: a
|
|
164
|
+
// handful of long-lived machine tokens (one per caller, so a leaked token
|
|
165
|
+
// scopes the blast radius) plus a human's OAuth token on the same routes.
|
|
166
|
+
// It lives here rather than in userland because the last project that
|
|
167
|
+
// hand-rolled it shipped a silent auth BYPASS — a composed verifier that
|
|
168
|
+
// the resolver didn't recognise degraded to a pass-through `next()` and
|
|
169
|
+
// merely warned. The failure mode of getting this wrong is not "denied",
|
|
170
|
+
// it is "wide open", so it belongs in one tested place.
|
|
171
|
+
//
|
|
172
|
+
// The three-valued merge is the whole subtlety:
|
|
173
|
+
//
|
|
174
|
+
// any verifier accepts → accept (first wins)
|
|
175
|
+
// all say "nothing presented" → null (loopback policy may still apply)
|
|
176
|
+
// any says "presented, bad" → false (NEVER falls back to loopback —
|
|
177
|
+
// a caller who presented a credential has
|
|
178
|
+
// identified itself and must be judged)
|
|
179
|
+
export function anyOf(...verifiers) {
|
|
180
|
+
const list = verifiers.filter(Boolean)
|
|
181
|
+
if (!list.length) return null
|
|
182
|
+
if (list.length === 1) return list[0]
|
|
183
|
+
|
|
184
|
+
// Preserve discovery metadata so an MCP client can still find the
|
|
185
|
+
// authorization server through the composite. First verifier that
|
|
186
|
+
// advertises one wins; a static token has nothing to advertise.
|
|
187
|
+
const discovering = list.find(v => v.authorizationServers?.length)
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
name: `anyOf(${list.map(v => v.name ?? '?').join(',')})`,
|
|
191
|
+
authorizationServers: discovering?.authorizationServers,
|
|
192
|
+
resource: discovering?.resource,
|
|
193
|
+
scopesSupported: [...new Set(list.flatMap(v => v.scopesSupported ?? []))],
|
|
194
|
+
|
|
195
|
+
async verify(req) {
|
|
196
|
+
let rejected = false
|
|
197
|
+
for (const verifier of list) {
|
|
198
|
+
const result = await verifier.verify(req)
|
|
199
|
+
if (result) return result
|
|
200
|
+
if (result === false) rejected = true
|
|
201
|
+
}
|
|
202
|
+
return rejected ? false : null
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
// Challenge with the verifier that can actually be satisfied
|
|
206
|
+
// interactively — pointing a browser at "Bearer" when the real
|
|
207
|
+
// option is OAuth discovery helps nobody.
|
|
208
|
+
challenge(req, res) {
|
|
209
|
+
const chooser = discovering ?? list.find(v => v.challenge)
|
|
210
|
+
chooser?.challenge?.(req, res)
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Does this principal hold a capability?
|
|
216
|
+
//
|
|
217
|
+
// `capabilities: null` means "this credential is not capability-scoped" —
|
|
218
|
+
// a bare static token with nothing declared, which is every endpoint that
|
|
219
|
+
// worked before ADR-0012. Such a credential passes any capability check,
|
|
220
|
+
// because the endpoint's own `operations` list is what bounds it. A
|
|
221
|
+
// credential that DOES declare capabilities is bound by them as well, so
|
|
222
|
+
// a request's reach is the INTERSECTION of the endpoint's ceiling and the
|
|
223
|
+
// principal's grant — never the union.
|
|
224
|
+
export function hasCapability(principal, capability) {
|
|
225
|
+
if (!capability) return true
|
|
226
|
+
const caps = principal?.capabilities
|
|
227
|
+
if (caps == null) return true
|
|
228
|
+
return caps.includes(capability)
|
|
229
|
+
}
|
package/src/catalog.js
CHANGED
|
@@ -319,6 +319,28 @@ onPersist(async () => {
|
|
|
319
319
|
})
|
|
320
320
|
|
|
321
321
|
onFinalize(async () => {
|
|
322
|
+
// Second drain, and the one that closes the cycle.
|
|
323
|
+
//
|
|
324
|
+
// onPersist runs BEFORE render, so the drain above only ever sees what
|
|
325
|
+
// the load and process phases journalled. Everything a render or a
|
|
326
|
+
// postprocess journals lands after it — and onFinalized's clearJournal()
|
|
327
|
+
// then throws those entries away unread. The manifest never had this
|
|
328
|
+
// problem because it drains at onFinalize; the catalog simply had a
|
|
329
|
+
// shorter view of the same journal.
|
|
330
|
+
//
|
|
331
|
+
// The visible symptom was pruning that silently did nothing: a render
|
|
332
|
+
// asking for `catalog: false` journals its DELETE once the render
|
|
333
|
+
// resolves, which is past persist, so the row stayed. gpoint's cms
|
|
334
|
+
// accumulated 1,134 scratch entities carrying 86 MB of render payload,
|
|
335
|
+
// took its public endpoint from 70ms to 8-15s, and blanked the site.
|
|
336
|
+
//
|
|
337
|
+
// Draining again here rather than moving the persist drain: the phases
|
|
338
|
+
// before render still need their mutations committed at persist (the
|
|
339
|
+
// render reads the catalog), and journal consumers are named and
|
|
340
|
+
// independent, so a second pass only ever picks up what the first could
|
|
341
|
+
// not have seen.
|
|
342
|
+
await applyJournalMutations()
|
|
343
|
+
|
|
322
344
|
// Checkpoint the WAL so the main file size stays representative
|
|
323
345
|
// and external tools (mikser --verify on a separate run, debug
|
|
324
346
|
// scripts) see committed state. PASSIVE never blocks readers or
|
package/src/manager.js
CHANGED
|
@@ -4,6 +4,7 @@ import cron from 'node-cron'
|
|
|
4
4
|
import { onProcess, onFinalized } from './lifecycle.js'
|
|
5
5
|
import { useLogger } from './engine.js'
|
|
6
6
|
import { ACTION } from './constants.js'
|
|
7
|
+
import { junkFilter } from './utils.js'
|
|
7
8
|
|
|
8
9
|
const tasks = []
|
|
9
10
|
|
|
@@ -67,7 +68,13 @@ export async function deletedHook(name, context) {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
// Dot-prefixed anything, plus the OS/file-manager litter that is NOT
|
|
72
|
+
// dot-prefixed — Thumbs.db and desktop.ini were measurably being watched.
|
|
73
|
+
// A function rather than a regex because chokidar 4+ dropped glob support in
|
|
74
|
+
// `ignored` and a function is the one form that has stayed stable.
|
|
75
|
+
const ignoreJunk = (filePath) => /[/\\]\./.test(filePath) || junkFilter()(filePath)
|
|
76
|
+
|
|
77
|
+
export function watch(name, folder, options = { interval: 1000, binaryInterval: 3000, ignored: ignoreJunk, ignoreInitial: true }) {
|
|
71
78
|
if (runtime.options.watch !== true) return
|
|
72
79
|
|
|
73
80
|
chokidar.watch(folder, options)
|
package/src/manifest.js
CHANGED
|
@@ -61,8 +61,6 @@ import { filterKey } from './track.js'
|
|
|
61
61
|
import { findById } from './catalog.js'
|
|
62
62
|
import { useDatabase, registerSchema } from './database/index.js'
|
|
63
63
|
|
|
64
|
-
export { inputHashOf } from './utils.js'
|
|
65
|
-
|
|
66
64
|
// Schema registration. Applied at db.open(). PRIMARY KEY (id,
|
|
67
65
|
// destination) — leading id column means `WHERE id = ?` queries use the
|
|
68
66
|
// PK index, no separate id index needed. Parent index is for pagination
|
|
@@ -542,11 +540,27 @@ onFinalize(async () => {
|
|
|
542
540
|
const m = sharedManifest
|
|
543
541
|
|
|
544
542
|
// 2a. Stage file unlinks for deleted entities + their children.
|
|
543
|
+
const deleted = new Set(deletedIds)
|
|
545
544
|
const filesToUnlink = []
|
|
545
|
+
const staged = new Set()
|
|
546
|
+
const stageUnlink = (destination) => {
|
|
547
|
+
if (!destination || staged.has(destination)) return
|
|
548
|
+
staged.add(destination)
|
|
549
|
+
filesToUnlink.push({ destination, reason: 'Entity deleted' })
|
|
550
|
+
}
|
|
546
551
|
for (const id of deletedIds) {
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
552
|
+
for (const row of m._stmtSelectByIdOrParent.all(id, id)) {
|
|
553
|
+
stageUnlink(row.destination)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// Recorded snapshots only describe PRIOR cycles. An entity rendered and
|
|
557
|
+
// deleted within the same cycle has no row to look its destination up in —
|
|
558
|
+
// on a first build there is no row at all — so the file it just wrote would
|
|
559
|
+
// survive both the entity and its snapshot. Take the destination from this
|
|
560
|
+
// cycle's render entries instead.
|
|
561
|
+
for (const { entity } of renderedEntries) {
|
|
562
|
+
if (deleted.has(entity.id) || (entity.parent && deleted.has(entity.parent))) {
|
|
563
|
+
stageUnlink(entity.destination)
|
|
550
564
|
}
|
|
551
565
|
}
|
|
552
566
|
|
|
@@ -581,8 +595,20 @@ onFinalize(async () => {
|
|
|
581
595
|
}
|
|
582
596
|
|
|
583
597
|
// 2e. Hash the rendered output files (async).
|
|
598
|
+
//
|
|
599
|
+
// An id can appear in BOTH drains within one cycle — rendered, then
|
|
600
|
+
// deleted. A render that opts out of the catalog journals its DELETE the
|
|
601
|
+
// moment it resolves, so the RENDER and the DELETE sit in the same
|
|
602
|
+
// journal. 3a below drops the snapshot and 3c would insert it straight
|
|
603
|
+
// back, undoing the delete inside its own transaction. The DELETE wins:
|
|
604
|
+
// the entity is gone, so nothing may go on describing it. Skipping here
|
|
605
|
+
// rather than in 3c also saves hashing an output that was just unlinked.
|
|
606
|
+
//
|
|
607
|
+
// `parent` is checked too, mirroring _stmtDeleteByIdOrParent — a deleted
|
|
608
|
+
// parent takes its paginated children's snapshots with it.
|
|
584
609
|
const recordedSnapshots = []
|
|
585
610
|
for (const { entity, deps } of renderedEntries) {
|
|
611
|
+
if (deleted.has(entity.id) || (entity.parent && deleted.has(entity.parent))) continue
|
|
586
612
|
const outputHash = await hashOutputFile(entity.destination)
|
|
587
613
|
recordedSnapshots.push(buildSnapshot(entity, deps, outputHash))
|
|
588
614
|
}
|
package/src/plugins/api.js
CHANGED
|
@@ -3,6 +3,7 @@ import { access, writeFile, mkdir, rm } from 'node:fs/promises'
|
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import _ from 'lodash'
|
|
5
5
|
import sift from 'sift'
|
|
6
|
+
import { resolveAuth, requireAuth, hasCapability, reachabilityOf } from '../auth.js'
|
|
6
7
|
import { useRenderer } from '../render.js'
|
|
7
8
|
import { mimeForEntity, isLoopback, ExpandError, useCollection } from '../utils.js'
|
|
8
9
|
import { registerRoute } from '../routes.js'
|
|
@@ -447,6 +448,55 @@ export function api(options = {}) {
|
|
|
447
448
|
? ep.query
|
|
448
449
|
: null
|
|
449
450
|
|
|
451
|
+
// Principal-bound scope (ADR-0012). A credential may carry its
|
|
452
|
+
// OWN row filter — "gpoint-web sees only /web" — which is ANDed
|
|
453
|
+
// with the endpoint's, never ORed: a credential can narrow what
|
|
454
|
+
// an endpoint exposes, never widen it. That direction is the
|
|
455
|
+
// whole safety property, so the combination is $and and there is
|
|
456
|
+
// no code path that produces anything else.
|
|
457
|
+
//
|
|
458
|
+
// Both halves stay sift OBJECTS so queryEntities can push them
|
|
459
|
+
// into the WHERE clause. A function scope is applied post-fetch
|
|
460
|
+
// — the form that pinned a box at 111% CPU on 1,367 entities —
|
|
461
|
+
// so a principal scope is REQUIRED to be an object, and a
|
|
462
|
+
// function endpoint scope keeps its post-fetch path while the
|
|
463
|
+
// principal's half still pushes down.
|
|
464
|
+
const scopeFor = (req) => {
|
|
465
|
+
const principal = req.principal?.scope
|
|
466
|
+
if (!principal) return { query, matchesScope, principalScoped: false }
|
|
467
|
+
if (typeof principal !== 'object' || Array.isArray(principal)) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
'api: a principal scope must be a sift filter object — ' +
|
|
470
|
+
'a function cannot be pushed into the query and would be ' +
|
|
471
|
+
'applied to every row the caller matched'
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
// queryEntities takes ONE scope, so the two halves have to
|
|
475
|
+
// become one value of a form it understands:
|
|
476
|
+
//
|
|
477
|
+
// object endpoint scope → { $and: [ … ] }, fully pushed
|
|
478
|
+
// into the WHERE clause. The form to prefer, and the
|
|
479
|
+
// one gpoint uses.
|
|
480
|
+
// function endpoint scope → one combined function, applied
|
|
481
|
+
// post-fetch. No pushdown, but that endpoint already
|
|
482
|
+
// chose post-fetch; the principal's half just narrows it
|
|
483
|
+
// further. Returning the object here instead would
|
|
484
|
+
// SILENTLY DROP the endpoint's own filter.
|
|
485
|
+
const principalMatches = sift(principal)
|
|
486
|
+
const merged = typeof query === 'function'
|
|
487
|
+
? (entity) => query(entity) && principalMatches(entity)
|
|
488
|
+
: query
|
|
489
|
+
? { $and: [query, principal] }
|
|
490
|
+
: principal
|
|
491
|
+
return {
|
|
492
|
+
query: merged,
|
|
493
|
+
matchesScope: matchesScope
|
|
494
|
+
? (entity) => matchesScope(entity) && principalMatches(entity)
|
|
495
|
+
: principalMatches,
|
|
496
|
+
principalScoped: true,
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
450
500
|
// The same scope as a PREDICATE. Two paths hold one entity in
|
|
451
501
|
// hand and have no query to push a filter into — admitting a
|
|
452
502
|
// POST /render body, and the graph-subscription filter — so
|
|
@@ -537,31 +587,39 @@ export function api(options = {}) {
|
|
|
537
587
|
// require the token everywhere, run mikser bound to a
|
|
538
588
|
// non-loopback interface only (or behind a proxy that
|
|
539
589
|
// doesn't forward loopback origin).
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
590
|
+
// The uniform rule now lives in the engine (ADR-0012), so this
|
|
591
|
+
// endpoint, mcp's and forms' cannot drift apart again. `auth`
|
|
592
|
+
// takes any verifier — a list of them, HTTP Basic, OAuth — while
|
|
593
|
+
// `token` keeps the trusted-local-host model it always had.
|
|
594
|
+
const verifier = resolveAuth(ep.auth ?? ep.token)
|
|
595
|
+
const trustLoopback = !ep.auth && !!ep.token
|
|
596
|
+
const auth = requireAuth(verifier, { allowRemote: ep.allowRemote, trustLoopback, logger })
|
|
597
|
+
|
|
598
|
+
// ORDER IS LOAD-BEARING: every route lists `auth` BEFORE
|
|
599
|
+
// `allow(op)`. The capability half reads req.principal, which
|
|
600
|
+
// only exists once auth has run — with the old order it read
|
|
601
|
+
// undefined, and an undefined principal passes every check.
|
|
602
|
+
// That is a silent bypass, not a failure, so it stays written
|
|
603
|
+
// down rather than remembered.
|
|
604
|
+
//
|
|
605
|
+
// Reach is the INTERSECTION of two independent limits: what the
|
|
606
|
+
// endpoint exposes, and what the caller's credential carries. A
|
|
607
|
+
// credential declaring `api:delete` still cannot delete on an
|
|
608
|
+
// endpoint whose `operations` omits it, and a bare token (which
|
|
609
|
+
// declares nothing, capabilities === null) is bounded by the
|
|
610
|
+
// endpoint alone — which is exactly how every endpoint behaved
|
|
611
|
+
// before this existed.
|
|
559
612
|
const allow = (op) => (req, res, next) => {
|
|
560
613
|
if (!allowedOps.has(op)) {
|
|
561
614
|
return res.status(403).json({
|
|
562
615
|
error: `Operation '${op}' is not allowed on endpoint '${name}'`,
|
|
563
616
|
})
|
|
564
617
|
}
|
|
618
|
+
if (!hasCapability(req.principal, `api:${op}`)) {
|
|
619
|
+
return res.status(403).json({
|
|
620
|
+
error: `Your credential does not carry 'api:${op}'`,
|
|
621
|
+
})
|
|
622
|
+
}
|
|
565
623
|
next()
|
|
566
624
|
}
|
|
567
625
|
|
|
@@ -572,7 +630,7 @@ export function api(options = {}) {
|
|
|
572
630
|
// per endpoint so per-endpoint renderTimeout overrides land.
|
|
573
631
|
const { render } = useRenderer(runtime, { defaultTimeout: renderTimeout })
|
|
574
632
|
|
|
575
|
-
router.get('/entities', allow('list'),
|
|
633
|
+
router.get('/entities', auth, allow('list'), async (req, res) => {
|
|
576
634
|
const t0 = Date.now()
|
|
577
635
|
try {
|
|
578
636
|
const parsed = parseQueryString(req.query)
|
|
@@ -599,7 +657,7 @@ export function api(options = {}) {
|
|
|
599
657
|
skip,
|
|
600
658
|
limit,
|
|
601
659
|
expand: parsed.expand,
|
|
602
|
-
scope: query,
|
|
660
|
+
scope: scopeFor(req).query,
|
|
603
661
|
}))
|
|
604
662
|
|
|
605
663
|
const page = Math.floor(skip / limit) + 1
|
|
@@ -618,7 +676,20 @@ export function api(options = {}) {
|
|
|
618
676
|
// nginx's `try_files` sees, no hashing on either
|
|
619
677
|
// side. See the caching docs for the nginx config
|
|
620
678
|
// that wires the failover.
|
|
621
|
-
|
|
679
|
+
// NEVER cache a principal-scoped response. The cache
|
|
680
|
+
// mirrors the request URL into the OUTPUT folder, where
|
|
681
|
+
// nginx try_files serves it without ever reaching this
|
|
682
|
+
// process — so one caller's scoped rows would be handed
|
|
683
|
+
// to every later caller of the same URL, unauthenticated.
|
|
684
|
+
// The cache key is endpoint+querystring by design (it has
|
|
685
|
+
// to match what nginx sees), so it cannot be salted with
|
|
686
|
+
// the principal; the only safe answer is not to write.
|
|
687
|
+
if (cacheEnabled && scopeFor(req).principalScoped) {
|
|
688
|
+
logger.debug(
|
|
689
|
+
'Api[%s] response not cached — caller %j carries its own scope',
|
|
690
|
+
name, req.principal?.subject ?? 'unknown')
|
|
691
|
+
}
|
|
692
|
+
if (cacheEnabled && !scopeFor(req).principalScoped && runtime.options.outputFolder) {
|
|
622
693
|
const url = req.originalUrl || req.url || ''
|
|
623
694
|
const qIdx = url.indexOf('?')
|
|
624
695
|
const rawQueryString = qIdx >= 0 ? url.slice(qIdx + 1) : ''
|
|
@@ -704,7 +775,7 @@ export function api(options = {}) {
|
|
|
704
775
|
// POST /entities/query — body-based query for anything that
|
|
705
776
|
// doesn't fit cleanly in a URL: $and/$or, nested operators,
|
|
706
777
|
// regex, projections, etc. Same shape as a Mongo find.
|
|
707
|
-
router.post('/entities/query', allow('list'),
|
|
778
|
+
router.post('/entities/query', auth, allow('list'), async (req, res) => {
|
|
708
779
|
const t0 = Date.now()
|
|
709
780
|
try {
|
|
710
781
|
const { filter = {}, sort, fields, expand, page: rawPage = 1, limit: rawLimit, skip: rawSkip } = req.body ?? {}
|
|
@@ -716,7 +787,7 @@ export function api(options = {}) {
|
|
|
716
787
|
fields: resolveFields(fields),
|
|
717
788
|
skip, limit,
|
|
718
789
|
expand,
|
|
719
|
-
scope: query,
|
|
790
|
+
scope: scopeFor(req).query,
|
|
720
791
|
})
|
|
721
792
|
|
|
722
793
|
const page = Math.floor(skip / limit) + 1
|
|
@@ -759,7 +830,7 @@ export function api(options = {}) {
|
|
|
759
830
|
// whose onChange writes SSE frames to `res`. subscribe()
|
|
760
831
|
// throws on invalid input; we wrap it so the error becomes a
|
|
761
832
|
// 400 BEFORE SSE headers are sent.
|
|
762
|
-
router.get('/entities/subscribe', allow('subscribe'),
|
|
833
|
+
router.get('/entities/subscribe', auth, allow('subscribe'), (req, res) => {
|
|
763
834
|
let filterFn = null
|
|
764
835
|
let expand = null
|
|
765
836
|
try {
|
|
@@ -782,7 +853,7 @@ export function api(options = {}) {
|
|
|
782
853
|
let sub
|
|
783
854
|
try {
|
|
784
855
|
sub = subscribe({
|
|
785
|
-
scope: query,
|
|
856
|
+
scope: scopeFor(req).query,
|
|
786
857
|
filter: filterFn,
|
|
787
858
|
expand,
|
|
788
859
|
onChange: ({ operation, entity, causedBy }) => {
|
|
@@ -828,7 +899,7 @@ export function api(options = {}) {
|
|
|
828
899
|
)
|
|
829
900
|
})
|
|
830
901
|
|
|
831
|
-
router.put('/entities', allow('update'),
|
|
902
|
+
router.put('/entities', auth, allow('update'), async (req, res) => {
|
|
832
903
|
try {
|
|
833
904
|
const { collection, relativePath, content = '' } = req.body
|
|
834
905
|
await useCollection(runtime, collection).write(relativePath, content)
|
|
@@ -839,7 +910,7 @@ export function api(options = {}) {
|
|
|
839
910
|
}
|
|
840
911
|
})
|
|
841
912
|
|
|
842
|
-
router.delete('/entities', allow('delete'),
|
|
913
|
+
router.delete('/entities', auth, allow('delete'), async (req, res) => {
|
|
843
914
|
try {
|
|
844
915
|
const { collection, relativePath } = req.body
|
|
845
916
|
await useCollection(runtime, collection).remove(relativePath)
|
|
@@ -850,7 +921,7 @@ export function api(options = {}) {
|
|
|
850
921
|
}
|
|
851
922
|
})
|
|
852
923
|
|
|
853
|
-
router.post('/render', allow('render'),
|
|
924
|
+
router.post('/render', auth, allow('render'), async (req, res) => {
|
|
854
925
|
// Hoisted so the catch can name WHICH entity failed. A
|
|
855
926
|
// render that throws before this is assigned is itself the
|
|
856
927
|
// finding — it means the body never parsed.
|
|
@@ -868,7 +939,8 @@ export function api(options = {}) {
|
|
|
868
939
|
renderId = entityShape.id
|
|
869
940
|
// When the endpoint declares a scope, reject anything
|
|
870
941
|
// outside it BEFORE pushing through the renderer.
|
|
871
|
-
|
|
942
|
+
const { matchesScope: inScope } = scopeFor(req)
|
|
943
|
+
if (inScope && !inScope(entityShape)) {
|
|
872
944
|
return res.status(403).json({ error: 'Entity is outside this endpoint\'s scope' })
|
|
873
945
|
}
|
|
874
946
|
const { output, entity } = await render(entityShape, options)
|
package/src/server.js
CHANGED
|
@@ -175,7 +175,7 @@ export function setupServer() {
|
|
|
175
175
|
runtime.options.app.use(express.static(runtime.options.outputFolder))
|
|
176
176
|
|
|
177
177
|
await new Promise(resolve => {
|
|
178
|
-
runtime.options.app.listen(runtime.options.port, () => {
|
|
178
|
+
const httpServer = runtime.options.app.listen(runtime.options.port, () => {
|
|
179
179
|
// Public URL wins for operator-clickable log lines —
|
|
180
180
|
// a reverse-proxy/tunnel/ngrok setup binds locally but
|
|
181
181
|
// is reached externally at runtime.options.url. Fall
|
|
@@ -184,6 +184,36 @@ export function setupServer() {
|
|
|
184
184
|
logger.info('Server listening: %s', externalUrl)
|
|
185
185
|
resolve()
|
|
186
186
|
})
|
|
187
|
+
|
|
188
|
+
// Node caps a single request at 5 minutes (requestTimeout,
|
|
189
|
+
// default 300_000ms), which is not a timeout in the usual
|
|
190
|
+
// sense here — it is an upload size limit expressed in
|
|
191
|
+
// seconds. A large file over a slow link is indistinguishable
|
|
192
|
+
// from a stalled request, so it gets cut off, and the caller
|
|
193
|
+
// sees a truncated write rather than an error it can read.
|
|
194
|
+
//
|
|
195
|
+
// Only reachable from the http.Server, which the engine owns
|
|
196
|
+
// — a plugin that mounts an upload surface (webdav, forms with
|
|
197
|
+
// large attachments) cannot raise it for itself. Hence a
|
|
198
|
+
// config knob here rather than in the plugin.
|
|
199
|
+
//
|
|
200
|
+
// `0` disables the cap entirely. That is a deliberate choice
|
|
201
|
+
// for a trusted-network build server and a bad one facing the
|
|
202
|
+
// internet, where it removes the only bound on how long a
|
|
203
|
+
// client can hold a connection open doing nothing.
|
|
204
|
+
const requestTimeout = runtime.config.server?.requestTimeout
|
|
205
|
+
if (requestTimeout != null) {
|
|
206
|
+
httpServer.requestTimeout = requestTimeout
|
|
207
|
+
// headersTimeout must not exceed requestTimeout, or Node
|
|
208
|
+
// warns and the shorter one silently wins. Keep it at the
|
|
209
|
+
// smaller of its default and the new request timeout.
|
|
210
|
+
if (requestTimeout !== 0) {
|
|
211
|
+
httpServer.headersTimeout = Math.min(httpServer.headersTimeout, requestTimeout)
|
|
212
|
+
}
|
|
213
|
+
logger.info('Server request timeout: %s',
|
|
214
|
+
requestTimeout === 0 ? 'disabled' : `${requestTimeout}ms`)
|
|
215
|
+
}
|
|
216
|
+
runtime.options.httpServer = httpServer
|
|
187
217
|
})
|
|
188
218
|
})
|
|
189
219
|
})
|
package/src/source.js
CHANGED
|
@@ -43,7 +43,7 @@ import { globby } from 'globby'
|
|
|
43
43
|
import pMap from 'p-map'
|
|
44
44
|
import runtime from './runtime.js'
|
|
45
45
|
import { ACTION } from './constants.js'
|
|
46
|
-
import { checksum as fileChecksum } from './utils.js'
|
|
46
|
+
import { checksum as fileChecksum, junkIgnore } from './utils.js'
|
|
47
47
|
import { findById, findEntities, checksumsByCollection } from './catalog.js'
|
|
48
48
|
import { useDatabase } from './database/index.js'
|
|
49
49
|
|
|
@@ -358,7 +358,11 @@ export function useSource(core, options) {
|
|
|
358
358
|
cwd: absFolder,
|
|
359
359
|
absolute: true,
|
|
360
360
|
onlyFiles: true,
|
|
361
|
-
ignore
|
|
361
|
+
// OS and file-manager litter first, so a plugin's own `ignore`
|
|
362
|
+
// adds to it rather than having to restate it. globby's
|
|
363
|
+
// dot: false default already hid the macOS ones; Thumbs.db and
|
|
364
|
+
// desktop.ini are not dotfiles and were being scanned.
|
|
365
|
+
ignore: [...junkIgnore(), ...ignore],
|
|
362
366
|
})
|
|
363
367
|
if (phase === 'import') trackProgress(progressLabel, files.length)
|
|
364
368
|
const scanStats = { emitted: 0, skipped: 0, deleted: 0 }
|
package/src/subscriptions.js
CHANGED
|
@@ -35,6 +35,7 @@ import { onFinalize } from './lifecycle.js'
|
|
|
35
35
|
import { useJournal } from './journal.js'
|
|
36
36
|
import { OPERATION } from './constants.js'
|
|
37
37
|
import { assertExpand, queryEntities } from './catalog.js'
|
|
38
|
+
import sift from 'sift'
|
|
38
39
|
|
|
39
40
|
// Per-module subscription registry. The dispatcher walks this Set
|
|
40
41
|
// every onFinalize. A Set (not a Map) because subscriptions are keyed
|
|
@@ -50,10 +51,34 @@ const opNames = {
|
|
|
50
51
|
[OPERATION.DELETE]: 'delete',
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// A scope may be a predicate OR a sift object, and this is the ONE place that
|
|
55
|
+
// difference is resolved.
|
|
56
|
+
//
|
|
57
|
+
// An endpoint declares its scope once and it then reaches several consumers:
|
|
58
|
+
// queryEntities merges a sift object into the WHERE clause, while a dispatch
|
|
59
|
+
// holding a single entity can only test it. Every consumer that reached for
|
|
60
|
+
// `scope(entity)` therefore worked with the function form and threw
|
|
61
|
+
// `TypeError: scope is not a function` with the object one — which took a
|
|
62
|
+
// production render endpoint down and, because it needed a live subscription to
|
|
63
|
+
// fire at all, reproduced in nothing smaller than production.
|
|
64
|
+
//
|
|
65
|
+
// Compiling here rather than at each call site is the point: a call site that
|
|
66
|
+
// has to remember is a call site that will forget, and the next consumer added
|
|
67
|
+
// inherits the fix instead of the bug.
|
|
68
|
+
function toPredicate(scope) {
|
|
69
|
+
if (scope == null) return null
|
|
70
|
+
if (typeof scope === 'function') return scope
|
|
71
|
+
if (typeof scope === 'object') return sift(scope)
|
|
72
|
+
throw new Error('subscribe: scope must be a function or a sift filter object')
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
export function subscribe({ filter, scope, expand, onChange, signal } = {}) {
|
|
54
76
|
if (typeof onChange !== 'function') {
|
|
55
77
|
throw new Error('subscribe: onChange must be a function')
|
|
56
78
|
}
|
|
79
|
+
// Normalised before it is stored, so both the journal-walk dispatch and the
|
|
80
|
+
// graph dispatch below see a predicate and neither has to care.
|
|
81
|
+
scope = toPredicate(scope)
|
|
57
82
|
|
|
58
83
|
// Reject bad expand at registration. A misconfigured subscriber
|
|
59
84
|
// can otherwise open a session that's expensive to re-dispatch on
|
package/src/utils.js
CHANGED
|
@@ -846,4 +846,111 @@ export function useCollection(runtime, name) {
|
|
|
846
846
|
await unlink(uri)
|
|
847
847
|
},
|
|
848
848
|
}
|
|
849
|
-
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ── operating-system and file-manager litter ────────────────────────────
|
|
852
|
+
//
|
|
853
|
+
// Exposing a source folder over a network filesystem (mikser-io-webdav) or
|
|
854
|
+
// simply opening it in a file manager drops metadata files into it. Every one
|
|
855
|
+
// of them would otherwise become an entity, render a page, and appear in a
|
|
856
|
+
// catalog.
|
|
857
|
+
//
|
|
858
|
+
// The dot-prefixed ones were already handled by accident: globby defaults to
|
|
859
|
+
// `dot: false` and the watcher ignores /[\/\\]\./ — so .DS_Store and ._*
|
|
860
|
+
// never got through. The Windows ones are NOT dotfiles, and measurably did:
|
|
861
|
+
// Thumbs.db and desktop.ini were both scanned AND watched. That asymmetry is
|
|
862
|
+
// the reason this list is explicit rather than a rule about leading dots.
|
|
863
|
+
//
|
|
864
|
+
// Deliberately conservative: OS and file-manager artifacts only. No *.tmp,
|
|
865
|
+
// no *.bak, no editor backups — anything a person might plausibly have meant
|
|
866
|
+
// to write stays out, because a filter that silently drops content is worse
|
|
867
|
+
// than the litter it prevents.
|
|
868
|
+
const JUNK_NAMES = new Set([
|
|
869
|
+
'.DS_Store', // macOS Finder, every folder it opens
|
|
870
|
+
'.localized', // macOS localised folder marker
|
|
871
|
+
'.VolumeIcon.icns',
|
|
872
|
+
'.com.apple.timemachine.donotpresent',
|
|
873
|
+
'Icon\r', // macOS custom folder icon (trailing CR)
|
|
874
|
+
'Thumbs.db', // Windows Explorer thumbnail cache
|
|
875
|
+
'ehthumbs.db',
|
|
876
|
+
'ehthumbs_vista.db',
|
|
877
|
+
'desktop.ini', // Windows folder customisation
|
|
878
|
+
])
|
|
879
|
+
|
|
880
|
+
const JUNK_DIRS = new Set([
|
|
881
|
+
'.Spotlight-V100', '.Trashes', '.fseventsd', '.TemporaryItems',
|
|
882
|
+
'.DocumentRevisions-V100', '.AppleDouble', '.AppleDB', '.AppleDesktop',
|
|
883
|
+
'$RECYCLE.BIN', 'System Volume Information',
|
|
884
|
+
])
|
|
885
|
+
|
|
886
|
+
const JUNK_PATTERNS = [
|
|
887
|
+
/^\._/, // macOS AppleDouble resource fork
|
|
888
|
+
/^~\$/, // Microsoft Office lock/owner file (~$report.docx)
|
|
889
|
+
/^\.~lock\..*#$/, // LibreOffice lock file
|
|
890
|
+
]
|
|
891
|
+
|
|
892
|
+
// Is this path OS/file-manager litter? Matches on the basename, and on any
|
|
893
|
+
// directory segment for the folder-shaped ones — litter inside .Trashes is
|
|
894
|
+
// still litter whatever it is called.
|
|
895
|
+
export function isJunkPath(filePath) {
|
|
896
|
+
if (typeof filePath !== 'string' || !filePath) return false
|
|
897
|
+
const segments = filePath.split(/[/\\]/)
|
|
898
|
+
const name = segments[segments.length - 1]
|
|
899
|
+
if (!name) return false
|
|
900
|
+
if (JUNK_NAMES.has(name)) return true
|
|
901
|
+
if (JUNK_PATTERNS.some(re => re.test(name))) return true
|
|
902
|
+
return segments.slice(0, -1).some(segment => JUNK_DIRS.has(segment))
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// The same list as globby ignore patterns, for the scan side.
|
|
906
|
+
export const JUNK_IGNORE = [
|
|
907
|
+
...[...JUNK_NAMES].map(name => `**/${name}`),
|
|
908
|
+
...[...JUNK_DIRS].map(dir => `**/${dir}/**`),
|
|
909
|
+
'**/._*',
|
|
910
|
+
'**/~$*',
|
|
911
|
+
'**/.~lock.*#',
|
|
912
|
+
]
|
|
913
|
+
|
|
914
|
+
// Plugins contribute their own artifacts.
|
|
915
|
+
//
|
|
916
|
+
// The built-in list is OS and file-manager litter, and it stays that way —
|
|
917
|
+
// the engine has no business knowing what a particular library's sidecar file
|
|
918
|
+
// is called. What it can provide is the mechanism: a plugin that writes
|
|
919
|
+
// metadata next to content says so, and both the scan and the watcher honour
|
|
920
|
+
// it. (mikser-io-webdav registers `*.nephelemeta` for exactly this reason:
|
|
921
|
+
// the collection-level file is dot-prefixed and was already invisible, while
|
|
922
|
+
// the per-file one — `page.md.nephelemeta` — is not, and was measurably
|
|
923
|
+
// becoming an entity.)
|
|
924
|
+
const registered = { ignore: [], match: [] }
|
|
925
|
+
|
|
926
|
+
export function registerJunk({ ignore = [], match } = {}) {
|
|
927
|
+
registered.ignore.push(...(Array.isArray(ignore) ? ignore : [ignore]))
|
|
928
|
+
for (const m of (Array.isArray(match) ? match : match ? [match] : [])) {
|
|
929
|
+
if (m instanceof RegExp) registered.match.push((name) => m.test(name))
|
|
930
|
+
else if (typeof m === 'function') registered.match.push(m)
|
|
931
|
+
else throw new Error('registerJunk: `match` must be a RegExp or a function')
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// `junk: false` in config turns the filter off entirely; an array replaces
|
|
936
|
+
// the built-in list. Plugin registrations survive an array override — an
|
|
937
|
+
// operator narrowing the OS list did not ask to start importing a library's
|
|
938
|
+
// sidecar files.
|
|
939
|
+
export function junkIgnore() {
|
|
940
|
+
const configured = runtime.config?.junk
|
|
941
|
+
if (configured === false) return []
|
|
942
|
+
if (Array.isArray(configured)) return [...configured, ...registered.ignore]
|
|
943
|
+
return [...JUNK_IGNORE, ...registered.ignore]
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
export function junkFilter() {
|
|
947
|
+
const configured = runtime.config?.junk
|
|
948
|
+
if (configured === false) return () => false
|
|
949
|
+
if (!registered.match.length) return isJunkPath
|
|
950
|
+
return (filePath) => {
|
|
951
|
+
if (isJunkPath(filePath)) return true
|
|
952
|
+
if (typeof filePath !== 'string') return false
|
|
953
|
+
const name = filePath.split(/[/\\]/).pop()
|
|
954
|
+
return registered.match.some(test => test(name))
|
|
955
|
+
}
|
|
956
|
+
}
|