From f55e383ebc919c6a218c83b314e7cd4e852b9263 Mon Sep 17 00:00:00 2001 From: yannickmonney Date: Fri, 9 Oct 2026 11:04:18 +0200 Subject: [PATCH] fix(platform): bound transform analysis separately from expressions --- docs/de/platform/automations/concepts.md | 2 +- docs/en/platform/automations/concepts.md | 2 +- docs/fr/platform/automations/concepts.md | 2 +- .../lib/engine/core/syntax/limits.test.ts | 70 +++++++++++++++++-- .../platform/lib/engine/core/syntax/parse.ts | 19 +++-- .../reference/automation/automations.md | 2 +- .../src/lib/config/releases/native.test.ts | 36 +++++++++- 7 files changed, 118 insertions(+), 15 deletions(-) diff --git a/docs/de/platform/automations/concepts.md b/docs/de/platform/automations/concepts.md index 59bf6a8519..dee499a35a 100644 --- a/docs/de/platform/automations/concepts.md +++ b/docs/de/platform/automations/concepts.md @@ -84,7 +84,7 @@ Ein Werkzeug ohne Ausgabeschema liefert unstrukturierte Ausgabe. Soll daraus str Tale prüft das ganze Dokument, wenn du es speicherst, wenn du eine Version bereitstellst und wann immer ein Client `validate_automation` aufruft. Ein **Fehler** beschreibt etwas, das sicher scheitert, oder Code, der die Analysegrenzen überschreitet. Er verhindert Speichern wie Bereitstellen. Eine **Warnung** zeigt auf etwas, das scheitern kann oder nichts Nützliches tut. Sie verhindert weder Speichern noch Bereitstellen; du entscheidest selbst, ob du etwas änderst. Jedes Problem nennt seine Node und sein Feld und, in einem Template, einer Bedingung oder in Code, den genauen Ausdruck. -Damit die Prüfung zügig bleibt, gelten für jeden Ausdruck und jeden `transform`-Code Grenzen von 8192 UTF-16-Codeeinheiten, 512 JavaScript-Tokens und 64 Verschachtelungsebenen in der Syntax oder im Syntaxbaum. Leerraum um einen Template-Ausdruck zählt nicht zu seiner Größe; Leerraum im Transform-Code zählt mit. Reiner Text außerhalb von Templates ist kein Code. Diese Grenzen können zuvor gültigen Code ablehnen. Kürze ihn oder verteile die Arbeit auf mehrere Nodes, bevor du erneut speicherst oder bereitstellst. +Damit die Prüfung zügig bleibt, ist jeder Ausdruck auf 8192 UTF-16-Codeeinheiten und 512 JavaScript-Tokens begrenzt. Für `transform`-Code gelten 16384 Codeeinheiten und 4096 Tokens. Beide dürfen höchstens 64 Verschachtelungsebenen in der Syntax oder im Syntaxbaum enthalten. Leerraum um einen Template-Ausdruck zählt nicht zu seiner Größe; Leerraum im Transform-Code zählt mit. Reiner Text außerhalb von Templates ist kein Code. Diese Grenzen können zuvor gültigen Code ablehnen. Kürze ihn oder verteile die Arbeit auf mehrere Nodes, bevor du erneut speicherst oder bereitstellst. ### Referenzen und Namen {#checks-references} diff --git a/docs/en/platform/automations/concepts.md b/docs/en/platform/automations/concepts.md index 78e9b2ca97..62d03049ec 100644 --- a/docs/en/platform/automations/concepts.md +++ b/docs/en/platform/automations/concepts.md @@ -84,7 +84,7 @@ A tool without an output schema is unstructured. To turn its text into structure Tale checks the whole document when you save it, when you deploy a version, and whenever a client calls `validate_automation`. An **error** describes a definite failure or code that exceeds the analysis limits, and it stops both saving and deploying. A **warning** points at something that can fail or does no useful work; it never stops a save or a deployment, so you decide whether to act on it. Each problem names its node and field and, inside a template, a condition, or code, the exact expression. -To keep checks responsive, each expression and each `transform` body is limited to 8192 UTF-16 code units, 512 JavaScript tokens and 64 levels of syntax or syntax-tree nesting. Whitespace around a template expression does not count toward its size; whitespace in a transform body does. Plain text outside templates is not code. These limits can reject previously valid code: shorten it or split the work across nodes before saving or deploying again. +To keep checks responsive, each expression is limited to 8192 UTF-16 code units and 512 JavaScript tokens. A `transform` body may contain up to 16384 code units and 4096 tokens. Both retain a limit of 64 levels of syntax or syntax-tree nesting. Whitespace around a template expression does not count toward its size; whitespace in a transform body does. Plain text outside templates is not code. These limits can reject previously valid code: shorten it or split the work across nodes before saving or deploying again. ### References and names {#checks-references} diff --git a/docs/fr/platform/automations/concepts.md b/docs/fr/platform/automations/concepts.md index ea9de983b1..5e0d0a6802 100644 --- a/docs/fr/platform/automations/concepts.md +++ b/docs/fr/platform/automations/concepts.md @@ -84,7 +84,7 @@ Un outil sans schéma de sortie produit une sortie non structurée. Pour transfo Tale vérifie le document entier quand tu l’enregistres, quand tu déploies une version et chaque fois qu’un client appelle `validate_automation`. Une **erreur** décrit un échec certain ou du code qui dépasse les limites d’analyse : elle empêche d’enregistrer comme de déployer. Un **avertissement** signale ce qui peut échouer ou ne sert à rien. Il n’empêche jamais d’enregistrer ni de déployer, c’est donc toi qui décides d’agir. Chaque problème nomme son nœud et son champ et, dans un template, une condition ou du code, l’expression exacte. -Pour que les vérifications restent réactives, chaque expression et chaque corps `transform` sont limités à 8192 unités de code UTF-16, 512 tokens JavaScript et 64 niveaux d’imbrication dans la syntaxe ou l’arbre syntaxique. Les espaces autour d’une expression de template ne comptent pas dans sa taille ; ceux du corps `transform` comptent. Le texte ordinaire hors des templates n’est pas du code. Ces limites peuvent refuser du code auparavant valide. Raccourcis-le ou répartis le travail entre plusieurs nœuds avant d’enregistrer ou de déployer à nouveau. +Pour que les vérifications restent réactives, chaque expression est limitée à 8192 unités de code UTF-16 et 512 tokens JavaScript. Un corps `transform` peut contenir jusqu’à 16384 unités de code et 4096 tokens. Dans les deux cas, la limite reste de 64 niveaux d’imbrication dans la syntaxe ou l’arbre syntaxique. Les espaces autour d’une expression de template ne comptent pas dans sa taille ; ceux du corps `transform` comptent. Le texte ordinaire hors des templates n’est pas du code. Ces limites peuvent refuser du code auparavant valide. Raccourcis-le ou répartis le travail entre plusieurs nœuds avant d’enregistrer ou de déployer à nouveau. ### Références et noms {#checks-references} diff --git a/services/platform/lib/engine/core/syntax/limits.test.ts b/services/platform/lib/engine/core/syntax/limits.test.ts index 939336a186..940764b188 100644 --- a/services/platform/lib/engine/core/syntax/limits.test.ts +++ b/services/platform/lib/engine/core/syntax/limits.test.ts @@ -1,11 +1,14 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { nodeVmRunner } from '../../runners/node-vm'; +import { createSandboxExecRunner } from '../../runners/sandbox-exec'; import { setCodeRunner } from '../runner'; import { evalCondition, evalTemplates, runCode } from '../template'; import type { Automation } from '../types'; import { validate } from '../validate'; import { + MAX_BODY_PARSE_TOKENS, + MAX_BODY_SOURCE_SIZE, MAX_PARSE_DEPTH, MAX_PARSE_TOKENS, MAX_SOURCE_SIZE, @@ -30,9 +33,6 @@ describe('parser analysis boundaries', () => { 'accepts %i source code units', (size) => { expect(expression(literal(size))).toMatchObject({ ok: true }); - expect(parseBody(`return ${literal(size - 8)};`)).toMatchObject({ - ok: true, - }); }, ); @@ -41,7 +41,9 @@ describe('parser analysis boundaries', () => { ok: false, limited: true, }); - expect(parseBody(`return ${literal(MAX_SOURCE_SIZE - 7)};`)).toMatchObject({ + expect( + parseBody(`return ${literal(MAX_BODY_SOURCE_SIZE - 7)};`), + ).toMatchObject({ ok: false, limited: true, }); @@ -54,7 +56,7 @@ describe('parser analysis boundaries', () => { ).toMatchObject({ ok: true }); expect( parseBody( - `return ${'!'.repeat(extra)}${array((MAX_PARSE_TOKENS - 4) / 2)};`, + `return ${'!'.repeat(extra)}${array((MAX_BODY_PARSE_TOKENS - 4) / 2)};`, ), ).toMatchObject({ ok: true }); }); @@ -65,10 +67,31 @@ describe('parser analysis boundaries', () => { limited: true, }); expect( - parseBody(`return !!${array((MAX_PARSE_TOKENS - 4) / 2)};`), + parseBody(`return !!${array((MAX_BODY_PARSE_TOKENS - 4) / 2)};`), ).toMatchObject({ ok: false, limited: true }); }); + it.each([MAX_BODY_SOURCE_SIZE - 1, MAX_BODY_SOURCE_SIZE])( + 'accepts %i transform code units without enlarging expressions', + (size) => { + expect(parseBody(`return ${literal(size - 8)};`)).toMatchObject({ + ok: true, + }); + expect(expression(literal(size))).toMatchObject({ + ok: false, + limited: true, + }); + }, + ); + + it('keeps malformed code invalid inside the larger body budget', () => { + const prefix = Array(300).fill('void 0;').join('\n'); + expect(parseBody(`${prefix}\nreturn (;`)).toMatchObject({ + ok: false, + message: expect.not.stringContaining('limit'), + }); + }); + it.each([MAX_PARSE_DEPTH - 1, MAX_PARSE_DEPTH])( 'accepts %i delimiters with a shallow AST', (depth) => { @@ -106,6 +129,41 @@ describe('parser analysis boundaries', () => { }); describe('consumer parity at the admitted limits', () => { + it('admits forty bounded bodies without executing them and retains the node cap', async () => { + let executions = 0; + setCodeRunner( + createSandboxExecRunner(async () => { + executions++; + throw new Error('validation cannot execute a body'); + }), + ); + // 4096 tokens and 16384 code units per body, with a shallow, wide AST. + const body = `return !${array((MAX_BODY_PARSE_TOKENS - 4) / 2)};`; + const code = `/*${'x'.repeat(MAX_BODY_SOURCE_SIZE - body.length - 4)}*/${body}`; + const nodes = Array.from({ length: 40 }, (_, index) => ({ + id: `step${index}`, + type: 'transform', + code, + })); + const document = { + version: 1, + name: 'bounded-bodies', + nodes, + output: nodes.map((node) => `{{ nodes.${node.id}.output }}`), + }; + const admitted = await validate(document); + expect(admitted.errors).toEqual([]); + expect(admitted.warnings).toEqual([]); + const refused = await validate({ + ...document, + nodes: [...nodes, { id: 'step40', type: 'transform', code }], + }); + expect(refused.errors).toEqual([ + expect.objectContaining({ code: 'NODES_TOO_MANY' }), + ]); + expect(executions).toBe(0); + }); + it.each([MAX_SOURCE_SIZE - 2, MAX_SOURCE_SIZE - 1, MAX_SOURCE_SIZE])( 'keeps a quoted closer inside a %i-unit expression through runtime evaluation', async (size) => { diff --git a/services/platform/lib/engine/core/syntax/parse.ts b/services/platform/lib/engine/core/syntax/parse.ts index 10668d662f..791f02f994 100644 --- a/services/platform/lib/engine/core/syntax/parse.ts +++ b/services/platform/lib/engine/core/syntax/parse.ts @@ -57,6 +57,10 @@ const OPTIONS: Options = { export const MAX_SOURCE_SIZE = 8_192; export const MAX_PARSE_DEPTH = 64; export const MAX_PARSE_TOKENS = 512; +/** Transform bodies hold statements as well as expressions. Their separate + * bounded capacity does not enlarge templates, conditions or either depth cap. */ +export const MAX_BODY_SOURCE_SIZE = 16_384; +export const MAX_BODY_PARSE_TOKENS = 4_096; export const PARSE_LIMIT_MESSAGE = 'code exceeds the analysis size or depth limit'; @@ -69,14 +73,21 @@ function limit(start: number, end: number): ParseResult { }; } -function budget(text: string, start: number, end: number): ParseResult | null { - if (end - start > MAX_SOURCE_SIZE) return limit(start, end); +function budget( + text: string, + start: number, + end: number, + kind: 'expression' | 'body' = 'expression', +): ParseResult | null { + const sourceLimit = kind === 'body' ? MAX_BODY_SOURCE_SIZE : MAX_SOURCE_SIZE; + const tokenLimit = kind === 'body' ? MAX_BODY_PARSE_TOKENS : MAX_PARSE_TOKENS; + if (end - start > sourceLimit) return limit(start, end); let depth = 0; let count = 0; try { for (const token of tokenizer(text.slice(start, end), OPTIONS)) { const label = token.type.label; - if (++count > MAX_PARSE_TOKENS) return limit(start + token.start, end); + if (++count > tokenLimit) return limit(start + token.start, end); if (['(', '[', '{', '${'].includes(label)) { if (++depth > MAX_PARSE_DEPTH) return limit(start + token.start, end); } else if ([')', ']', '}'].includes(label)) @@ -320,7 +331,7 @@ export function parseExpressionIn( /** Parse a transform body the way the runner compiles it: a synchronous * function body, so `return` is allowed at its top level. */ export function parseBody(code: string): ParseResult { - const limited = budget(code, 0, code.length); + const limited = budget(code, 0, code.length, 'body'); if (limited !== null) return limited; try { const ast = parse(code, { ...OPTIONS, allowReturnOutsideFunction: true }); diff --git a/services/platform/tests/manual/reference/automation/automations.md b/services/platform/tests/manual/reference/automation/automations.md index e133a30a2e..0289639003 100644 --- a/services/platform/tests/manual/reference/automation/automations.md +++ b/services/platform/tests/manual/reference/automation/automations.md @@ -12,7 +12,7 @@ Rows for [`automations`](../../suites/automations.md) boxes that moved out of th | [automations](../../suites/automations.md) / [tasks](../../suites/tasks.md) | A hung agent ends: runnerd ends an exec that printed nothing and whose processes (its `/proc` tree plus the inner engine's containers) used under 1% of one CPU for `TALE_EXEC_STALL_MS` (the spawner's `SANDBOX_EXEC_STALL_MINUTES`, 45 by default, `0` off) through the cancel path and marks its exit `EXEC_STALLED`; the spawner reports a failed `EXEC_STALLED` result on the exec and attach streams, even after a clean exit; a task run settles `turn_stalled` with its localized "stopped responding" notice, no automatic retry and no fresh relaunch of a resume, and an automation turn settles `turn_stalled`, never re-kicked and reported as `turn_crashed` | ✅ unit (fake process table and clock) + real process | `services/sandbox-runtime/daemon/src/exec-stall.test.ts`, `services/sandbox-runtime/daemon/src/exec-manager.stall.test.ts`, `services/sandbox/src/session/runnerd-protocol.test.ts`, `services/sandbox/src/session/session-routes.test.ts`, `services/sandbox/src/config.test.ts`, `backend/core/chat/external_turn_shared.sandbox_ended.test.ts`, `backend/core/tasks/agent_run_host.sandbox_ended.test.ts`, `backend/core/automations/agent_host.sandbox_ended.test.ts`, `lib/shared/task-run-failure.test.ts`; a real hang in a live session stays a deployment observation | | [automations](../../suites/automations.md) | A Gmail triage digest carries subject, sender, time and snippet per row: the fan-out behind `conversation.list_mailbox_messages` lists bare ids (`users.messages.list`) and fetches each one's metadata with the credential that listed it, Outlook and IMAP read their envelopes off the listing alone, a message gone between the list and the fetch is skipped, and every fetch failing fails that mailbox instead of reading as an empty inbox | ✅ unit | `backend/core/conversations/sync_mailbox.test.ts` (`listMailboxMessages`) | | [automations](../../suites/automations.md) | The Editor's Problems surface: the author-gated draft check (`POST /api/app/automations/:name/validate` answers members 403 and writes nothing) and refusals that carry their errors and warnings under `data`; the check waits for a pause in the edits, withdraws an older request, keeps the last list while the next runs and fails without a toast; issues get stable ids, document order, per-node counts, crumbs and a go-to target; node boxes count their problems and say them after their name; a field's problem describes its control and never alerts; a node's problems without a field of their own list at the top of its fields; go to focuses the field with the offending text selected, under the canvas on a desktop and through the sheets on a tablet; the dock and the inspector pass axe's contrast check in light and dark; Save waits with its reason (a visible line on a phone); a save or deploy refused for errors lands in Problems on its first error, without a toast, on every problem whatever the filter was left on, and is said once (the deploy alert names it, the announcer counts); a draft's check is announced only when its counts change, and one that hangs fails after 15 s so Save never waits on it; a reopened sheet starts on All with focus inside it; on a phone, Show problems beside the Save reason opens the list on its first error; types read as words and an unknown code keeps the engine's English in Technical details; ⌘S with errors keeps the browser's page save shut and shows the reason | ✅ unit + component + Chromium | `backend/domains/automations/routes.validate.test.ts`, `backend/domains/automations/routes.authoring.test.ts`, `app/features/automations/hooks/use-automation-validation.test.tsx`, `app/features/automations/lib/issues.test.ts`, `app/features/automations/lib/issue-text.test.ts`, `app/features/automations/lib/errors.test.ts`, `app/features/automations/components/automation-node.test.tsx`, `app/features/automations/components/node-inspector.test.tsx`, `app/features/automations/components/automation-editor.test.tsx`, `app/features/automations/components/automation-editor.browser.test.tsx`, `packages/ui/src/components/editor/editor-actions.test.tsx`, `packages/ui/src/components/feedback/issue-summary.browser.test.tsx`; translation quality, a screen reader's actual speech and the live round trip stay manual (`AUTO-F62`–`AUTO-F68`, `AUTO-B15`, `AUTO-B16`, `AUTO-A7`–`AUTO-A10`) | -| [automations](../../suites/automations.md) | Analysis refuses oversized source, token streams and deep syntax before recursive consumers; binary operations on BigInt are not constant-folded, unreachable item/index reads do not become scope errors, and shadowed globals stay unknown. Failed template recovery scans shared whitespace padding once per field, preserving legacy spans, separate whitespace islands and valid long padding; deterministic operation counts cover suffixes with and without a final closer. Valid boundary expressions retain quoted/comment/regex/template-literal closers and plain suffixes through the real runner; an installed runner cannot bypass an analysis refusal. Limit messages render in EN/DE/FR with de-CH fallback. | ✅ unit + real code runner | `lib/engine/core/syntax/limits.test.ts`, `constant.test.ts`, `tokens.test.ts`, `parse.test.ts`, `lib/engine/core/analysis/rules/names.test.ts`, `app/features/automations/lib/issue-text.test.ts`; limits deliberately restrict previously valid large or deep code, and rendered translations remain a browser check | +| [automations](../../suites/automations.md) | Expressions retain their 8192-code-unit/512-token budget; transform bodies have a separate 16384-code-unit/4096-token budget, with the same depth limits. Forty maximum-sized bodies pass compile-only native validation without execution; the next node and over-budget bodies still refuse. Larger synthetic bodies survive CLI release emission and the backend’s native validator unchanged. Analysis refuses oversized source, token streams and deep syntax before recursive consumers; binary operations on BigInt are not constant-folded, unreachable item/index reads do not become scope errors, and shadowed globals stay unknown. Failed template recovery scans shared whitespace padding once per field, preserving legacy spans, separate whitespace islands and valid long padding; deterministic operation counts cover suffixes with and without a final closer. Valid boundary expressions retain quoted/comment/regex/template-literal closers and plain suffixes through the real runner; an installed runner cannot bypass an analysis refusal. Limit messages render in EN/DE/FR with de-CH fallback. | ✅ unit + real code runner | `lib/engine/core/syntax/limits.test.ts`, `tools/cli/src/lib/config/releases/native.test.ts`, `constant.test.ts`, `tokens.test.ts`, `parse.test.ts`, `lib/engine/core/analysis/rules/names.test.ts`, `app/features/automations/lib/issue-text.test.ts`; limits deliberately restrict previously valid large or deep code, and rendered translations remain a browser check | | [automations](../../suites/automations.md) | Deploy and rollback check the installed automation protocol before mutation, bind capable backend images to immutable source/digest evidence, refuse unknown or custom DB custody, and inspect resumed writers. Catalog reads reject shadow or malformed ledgers. | ✅ CLI + real Postgres | `tools/cli/src/lib/deployment/automation-protocol.test.ts`, `automation-floor.test.ts`, `tools/cli/src/lib/actions/rollback.test.ts`, `tools/cli/scripts/automation-image-protocol.test.ts`, `backend/jobs/automation-floor.integration.ts` (owned database; CREATE DATABASE required); published image labels and full live deployment remain release acceptance checks | | [automations](../../suites/automations.md) | Legacy holds remain distinct from queued/running/finished states; unknown effects stay visible; acknowledged stop requests pin the hold identity, reject refreshed confirmations, preserve the hold after acceptance, and expose one recoverable refusal; task panels hide ordinary start/cancel/approval while held | 🔶 component with real mutation adapter and synthetic transport; live browser and migration outcome remain separate checks | `app/features/automations/components/run-quarantine-card.test.tsx`, `run-status-badge.test.tsx`, `run-detail.test.tsx`, `app/features/automations/lib/run-view.test.ts`, `app/features/tasks/components/task-subject-panel.test.tsx` | | [automations](../../suites/automations.md) / [tasks](../../suites/tasks.md) | Protocol cutover holds unfinished legacy runs without inventing effect outcomes. The real boot transaction and its schema-bound ledger fence stale snapshots; old claim/progress writes, held-task starts and destructive evidence changes refuse. An exact-identity acknowledged stop request records one operator decision while retaining the hold, task exclusion and original asks. | ✅ unit + real PostgreSQL | `backend/domains/automations/legacy-protocol.integration.ts`, `backend/domains/automations/legacy-quarantine.test.ts`, `backend/domains/automations/routes.project-scope.test.ts`, `backend/domains/tasks/retire.test.ts`, `tests/guards/automation-legacy-fixture.test.ts`, `backend/domains/automations/legacy-agent-flow.integration.ts`, `tests/guards/automation-legacy-agent-flow.test.ts` (complete released start/resume bodies and actual old run/ask SQL; external ports record requested effects only); already-admitted effects are not asserted undone, and requested token expiry does not prove gateway-key retirement | diff --git a/tools/cli/src/lib/config/releases/native.test.ts b/tools/cli/src/lib/config/releases/native.test.ts index 9aad6154a2..1283779817 100644 --- a/tools/cli/src/lib/config/releases/native.test.ts +++ b/tools/cli/src/lib/config/releases/native.test.ts @@ -2,8 +2,10 @@ import { expect, test } from 'bun:test'; import { existsSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import { stringify } from 'yaml'; +import { parse, stringify } from 'yaml'; +import { parseAutomationPackZip } from '../../../../../../services/platform/backend/core/automations/pack_zip'; +import { validate } from '../../../../../../services/platform/lib/engine/core/validate'; import { buildRelease } from './release'; import { commandFixture } from './tests/command-fixture'; @@ -48,6 +50,38 @@ test('native syntax errors are rejected before any immutable release is publishe } }, 30_000); +test('larger transform bodies survive native release emission and backend admission unchanged', async () => { + const f = commandFixture('code-team', false); + // Synthetic code exceeds both former expression-sized limits. Validation + // compiles it only; the native validator's transport refuses all execution. + const statements = Array.from( + { length: 300 }, + (_, index) => `const value${index} = ${index};`, + ).join('\n'); + const body = `${statements}\nreturn { count: value299 };`; + const code = `/*${'x'.repeat(15_000 - body.length - 4)}*/${body}`; + const document = { + ...f.document, + nodes: [{ id: 'work', type: 'transform', code }], + }; + writeFileSync(path.join(f.pack, 'workflow.yml'), stringify(document)); + const release = await buildRelease({ + ...f.options, + sourceCommit: f.commit(), + }); + if (!release.installation) throw new Error('native installation missing'); + const emitted = await parseAutomationPackZip( + release.installation.workflow.bytes, + ); + expect(parse(emitted.document.text)).toEqual(document); + // This is the same validator used by native upload/save/deploy, after the + // real CLI ZIP projection, not a second permissive parser. + expect(await validate(parse(emitted.document.text))).toEqual({ + errors: [], + warnings: [], + }); +}, 30_000); + test('native normalization and unsupported catalogue fields are refused before publication', async () => { for (const [extra, refusal] of [ [{ hidden: true }, 'native upload does not install hidden visibility'],