mikser-io 9.0.5 → 9.2.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/plugins.md +13 -0
- package/package.json +1 -1
- package/src/catalog.js +24 -5
- package/src/plugins/api.js +26 -2
- package/src/render.js +22 -13
package/docs/plugins.md
CHANGED
|
@@ -681,6 +681,7 @@ api: {
|
|
|
681
681
|
base: '/api', // mount prefix; default '/api'
|
|
682
682
|
pageSize: 10, // global default; per-endpoint override below
|
|
683
683
|
renderTimeout: 30_000, // global default; per-endpoint override below
|
|
684
|
+
bodyLimit: '2mb', // global default; per-endpoint override below
|
|
684
685
|
endpoints: {
|
|
685
686
|
public: {
|
|
686
687
|
// No token → publicly readable. The query function scopes what
|
|
@@ -706,11 +707,23 @@ api: {
|
|
|
706
707
|
token: process.env.API_RENDER_TOKEN,
|
|
707
708
|
operations: ['render'],
|
|
708
709
|
renderTimeout: 60_000,
|
|
710
|
+
bodyLimit: '8mb', // override the global bodyLimit
|
|
709
711
|
},
|
|
710
712
|
},
|
|
711
713
|
}
|
|
712
714
|
```
|
|
713
715
|
|
|
716
|
+
**`bodyLimit`** is worth setting deliberately on a `render` endpoint. The request
|
|
717
|
+
body carries the whole entity, so its size is the size of everything the layout
|
|
718
|
+
needs — a mail template handed a customer's recent history is easily several
|
|
719
|
+
hundred kilobytes, which is not large but is well past Express's 100kb default.
|
|
720
|
+
|
|
721
|
+
The failure mode argues for getting this right rather than discovering it: the
|
|
722
|
+
body is rejected while the stream is being read, so the render never runs, the
|
|
723
|
+
rejection is not logged as a render error, and the caller receives an HTML error
|
|
724
|
+
page from a JSON API. It reads as "the thing I was rendering for is broken",
|
|
725
|
+
not as "the request was too big".
|
|
726
|
+
|
|
714
727
|
**Routes per endpoint:**
|
|
715
728
|
|
|
716
729
|
| Method | Path | Operation | Description |
|
package/package.json
CHANGED
package/src/catalog.js
CHANGED
|
@@ -567,19 +567,38 @@ async function expandAndProject(entity, expand) {
|
|
|
567
567
|
return { ...result, meta: projectMeta(result.meta) }
|
|
568
568
|
}
|
|
569
569
|
|
|
570
|
+
// Combine a caller's filter with an endpoint scope. A sift-shaped scope
|
|
571
|
+
// becomes part of the query so sift-to-sql can push it down; a function scope
|
|
572
|
+
// returns the filter untouched and is applied by the caller after the fetch.
|
|
573
|
+
export function scopedFilter(filter, scope) {
|
|
574
|
+
if (!scope || typeof scope === 'function') return filter
|
|
575
|
+
const hasFilter = filter && Object.keys(filter).length > 0
|
|
576
|
+
return hasFilter ? { $and: [filter, scope] } : scope
|
|
577
|
+
}
|
|
578
|
+
|
|
570
579
|
export async function queryEntities({
|
|
571
580
|
filter, sort, fields, skip, limit, expand, scope,
|
|
572
581
|
} = {}) {
|
|
573
582
|
const effectiveLimit = Math.min(100, Math.max(1, limit ?? 25))
|
|
574
583
|
const effectiveSkip = Math.max(0, skip ?? 0)
|
|
575
584
|
|
|
576
|
-
|
|
585
|
+
// A sift-shaped `scope` is merged into the filter so it reaches
|
|
586
|
+
// findEntities, where sift-to-sql pushes what it can into the WHERE
|
|
587
|
+
// clause. A function `scope` cannot be translated and stays a post-fetch
|
|
588
|
+
// predicate — which means the query materializes every row the caller's
|
|
589
|
+
// own filter matched, including the ones the endpoint would then reject.
|
|
590
|
+
// For an endpoint whose filter is broad (or absent) that is the entire
|
|
591
|
+
// catalog, per request, and `limit` does not help: it is applied after
|
|
592
|
+
// this. Prefer the object form.
|
|
593
|
+
const scopePredicate = typeof scope === 'function' ? scope : null
|
|
594
|
+
const effectiveFilter = scopedFilter(filter, scope)
|
|
595
|
+
|
|
596
|
+
recordQuery(effectiveFilter)
|
|
577
597
|
|
|
578
598
|
// Materialize via findEntities (which handles sqlite + shim
|
|
579
|
-
// dispatch + js fallback).
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (scope) all = all.filter(scope)
|
|
599
|
+
// dispatch + js fallback).
|
|
600
|
+
let all = await findEntities(effectiveFilter)
|
|
601
|
+
if (scopePredicate) all = all.filter(scopePredicate)
|
|
583
602
|
|
|
584
603
|
const total = all.length
|
|
585
604
|
|
package/src/plugins/api.js
CHANGED
|
@@ -359,6 +359,23 @@ export function api(options = {}) {
|
|
|
359
359
|
const base = apiBase // alias so existing local references still work
|
|
360
360
|
const globalPageSize = options.pageSize ?? 10
|
|
361
361
|
const globalRenderTimeout = options.renderTimeout ?? 30_000
|
|
362
|
+
// express.json() defaults to 100kb, which is a sensible ceiling for a
|
|
363
|
+
// REST body and much too small for `render`: the caller posts an entity
|
|
364
|
+
// whose meta carries everything the layout needs, and a mail template
|
|
365
|
+
// given a list of a customer's recent bookings runs 150–400kb. That
|
|
366
|
+
// exceeds the default without being remotely large.
|
|
367
|
+
//
|
|
368
|
+
// The failure is quiet at the wrong end. The renderer never sees the
|
|
369
|
+
// request — raw-body rejects it while reading the stream — so nothing
|
|
370
|
+
// appears in mikser's log, and the caller gets an HTML error page for a
|
|
371
|
+
// JSON API. On gpoint that was 168 renders lost in under two hours,
|
|
372
|
+
// including the customer booking confirmations, and it looked from the
|
|
373
|
+
// outside like the mail was broken rather than the render.
|
|
374
|
+
//
|
|
375
|
+
// 2mb, and configurable per endpoint like every other limit here. Not
|
|
376
|
+
// unbounded: a token-gated endpoint is still a body an attacker can
|
|
377
|
+
// choose the size of.
|
|
378
|
+
const globalBodyLimit = options.bodyLimit ?? '2mb'
|
|
362
379
|
|
|
363
380
|
// Preview workflow (render → cache → URL) lives in its own
|
|
364
381
|
// plugin (src/plugins/preview.js) as of v7.3.0. The api plugin
|
|
@@ -377,7 +394,7 @@ export function api(options = {}) {
|
|
|
377
394
|
// api.endpoints.admin { token: '...', operations: ['list','update','delete','render'] }
|
|
378
395
|
for (const [name, ep] of Object.entries(endpoints)) {
|
|
379
396
|
const router = express.Router()
|
|
380
|
-
router.use(express.json())
|
|
397
|
+
router.use(express.json({ limit: ep.bodyLimit ?? globalBodyLimit }))
|
|
381
398
|
|
|
382
399
|
// Operations default to the safer shape when no token is set
|
|
383
400
|
// (read-only) and full access when token-gated. `subscribe`
|
|
@@ -390,7 +407,14 @@ export function api(options = {}) {
|
|
|
390
407
|
: ['list']
|
|
391
408
|
const allowedOps = new Set(ep.operations ?? defaultOps)
|
|
392
409
|
|
|
393
|
-
|
|
410
|
+
// The endpoint's scope. A sift filter is the form to prefer —
|
|
411
|
+
// queryEntities merges it into the WHERE clause, so the endpoint
|
|
412
|
+
// never materializes rows it would only reject. A function still
|
|
413
|
+
// works and is applied post-fetch, which costs every row the
|
|
414
|
+
// caller's filter matched.
|
|
415
|
+
const query = (typeof ep.query === 'function' || (ep.query && typeof ep.query === 'object'))
|
|
416
|
+
? ep.query
|
|
417
|
+
: null
|
|
394
418
|
const pageSize = ep.pageSize ?? globalPageSize
|
|
395
419
|
const renderTimeout = ep.renderTimeout ?? globalRenderTimeout
|
|
396
420
|
|
package/src/render.js
CHANGED
|
@@ -376,7 +376,10 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
|
|
|
376
376
|
* - `catalog: true` (default) — keep the entity in the catalog after
|
|
377
377
|
* the render. Pass `catalog: false` to prune the catalog row;
|
|
378
378
|
* useful for on-demand renders where the metadata row would just
|
|
379
|
-
* accumulate.
|
|
379
|
+
* accumulate. Requires `save: false` — the prune goes through the
|
|
380
|
+
* journal, and a DELETE takes the manifest's file cleanup with it,
|
|
381
|
+
* so it is only safe for a render that wrote nothing. With
|
|
382
|
+
* `save: true` the row is kept and a warning is logged.
|
|
380
383
|
* - `save: true` (default) — write the rendered output to disk at
|
|
381
384
|
* `<outputFolder>/<entity.destination>`. Pass `save: false` to
|
|
382
385
|
* skip the final disk write; the bytes still come back via
|
|
@@ -439,18 +442,24 @@ export function useRenderer(runtime, { defaultTimeout = 30_000 } = {}) {
|
|
|
439
442
|
})
|
|
440
443
|
|
|
441
444
|
if (catalog === false) {
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
445
|
+
// Prune the row through the journal, so the DELETE lands in
|
|
446
|
+
// sqlite at onPersist alongside the CREATE that put it there.
|
|
447
|
+
// Strict equality — null / "false" / 0 keep the row.
|
|
448
|
+
//
|
|
449
|
+
// Only when `save` is also false. A DELETE carries the
|
|
450
|
+
// manifest's file cleanup with it, which unlinks the render's
|
|
451
|
+
// output; that is correct for an entity that produced no file
|
|
452
|
+
// and wrong for one that did. `catalog: false, save: true`
|
|
453
|
+
// therefore keeps its row, and says so rather than dropping
|
|
454
|
+
// the output on the floor.
|
|
455
|
+
if (save === false) {
|
|
456
|
+
await runtime.delete(result.entity)
|
|
457
|
+
} else {
|
|
458
|
+
useLogger()?.warn(
|
|
459
|
+
'render: catalog:false ignored for %s — it needs save:false, ' +
|
|
460
|
+
'because pruning the row also unlinks the rendered output',
|
|
461
|
+
result.entity.id,
|
|
462
|
+
)
|
|
454
463
|
}
|
|
455
464
|
}
|
|
456
465
|
|