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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "9.0.5",
3
+ "version": "9.2.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": {
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
- recordQuery(filter)
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). scope is a JS predicate applied
580
- // post-fetch.
581
- let all = await findEntities(filter)
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
 
@@ -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
- const query = typeof ep.query === 'function' ? ep.query : null
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
- // Explicit opt-out: prune the catalog row so it doesn't
443
- // accumulate. The rendered output file stays on disk the
444
- // bytes are the work product. We deliberately bypass the
445
- // journal/DELETE path here (which would also unlink the file
446
- // via the manifest module's cleanup) and splice the entity
447
- // out of the in-memory catalog directly. Strict equality so
448
- // ambiguous inputs (null, "false", 0) fall through to the
449
- // default of keeping the row.
450
- const entities = runtime.catalog?.data?.entities
451
- if (entities) {
452
- const idx = entities.findIndex(e => e.id === result.entity.id)
453
- if (idx >= 0) entities.splice(idx, 1)
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