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.
@@ -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.
@@ -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.0",
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
- export function watch(name, folder, options = { interval: 1000, binaryInterval: 3000, ignored: /[\/\\]\./, ignoreInitial: true }) {
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 rows = m._stmtSelectByIdOrParent.all(id, id)
548
- for (const row of rows) {
549
- filesToUnlink.push({ destination: row.destination, reason: 'Entity deleted' })
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
  }
@@ -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
- const expectedAuth = ep.token ? `Bearer ${ep.token}` : null
541
- const auth = (req, res, next) => {
542
- const presented = req.headers.authorization
543
- if (expectedAuth && presented && presented !== expectedAuth) {
544
- return res.status(401).json({ error: 'Unauthorized' })
545
- }
546
- if (presented === expectedAuth && expectedAuth) {
547
- return next() // valid token from anywhere
548
- }
549
- if (ep.allowRemote || isLoopback(req.ip)) {
550
- return next()
551
- }
552
- res.status(403).json({
553
- error: expectedAuth
554
- ? 'Token required from non-loopback sources'
555
- : 'Endpoint accepts loopback connections only configure a token or set allowRemote: true to enable remote access',
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'), auth, async (req, res) => {
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
- if (cacheEnabled && runtime.options.outputFolder) {
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'), auth, async (req, res) => {
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'), auth, (req, res) => {
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'), auth, async (req, res) => {
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'), auth, async (req, res) => {
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'), auth, async (req, res) => {
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
- if (matchesScope && !matchesScope(entityShape)) {
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 }
@@ -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
+ }