From 36ea2da0bfaa5ebd3ee2d63eae2395f6f54172c9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 12 Oct 2026 19:30:29 +0200 Subject: [PATCH] warn when a raw app sql viewer-identity input is not a ctx field (#11704) Co-authored-by: Claude Opus 5.5 --- cli/src/commands/app/lint.ts | 46 +++++++++++++++++++ .../app_lint_viewer_identity_unit.test.ts | 24 ++++++++++ .../RawAppInlineScriptRunnable.svelte | 9 ++++ .../raw_apps/RawAppInputsSpecEditor.svelte | 11 ++++- frontend/src/lib/infer.ts | 2 +- 5 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 cli/test/app_lint_viewer_identity_unit.test.ts diff --git a/cli/src/commands/app/lint.ts b/cli/src/commands/app/lint.ts index 36f3187cd3..c90a22105d 100644 --- a/cli/src/commands/app/lint.ts +++ b/cli/src/commands/app/lint.ts @@ -9,6 +9,7 @@ import { GlobalOptions } from "../../types.ts"; import { createBundle, detectFrameworks } from "./bundle.ts"; import { APP_BACKEND_FOLDER } from "./app_metadata.ts"; import { loadRunnablesFromBackend } from "./raw_apps.ts"; +import { inferSchema } from "../../utils/metadata.ts"; import { statedIsolation } from "./app.ts"; import { getFolderSuffix, @@ -55,6 +56,50 @@ function validateRawAppYaml(appData: any): { return { errors, warnings }; } +const SQL_LANGUAGES = new Set([ + "postgresql", + "mysql", + "bigquery", + "snowflake", + "mssql", + "oracledb", + "duckdb", +]); + +// The viewer properties a `ctx` field can supply. The Inputs tab of the app +// editor hints on the same names (RawAppInlineScriptRunnable.svelte). +const VIEWER_IDENTITY_ARGS = ["username", "email", "groups"]; + +/** + * A SQL runnable argument named after a viewer property is most often a + * per-viewer filter. Left as a plain input it takes whatever the browser + * sends, so it has to be a `ctx` field for the server to fill it. + */ +export async function viewerIdentityInputWarnings( + runnables: Record, +): Promise { + const warnings: string[] = []; + for (const [id, runnable] of Object.entries(runnables)) { + const { language, content } = runnable?.inlineScript ?? {}; + if (!SQL_LANGUAGES.has(language) || typeof content !== "string") { + continue; + } + const { schema } = await inferSchema(language, content, {}, id); + for (const arg of VIEWER_IDENTITY_ARGS) { + const field = runnable.fields?.[arg]; + const serverFilled = field?.ctx !== undefined || + field?.value !== undefined; + if (arg in (schema?.properties ?? {}) && !serverFilled) { + warnings.push( + `Runnable '${id}': argument '${arg}' is a frontend input, so a viewer can send any value. ` + + `If it identifies the viewer, set 'fields: { ${arg}: { ctx: ${arg} } }' in backend/${id}.yaml (next to 'type: inline') so the server fills it.`, + ); + } + } + } + return warnings; +} + /** * Validates that runnables exist either in backend/*.yaml files or in raw_app.yaml */ @@ -84,6 +129,7 @@ async function validateRunnables( " - Or a 'runnables' field in raw_app.yaml (legacy format)", ); } else if (hasBackendRunnables) { + warnings.push(...await viewerIdentityInputWarnings(runnablesFromBackend)); log.info( colors.gray( ` Found ${ diff --git a/cli/test/app_lint_viewer_identity_unit.test.ts b/cli/test/app_lint_viewer_identity_unit.test.ts new file mode 100644 index 0000000000..f328ffe4e2 --- /dev/null +++ b/cli/test/app_lint_viewer_identity_unit.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test"; +// app.ts first: it is the entry the CLI loads lint.ts through, and loading +// lint.ts ahead of it trips their import cycle. +import "../src/commands/app/app.ts"; +import { viewerIdentityInputWarnings } from "../src/commands/app/lint.ts"; + +const sql = (content: string, fields?: Record) => ({ + type: "inline", + inlineScript: { language: "duckdb", content }, + ...(fields ? { fields } : {}), +}); + +test("app lint warns on a SQL viewer-identity argument the server does not fill", async () => { + const content = + "-- $username (text)\n-- $limit (int)\nSELECT * FROM orders WHERE owner = $username LIMIT $limit"; + + const warnings = await viewerIdentityInputWarnings({ + open: sql(content), + filled: sql(content, { username: { ctx: "username" } }), + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Runnable 'open': argument 'username'"); +}); diff --git a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte index 200e76d85b..ddbdae5bf3 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInlineScriptRunnable.svelte @@ -15,6 +15,7 @@ import { Pane, Splitpanes } from 'svelte-splitpanes' import Tabs from '../common/tabs/Tabs.svelte' import { Tab } from '../common' + import { SQL_LANGUAGES } from '$lib/infer' import RawAppInputsSpecEditor from './RawAppInputsSpecEditor.svelte' import SplitPanesWrapper from '../splitPanes/SplitPanesWrapper.svelte' import SchemaForm from '../SchemaForm.svelte' @@ -80,6 +81,13 @@ } } + // A SQL argument named after a viewer property is most often a per-viewer filter, + // which only a ctx field protects. `wmill app lint` warns on the same names. + const VIEWER_IDENTITY_ARGS = ['username', 'email', 'groups'] + let isSql = $derived( + isRunnableByName(runnable) && SQL_LANGUAGES.includes(runnable.inlineScript?.language ?? '') + ) + let selectedTab = $state('test') let args = $state({}) let hubFlowPreview: OpenFlow | undefined = $state(undefined) @@ -266,6 +274,7 @@ documentationLink={meta?.['documentationLink']} allowTypeChange={meta?.['allowTypeChange']} displayType + suggestCtx={isSql && VIEWER_IDENTITY_ARGS.includes(k)} /> {/each} diff --git a/frontend/src/lib/components/raw_apps/RawAppInputsSpecEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppInputsSpecEditor.svelte index 67ead38501..859a693ca9 100644 --- a/frontend/src/lib/components/raw_apps/RawAppInputsSpecEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppInputsSpecEditor.svelte @@ -13,6 +13,7 @@ import { fieldTypeToTsType } from '../apps/utils' import type { InputType } from '../apps/inputType' import Select from '$lib/components/select/Select.svelte' + import { Alert } from '$lib/components/common' import { userStore } from '$lib/stores' import { useOperatingUser, @@ -67,6 +68,8 @@ allowTypeChange?: boolean loading?: boolean documentationLink?: string | undefined + /** Warn that this input, left as a frontend input, is open to any value a viewer sends. */ + suggestCtx?: boolean } let { @@ -85,7 +88,8 @@ displayType = false, allowTypeChange = true, loading = false, - documentationLink = undefined + documentationLink = undefined, + suggestCtx = false }: Props = $props() run(() => { @@ -193,6 +197,11 @@ {:else if componentInput?.type === 'user' || componentInput?.type == undefined} Field's value is a frontend input + {#if suggestCtx} + + If this identifies the viewer, switch it to context so the server fills it. + + {/if} {/if} {#if componentInput?.type === 'user' && ((fieldType == 'object' && format?.startsWith('resource-') && format !== 'resource-s3_object') || fieldType == 'resource')}
diff --git a/frontend/src/lib/infer.ts b/frontend/src/lib/infer.ts index 2eb15f3c34..9e9cddeb00 100644 --- a/frontend/src/lib/infer.ts +++ b/frontend/src/lib/infer.ts @@ -301,7 +301,7 @@ export async function inferAnsibleExecutionMode(code: string): Promise { } } -const SQL_LANGUAGES = [ +export const SQL_LANGUAGES = [ 'postgresql', 'mysql', 'bigquery',