warn when a raw app sql viewer-identity input is not a ctx field (#11704)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-12 17:30:29 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 26afeecbd5
commit 36ea2da0bf
5 changed files with 90 additions and 2 deletions
+46
View File
@@ -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<string, any>,
): Promise<string[]> {
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 ${
@@ -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<string, any>) => ({
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'");
});