From 76385b3d3ec77ba2d19010bcb498680dc87e174e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dimitri=20Lavren=C3=BCk?= Date: Thu, 10 Sep 2026 11:13:58 +0200 Subject: [PATCH] wip(core): Apps round 7 stream A: action fixes and shared contracts (no-changelog) Row actions accept only rows the table shows on the rendered page; actions on a dynamic page redirect back to it (the action URL carries the rendered path). The layout preview keeps form controls, inert. create-page accepts a layout. Contracts for the parallel streams: agent-chat block, theme.contentWidth, App.components with migration and snapshot field, layout presets placeholder, access token mode, shared request authorizer. Co-Authored-By: Claude Fable 5.1 --- docs/generated/postgres-schema/README.md | 3 +- docs/generated/postgres-schema/public.app.md | 2 + .../postgres-schema/public.app_version.md | 1 + docs/generated/postgres-schema/public.page.md | 1 + .../postgres-schema/public.project.md | 1 + docs/generated/sqlite-schema/README.md | 3 +- docs/generated/sqlite-schema/app.md | 4 +- docs/generated/sqlite-schema/app_version.md | 1 + docs/generated/sqlite-schema/page.md | 1 + docs/generated/sqlite-schema/project.md | 1 + .../api-types/src/dto/app/create-app.dto.ts | 4 + .../api-types/src/dto/app/update-app.dto.ts | 2 + packages/@n8n/api-types/src/index.ts | 4 + .../__tests__/app-content.schema.test.ts | 5 + .../__tests__/app-version.schema.test.ts | 22 +++ .../src/schemas/__tests__/app.schema.test.ts | 6 + .../src/schemas/app-content.schema.ts | 12 ++ .../src/schemas/app-layout-presets.ts | 13 ++ .../src/schemas/app-version.schema.ts | 5 +- .../@n8n/api-types/src/schemas/app.schema.ts | 9 + .../1789030187576-AddComponentsToApp.ts | 21 +++ .../1789030187576-AddComponentsToApp.ts | 10 + packages/@n8n/instance-ai/docs/tools.md | 2 +- .../app-builder/references/block-catalog.md | 8 +- .../src/tools/__tests__/apps.tool.test.ts | 51 +++++ .../@n8n/instance-ai/src/tools/apps.tool.ts | 24 ++- packages/@n8n/instance-ai/src/types.ts | 2 +- .../apps/__tests__/apps.service.test.ts | 41 ++++ .../actions/__tests__/table-actions.test.ts | 58 +++++- .../apps/actions/app-actions.controller.ts | 178 ++++++++---------- .../src/modules/apps/actions/table-actions.ts | 7 + packages/cli/src/modules/apps/app.entity.ts | 4 + .../cli/src/modules/apps/app.repository.ts | 5 +- packages/cli/src/modules/apps/apps.service.ts | 14 +- .../rendering/__tests__/sanitize-html.test.ts | 28 ++- .../apps/rendering/blocks/table-rows.ts | 42 +++++ .../apps/rendering/blocks/table.renderer.ts | 24 +-- .../modules/apps/rendering/sanitize-html.ts | 39 +++- .../apps/rendering/styles/app-chat.css | 1 + .../src/modules/apps/rendering/styles/app.css | 1 + .../__tests__/app-code-runtime.test.ts | 2 +- .../__tests__/page-context.factory.test.ts | 12 +- .../src/modules/apps/runtime/action-url.ts | 20 ++ .../modules/apps/runtime/app-code-runtime.ts | 18 +- .../apps/runtime/page-context.factory.ts | 4 +- .../__tests__/app-token.service.test.ts | 21 ++- .../serving/__tests__/viewer.service.test.ts | 2 +- .../modules/apps/serving/app-request-auth.ts | 57 ++++++ .../modules/apps/serving/app-token.service.ts | 17 +- ...app-actions.controller.integration.test.ts | 121 +++++++++++- .../apps/app-auth.integration.test.ts | 5 +- ...app-serving.controller.integration.test.ts | 1 + .../apps/apps.controller.integration.test.ts | 3 +- ...789030187576-add-components-to-app.test.ts | 152 +++++++++++++++ 54 files changed, 918 insertions(+), 177 deletions(-) create mode 100644 packages/@n8n/api-types/src/schemas/__tests__/app-version.schema.test.ts create mode 100644 packages/@n8n/api-types/src/schemas/app-layout-presets.ts create mode 100644 packages/@n8n/db/src/migrations/common/1789030187576-AddComponentsToApp.ts create mode 100644 packages/@n8n/db/src/migrations/sqlite/1789030187576-AddComponentsToApp.ts create mode 100644 packages/cli/src/modules/apps/rendering/blocks/table-rows.ts create mode 100644 packages/cli/src/modules/apps/rendering/styles/app-chat.css create mode 100644 packages/cli/src/modules/apps/runtime/action-url.ts create mode 100644 packages/cli/src/modules/apps/serving/app-request-auth.ts create mode 100644 packages/cli/test/migration/1789030187576-add-components-to-app.test.ts diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 5ae678c3a3e..7b71af24f61 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -39,7 +39,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.agents_threads](public.agents_threads.md) | 6 | | BASE TABLE | | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) | 4 | | BASE TABLE | | [public.annotation_tag_entity](public.annotation_tag_entity.md) | 4 | | BASE TABLE | -| [public.app](public.app.md) | 9 | | BASE TABLE | +| [public.app](public.app.md) | 10 | | BASE TABLE | | [public.app_version](public.app_version.md) | 6 | | BASE TABLE | | [public.auth_identity](public.auth_identity.md) | 5 | | BASE TABLE | | [public.auth_provider_sync_history](public.auth_provider_sync_history.md) | 11 | | BASE TABLE | @@ -723,6 +723,7 @@ erDiagram "public.app" { varchar_36_ activeVersionId FK varchar_16_ auth + text components timestamp_3__with_time_zone createdAt varchar_36_ id varchar_128_ name diff --git a/docs/generated/postgres-schema/public.app.md b/docs/generated/postgres-schema/public.app.md index 2d495683eec..34477029442 100644 --- a/docs/generated/postgres-schema/public.app.md +++ b/docs/generated/postgres-schema/public.app.md @@ -6,6 +6,7 @@ | ---- | ---- | ------- | -------- | -------- | ------- | ------- | | activeVersionId | varchar(36) | | true | | [public.app_version](public.app_version.md) | app_version served at /apps/\; null means unpublished | | auth | varchar(16) | 'public'::character varying | false | | | Who may open the App: anyone (public) or a signed-in user of this instance (n8n) | +| components | text | | true | | | TSX source of the shared components that code blocks import from app/components; null when the App has none | | createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | | id | varchar(36) | | false | [public.app_version](public.app_version.md) [public.page](public.page.md) | | | | name | varchar(128) | | false | | | | @@ -50,6 +51,7 @@ erDiagram "public.app" { varchar_36_ activeVersionId FK varchar_16_ auth + text components timestamp_3__with_time_zone createdAt varchar_36_ id varchar_128_ name diff --git a/docs/generated/postgres-schema/public.app_version.md b/docs/generated/postgres-schema/public.app_version.md index 69fc0f4e8d3..6b69e90670a 100644 --- a/docs/generated/postgres-schema/public.app_version.md +++ b/docs/generated/postgres-schema/public.app_version.md @@ -51,6 +51,7 @@ erDiagram "public.app" { varchar_36_ activeVersionId FK varchar_16_ auth + text components timestamp_3__with_time_zone createdAt varchar_36_ id varchar_128_ name diff --git a/docs/generated/postgres-schema/public.page.md b/docs/generated/postgres-schema/public.page.md index e91227b7854..fa4d0434f35 100644 --- a/docs/generated/postgres-schema/public.page.md +++ b/docs/generated/postgres-schema/public.page.md @@ -55,6 +55,7 @@ erDiagram "public.app" { varchar_36_ activeVersionId FK varchar_16_ auth + text components timestamp_3__with_time_zone createdAt varchar_36_ id varchar_128_ name diff --git a/docs/generated/postgres-schema/public.project.md b/docs/generated/postgres-schema/public.project.md index 70b92d6d45e..9eb64ed5c32 100644 --- a/docs/generated/postgres-schema/public.project.md +++ b/docs/generated/postgres-schema/public.project.md @@ -152,6 +152,7 @@ erDiagram "public.app" { varchar_36_ activeVersionId FK varchar_16_ auth + text components timestamp_3__with_time_zone createdAt varchar_36_ id varchar_128_ name diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 50429d290de..79de7dbc596 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -39,7 +39,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [agents_threads](agents_threads.md) | 6 | | table | | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) | 4 | | table | | [annotation_tag_entity](annotation_tag_entity.md) | 4 | | table | -| [app](app.md) | 9 | | table | +| [app](app.md) | 10 | | table | | [app_version](app_version.md) | 6 | | table | | [auth_identity](auth_identity.md) | 5 | | table | | [auth_provider_sync_history](auth_provider_sync_history.md) | 11 | | table | @@ -710,6 +710,7 @@ erDiagram "app" { varchar_36_ activeVersionId FK varchar_16_ auth + TEXT components datetime_3_ createdAt varchar_36_ id PK varchar_128_ name diff --git a/docs/generated/sqlite-schema/app.md b/docs/generated/sqlite-schema/app.md index 7ce48048a53..ad85d738e54 100644 --- a/docs/generated/sqlite-schema/app.md +++ b/docs/generated/sqlite-schema/app.md @@ -6,7 +6,7 @@ Table Definition ```sql -CREATE TABLE "app" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "namespace" varchar(128) NOT NULL, "theme" text, "projectId" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "activeVersionId" varchar(36), "auth" varchar(16) NOT NULL DEFAULT ('public'), CONSTRAINT "CHK_app_auth" CHECK ("auth" IN ('public', 'n8n')), CONSTRAINT "FK_f84dd7eb539e46e0c233fa09b20" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "app_activeVersionId_foreign" FOREIGN KEY ("activeVersionId") REFERENCES "app_version" ("id") ON DELETE SET NULL) +CREATE TABLE "app" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "namespace" varchar(128) NOT NULL, "theme" text, "projectId" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "activeVersionId" varchar(36), "auth" varchar(16) NOT NULL DEFAULT ('public'), "components" text, CONSTRAINT "CHK_app_auth" CHECK ("auth" IN ('public', 'n8n')), CONSTRAINT "FK_f84dd7eb539e46e0c233fa09b20" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "app_activeVersionId_foreign" FOREIGN KEY ("activeVersionId") REFERENCES "app_version" ("id") ON DELETE SET NULL) ``` @@ -17,6 +17,7 @@ CREATE TABLE "app" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) N | ---- | ---- | ------- | -------- | -------- | ------- | ------- | | activeVersionId | varchar(36) | | true | | [app_version](app_version.md) | | | auth | varchar(16) | 'public' | false | | | | +| components | TEXT | | true | | | | | createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | | id | varchar(36) | | false | [app_version](app_version.md) [page](page.md) | | | | name | varchar(128) | | false | | | | @@ -55,6 +56,7 @@ erDiagram "app" { varchar_36_ activeVersionId FK varchar_16_ auth + TEXT components datetime_3_ createdAt varchar_36_ id PK varchar_128_ name diff --git a/docs/generated/sqlite-schema/app_version.md b/docs/generated/sqlite-schema/app_version.md index 36057c07dec..88c96c7d6ca 100644 --- a/docs/generated/sqlite-schema/app_version.md +++ b/docs/generated/sqlite-schema/app_version.md @@ -58,6 +58,7 @@ erDiagram "app" { varchar_36_ activeVersionId FK varchar_16_ auth + TEXT components datetime_3_ createdAt varchar_36_ id PK varchar_128_ name diff --git a/docs/generated/sqlite-schema/page.md b/docs/generated/sqlite-schema/page.md index cbd2108edcd..19e6ce35bf1 100644 --- a/docs/generated/sqlite-schema/page.md +++ b/docs/generated/sqlite-schema/page.md @@ -62,6 +62,7 @@ erDiagram "app" { varchar_36_ activeVersionId FK varchar_16_ auth + TEXT components datetime_3_ createdAt varchar_36_ id PK varchar_128_ name diff --git a/docs/generated/sqlite-schema/project.md b/docs/generated/sqlite-schema/project.md index 63e7f877c2c..0fc0c62649e 100644 --- a/docs/generated/sqlite-schema/project.md +++ b/docs/generated/sqlite-schema/project.md @@ -158,6 +158,7 @@ erDiagram "app" { varchar_36_ activeVersionId FK varchar_16_ auth + TEXT components datetime_3_ createdAt varchar_36_ id PK varchar_128_ name diff --git a/packages/@n8n/api-types/src/dto/app/create-app.dto.ts b/packages/@n8n/api-types/src/dto/app/create-app.dto.ts index 2c608d51a1b..6233049652d 100644 --- a/packages/@n8n/api-types/src/dto/app/create-app.dto.ts +++ b/packages/@n8n/api-types/src/dto/app/create-app.dto.ts @@ -1,7 +1,11 @@ +import { z } from 'zod'; + import { appNameSchema, appNamespaceSchema } from '../../schemas/app.schema'; import { Z } from '../../zod-class'; export class CreateAppDto extends Z.class({ name: appNameSchema, namespace: appNamespaceSchema, + /** Id of an `APP_LAYOUT_PRESETS` entry applied to the index page and the theme. */ + layoutPreset: z.string().max(32).optional(), }) {} diff --git a/packages/@n8n/api-types/src/dto/app/update-app.dto.ts b/packages/@n8n/api-types/src/dto/app/update-app.dto.ts index c288df023d5..8a3e00b67fe 100644 --- a/packages/@n8n/api-types/src/dto/app/update-app.dto.ts +++ b/packages/@n8n/api-types/src/dto/app/update-app.dto.ts @@ -1,5 +1,6 @@ import { appAuthSchema, + appComponentsSchema, appNameSchema, appNamespaceSchema, appThemeSchema, @@ -11,4 +12,5 @@ export class UpdateAppDto extends Z.class({ namespace: appNamespaceSchema.optional(), theme: appThemeSchema.nullable().optional(), auth: appAuthSchema.optional(), + components: appComponentsSchema.nullable().optional(), }) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 573141da380..db66bd3f12a 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -766,7 +766,9 @@ export { type AppTheme, appAuthSchema, type AppAuth, + appComponentsSchema, } from './schemas/app.schema'; +export { APP_LAYOUT_PRESETS, type AppLayoutPreset } from './schemas/app-layout-presets'; export { appBlockIdSchema, appActionNameSchema, @@ -791,6 +793,8 @@ export { type ButtonBlock, type HtmlBlock, type CodeBlock, + agentChatBlockSchema, + type AgentChatBlock, } from './schemas/app-content.schema'; export { appVersionSnapshotSchema, type AppVersionSnapshot } from './schemas/app-version.schema'; export { appPageApiTypes, APP_PAGE_API_FILE_NAME } from './schemas/app-page-api'; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/app-content.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/app-content.schema.test.ts index 00e1d2dad23..73c62ab7d1b 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/app-content.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/app-content.schema.test.ts @@ -47,6 +47,11 @@ describe('appBlockSchema', () => { type: 'code', data: { source: 'export function render() { return "

hi

"; }' }, }, + { + id: 'chat1', + type: 'agent-chat', + data: { agentId: 'agent-1', welcome: 'Hi, how can I help?', placeholder: 'Ask…' }, + }, ])('accepts a valid $type block', (block) => { expect(appBlockSchema.safeParse(block).success).toBe(true); }); diff --git a/packages/@n8n/api-types/src/schemas/__tests__/app-version.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/app-version.schema.test.ts new file mode 100644 index 00000000000..39bf3079e38 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/__tests__/app-version.schema.test.ts @@ -0,0 +1,22 @@ +import { appVersionSnapshotSchema } from '../app-version.schema'; + +describe('appVersionSnapshotSchema', () => { + const page = { id: 'p1', route: '', parentPageId: null, content: [] }; + + test('defaults components to null for a snapshot published before they existed', () => { + const parsed = appVersionSnapshotSchema.parse({ pages: [page], theme: null }); + + expect(parsed.components).toBeNull(); + expect(parsed.pages[0].layout).toBeNull(); + }); + + test('keeps the components source', () => { + const parsed = appVersionSnapshotSchema.parse({ + pages: [page], + theme: null, + components: 'export const Card = () =>
;', + }); + + expect(parsed.components).toContain('Card'); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/__tests__/app.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/app.schema.test.ts index 142b6086524..fc7570041d9 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/app.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/app.schema.test.ts @@ -11,11 +11,17 @@ describe('appThemeSchema', () => { expect(appThemeSchema.safeParse({}).success).toBe(true); }); + test.each(['72rem', '1200px'])('accepts the content width %s', (contentWidth) => { + expect(appThemeSchema.safeParse({ contentWidth }).success).toBe(true); + }); + test.each([ [{ radius: 'huge' }, 'unknown radius'], [{ colors: { primary: 'x'.repeat(33) } }, 'color too long'], [{ colors: { primary: '' } }, 'empty color'], [{ fontFamily: 'x'.repeat(101) }, 'font family too long'], + [{ contentWidth: '100%' }, 'content width not in px or rem'], + [{ contentWidth: '72rem; }' }, 'content width with extra characters'], ])('rejects %j (%s)', (theme, _reason) => { expect(appThemeSchema.safeParse(theme).success).toBe(false); }); diff --git a/packages/@n8n/api-types/src/schemas/app-content.schema.ts b/packages/@n8n/api-types/src/schemas/app-content.schema.ts index 77c20cd0c2b..4cccac1db99 100644 --- a/packages/@n8n/api-types/src/schemas/app-content.schema.ts +++ b/packages/@n8n/api-types/src/schemas/app-content.schema.ts @@ -101,6 +101,16 @@ export const htmlBlockSchema = block('html', z.object({ template: z.string().max export const codeBlockSchema = block('code', z.object({ source: z.string().max(100_000) })); +/** A chat with a published agent of the App's project, rendered on the page. */ +export const agentChatBlockSchema = block( + 'agent-chat', + z.object({ + agentId: z.string().min(1).max(36), + welcome: z.string().max(500).optional(), + placeholder: z.string().max(100).optional(), + }), +); + export const appBlockSchema = z.discriminatedUnion('type', [ headerBlockSchema, paragraphBlockSchema, @@ -112,6 +122,7 @@ export const appBlockSchema = z.discriminatedUnion('type', [ buttonBlockSchema, htmlBlockSchema, codeBlockSchema, + agentChatBlockSchema, ]); const hasUniqueIds = (blocks: Array<{ id: string }>) => @@ -158,3 +169,4 @@ export type FormBlock = z.infer; export type ButtonBlock = z.infer; export type HtmlBlock = z.infer; export type CodeBlock = z.infer; +export type AgentChatBlock = z.infer; diff --git a/packages/@n8n/api-types/src/schemas/app-layout-presets.ts b/packages/@n8n/api-types/src/schemas/app-layout-presets.ts new file mode 100644 index 00000000000..4ad21b03404 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/app-layout-presets.ts @@ -0,0 +1,13 @@ +import type { AppLayout } from './app-content.schema'; +import type { AppTheme } from './app.schema'; + +/** A ready-made layout plus theme an author or the AI starts an App from. */ +export type AppLayoutPreset = { + id: string; + name: string; + description: string; + blocks: AppLayout; + theme: AppTheme; +}; + +export const APP_LAYOUT_PRESETS: readonly AppLayoutPreset[] = []; diff --git a/packages/@n8n/api-types/src/schemas/app-version.schema.ts b/packages/@n8n/api-types/src/schemas/app-version.schema.ts index 2f789357c88..6b3b9af5162 100644 --- a/packages/@n8n/api-types/src/schemas/app-version.schema.ts +++ b/packages/@n8n/api-types/src/schemas/app-version.schema.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; import { appContentSchema, appLayoutSchema } from './app-content.schema'; -import { appThemeSchema, pageRouteSchema } from './app.schema'; +import { appComponentsSchema, appThemeSchema, pageRouteSchema } from './app.schema'; -/** A published version: the whole page tree and theme, frozen at publish time. */ +/** A published version: the whole page tree, theme and components, frozen at publish time. */ export const appVersionSnapshotSchema = z.object({ pages: z.array( z.object({ @@ -15,6 +15,7 @@ export const appVersionSnapshotSchema = z.object({ }), ), theme: appThemeSchema.nullable(), + components: appComponentsSchema.nullable().default(null), }); export type AppVersionSnapshot = z.infer; diff --git a/packages/@n8n/api-types/src/schemas/app.schema.ts b/packages/@n8n/api-types/src/schemas/app.schema.ts index c163f3e2212..3cf83a2614f 100644 --- a/packages/@n8n/api-types/src/schemas/app.schema.ts +++ b/packages/@n8n/api-types/src/schemas/app.schema.ts @@ -42,12 +42,21 @@ export const appThemeSchema = z.object({ .optional(), radius: z.enum(['none', 'sm', 'md', 'lg']).optional(), fontFamily: z.string().trim().max(100).optional(), + /** Max width of `.app-container`, e.g. `72rem` or `1200px`. */ + contentWidth: z + .string() + .trim() + .regex(/^\d{2,4}(px|rem)$/, 'Use a length in px or rem, e.g. 72rem') + .optional(), /** Appended to every served page after the theme variables. */ customCss: z.string().max(20_000).optional(), }); export type AppTheme = z.infer; +/** TSX source of the App's shared components, imported by code blocks from `app/components`. */ +export const appComponentsSchema = z.string().max(50_000); + /** Who may open the App: anyone, or only a signed-in user of this n8n instance. */ export const appAuthSchema = z.enum(['public', 'n8n']); diff --git a/packages/@n8n/db/src/migrations/common/1789030187576-AddComponentsToApp.ts b/packages/@n8n/db/src/migrations/common/1789030187576-AddComponentsToApp.ts new file mode 100644 index 00000000000..badb5ca69c4 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1789030187576-AddComponentsToApp.ts @@ -0,0 +1,21 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const APP_TABLE = 'app'; + +export class AddComponentsToApp1789030187576 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns( + APP_TABLE, + [ + column('components').text.comment( + 'TSX source of the shared components that code blocks import from app/components; null when the App has none', + ), + ], + { recreatesOnSqlite: true }, + ); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns(APP_TABLE, ['components'], { recreatesOnSqlite: true }); + } +} diff --git a/packages/@n8n/db/src/migrations/sqlite/1789030187576-AddComponentsToApp.ts b/packages/@n8n/db/src/migrations/sqlite/1789030187576-AddComponentsToApp.ts new file mode 100644 index 00000000000..78f743d897c --- /dev/null +++ b/packages/@n8n/db/src/migrations/sqlite/1789030187576-AddComponentsToApp.ts @@ -0,0 +1,10 @@ +import { AddComponentsToApp1789030187576 as BaseMigration } from '../common/1789030187576-AddComponentsToApp'; + +/** + * `app` has incoming FKs (`page.appId`, `app_version.appId`) with CASCADE; + * adding a column recreates the `app` table on SQLite, which would otherwise + * cascade-delete those rows when the original table is dropped. + */ +export class AddComponentsToApp1789030187576 extends BaseMigration { + withFKsDisabled = true as const; +} diff --git a/packages/@n8n/instance-ai/docs/tools.md b/packages/@n8n/instance-ai/docs/tools.md index 03c8ea08785..9f09d5f75e3 100644 --- a/packages/@n8n/instance-ai/docs/tools.md +++ b/packages/@n8n/instance-ai/docs/tools.md @@ -843,7 +843,7 @@ thread to the app without an extra read. | `create` | Create an app. Namespace is slugified from the name when omitted. HITL. | | `get` | Get an app and its pages | | `update-app` | Update an app's name or theme | -| `create-page` | Create a page. Block ids missing from `content` are generated before validation. | +| `create-page` | Create a page, optionally with `content` and a `layout`. Block ids missing from either are generated before validation. | | `get-page` | Read a page's route, path, and content | | `set-content` | Replace a page's content blocks (same id-generation + validation as `create-page`) | | `update-page` | Change a page's route | diff --git a/packages/@n8n/instance-ai/skills/app-builder/references/block-catalog.md b/packages/@n8n/instance-ai/skills/app-builder/references/block-catalog.md index 96bd3859cd0..5779a7ec794 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/references/block-catalog.md +++ b/packages/@n8n/instance-ai/skills/app-builder/references/block-catalog.md @@ -96,10 +96,10 @@ per shown column. "Save" posts to the block's built-in `update` action. Set block's built-in `delete` action. Only the shown columns are writable; `id`, `createdAt` and `updatedAt` are never writable. -Limitation: the `update` and `delete` actions cannot re-apply the block's -`filter`. A visitor who may edit or delete can address any row of the Data -Table by its `id`, not only the rows the block shows. Enable these flags -only on pages for trusted viewers. +The `update` and `delete` actions accept only rows the table shows on the +page the visitor is on: the block's `filter` (with the page's params), `sortBy` +and `limit` apply to them as they apply to the rendered rows. A row past the +`limit` is not shown and therefore cannot be edited through this block. ### `form` diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts index c85d6ab2aad..57c72564894 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts @@ -446,6 +446,57 @@ describe('apps tool', () => { expect(result).toHaveProperty('issues'); expect(context.appService?.createPage).not.toHaveBeenCalled(); }); + + it('validates, backfills ids and passes a layout', async () => { + const context = createMockContext(); + (context.appService?.createPage as Mock).mockResolvedValue({ + id: 'page-1', + route: 'clients', + parentPageId: null, + path: '/clients', + hasContent: false, + }); + (context.appService?.getApp as Mock).mockResolvedValue(appSummary); + + const tool = createAppsTool(context); + await executeTool( + tool, + { + action: 'create-page' as const, + appId: 'app-1', + route: 'clients', + layout: [ + { type: 'header', data: { text: 'Banner', level: 2 } }, + { id: 'slot', type: 'slot', data: {} }, + ], + }, + noSuspendCtx(), + ); + + const call = (context.appService?.createPage as Mock).mock.calls[0][1]; + expect(call.layout).toHaveLength(2); + expect(typeof call.layout[0].id).toBe('string'); + expect(call.layout[1]).toEqual({ id: 'slot', type: 'slot', data: {} }); + }); + + it('returns denied when the layout has no slot, without calling the adapter', async () => { + const context = createMockContext(); + + const tool = createAppsTool(context); + const result = await executeTool( + tool, + { + action: 'create-page' as const, + appId: 'app-1', + route: 'clients', + layout: [{ id: 'h', type: 'header', data: { text: 'Banner', level: 2 } }], + }, + noSuspendCtx(), + ); + + expect(result).toMatchObject({ denied: true, reason: 'Invalid page layout' }); + expect(context.appService?.createPage).not.toHaveBeenCalled(); + }); }); // ── get-page ──────────────────────────────────────────────────────────── diff --git a/packages/@n8n/instance-ai/src/tools/apps.tool.ts b/packages/@n8n/instance-ai/src/tools/apps.tool.ts index 12dc01d8448..64c2bbe1ce2 100644 --- a/packages/@n8n/instance-ai/src/tools/apps.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/apps.tool.ts @@ -149,6 +149,10 @@ const contentDescribe = 'ids are generated automatically. Rejected with `issues` (zod issues) when the blocks are invalid.'; const routeDescribe = "URL segment: '' for the index page of its level, a slug ('clients'), or a dynamic segment (':id')"; +const layoutDescribe = + 'Layout blocks (see the `app-builder` skill, "Layouts"): any content block plus exactly one ' + + '`slot` block where the page content goes. Block `id` is optional. Pass null to inherit the ' + + "nearest ancestor's layout, or the built-in shell when no ancestor has one."; const listAction = z.object({ action: z.literal('list').describe('List apps in a project'), @@ -192,6 +196,7 @@ const createPageAction = z.object({ route: pageRouteSchema.describe(routeDescribe), parentPageId: z.string().optional().describe('Parent page ID; omit for a top-level page'), content: z.array(rawBlockSchema).max(200).optional().describe(contentDescribe), + layout: z.array(rawBlockSchema).max(200).optional().describe(layoutDescribe), }); const getPageAction = z.object({ @@ -215,15 +220,7 @@ const setLayoutAction = z.object({ ), appId: z.string().describe('App ID'), pageId: z.string().describe('Page ID'), - layout: z - .array(rawBlockSchema) - .max(200) - .nullable() - .describe( - 'Layout blocks (see the `app-builder` skill, "Layouts"): any content block plus exactly one ' + - '`slot` block where the page content goes. Block `id` is optional. Pass null to inherit the ' + - "nearest ancestor's layout, or the built-in shell when no ancestor has one.", - ), + layout: z.array(rawBlockSchema).max(200).nullable().describe(layoutDescribe), }); const updatePageAction = z.object({ @@ -384,12 +381,21 @@ async function handleCreatePage( } content = validated.data; } + let layout: AppLayout | undefined; + if (input.layout) { + const validated = validateBlocks(appLayoutSchema, input.layout); + if ('issues' in validated) { + return { denied: true, reason: 'Invalid page layout', issues: validated.issues }; + } + layout = validated.data; + } return await callOrDeny(async () => { const page = await appService.createPage(input.appId, { route: input.route, parentPageId: input.parentPageId, content, + layout, }); const app = await appService.getApp(input.appId); return { diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index fa4f43fa23c..d86967f2f00 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -1079,7 +1079,7 @@ export interface InstanceAiAppService { ): Promise; createPage( appId: string, - input: { route: string; parentPageId?: string; content?: AppContent }, + input: { route: string; parentPageId?: string; content?: AppContent; layout?: AppLayout }, ): Promise; updatePage( appId: string, diff --git a/packages/cli/src/modules/apps/__tests__/apps.service.test.ts b/packages/cli/src/modules/apps/__tests__/apps.service.test.ts index cb27914f1b5..fc5efc6635f 100644 --- a/packages/cli/src/modules/apps/__tests__/apps.service.test.ts +++ b/packages/cli/src/modules/apps/__tests__/apps.service.test.ts @@ -11,8 +11,14 @@ import { AppContentInvalidError } from '../errors/app-content-invalid.error'; import type { Page } from '../page.entity'; import type { PageRepository } from '../page.repository'; import { registerStaticRenderers } from '../rendering/register-static-renderers'; +import { registerBlockRenderer } from '../rendering/renderer-registry'; registerStaticRenderers(); +// Stands in for the real button renderer, which needs the DI container. +registerBlockRenderer({ + type: 'button', + render: async () => "
", +}); const app = mock({ id: 'app-1', @@ -20,6 +26,7 @@ const app = mock({ namespace: 'acme', projectId: 'project-1', theme: null, + components: null, }); const slot = { id: 'slot', type: 'slot', data: {} }; @@ -58,6 +65,19 @@ describe('AppsService', () => { const [, snapshot] = appVersionRepository.createFromSnapshot.mock.calls[0]; expect(snapshot.pages[0].layout).toEqual([slot]); + expect(snapshot.components).toBeNull(); + }); + + test("freezes the App's components in the snapshot", async () => { + appRepository.findOneBy.mockResolvedValue( + mock({ ...app, components: 'export const Card = () =>
;' }), + ); + pageRepository.findManyByAppId.mockResolvedValue([page({ id: 'index', route: '' })]); + + await service.publish('app-1', 'user-1'); + + const [, snapshot] = appVersionRepository.createFromSnapshot.mock.calls[0]; + expect(snapshot.components).toContain('Card'); }); test('rejects a layout without a slot, naming the field in the issue path', async () => { @@ -120,6 +140,27 @@ describe('AppsService', () => { expect(result.errors).toEqual({}); }); + test('keeps the form controls of a layout block, inert', async () => { + const index = page({ + id: 'index', + route: '', + layout: [ + { + id: 'search', + type: 'button', + data: { label: 'Go', style: 'primary', target: { kind: 'workflow', workflowId: 'w' } }, + }, + slot, + ], + }); + pageRepository.findOneBy.mockResolvedValue(index); + pageRepository.findManyByAppId.mockResolvedValue([index]); + + const result = await service.previewLayout('app-1', 'index'); + + expect(result.html).toContain('
'); + }); + test('reports a layout block whose renderer throws, keyed by block id', async () => { const index = page({ id: 'index', diff --git a/packages/cli/src/modules/apps/actions/__tests__/table-actions.test.ts b/packages/cli/src/modules/apps/actions/__tests__/table-actions.test.ts index e81a84f1e64..6157f789366 100644 --- a/packages/cli/src/modules/apps/actions/__tests__/table-actions.test.ts +++ b/packages/cli/src/modules/apps/actions/__tests__/table-actions.test.ts @@ -2,6 +2,7 @@ import { mock } from 'vitest-mock-extended'; import type { TableBlock } from '@n8n/api-types'; +import type { BlockRenderContext } from '../../rendering/types'; import type { AppDataTableHandle } from '../../runtime/page-context.factory'; import { runTableAction } from '../table-actions'; @@ -13,11 +14,26 @@ function tableBlock(overrides: Partial = {}): TableBlock { }; } +const ctx: BlockRenderContext = { + app: { id: 'app-1', name: 'App', namespace: 'app', projectId: 'p1', theme: null }, + page: { id: 'page-1', route: ':id', path: '/apps/app/clients/42' }, + actionPageId: 'page-1', + params: { id: '42' }, + query: {}, + viewer: null, + menu: [], + baseUrl: 'https://n8n.example.com', + preview: false, +}; + const idFilter = (id: number) => ({ type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: id }], }); +const shownRow = (id: number) => ({ id, createdAt: new Date(), updatedAt: new Date() }); + +/** Rows 1, 3 and 7 are the ones the table shows on the page. */ function handleWithColumns() { const handle = mock(); handle.getColumns.mockResolvedValue([ @@ -26,12 +42,46 @@ function handleWithColumns() { { id: 'c2', name: 'paid', type: 'boolean', index: 2 }, { id: 'c3', name: 'dueAt', type: 'date', index: 3 }, ]); + handle.getManyRowsAndCount.mockResolvedValue({ + count: 3, + data: [shownRow(1), shownRow(3), shownRow(7)], + }); handle.updateRows.mockResolvedValue([]); handle.deleteRows.mockResolvedValue([]); return handle; } describe('runTableAction', () => { + test('refuses a row the table does not show on this page', async () => { + const handle = handleWithColumns(); + const block = tableBlock({ + deletable: true, + filter: { + type: 'and', + filters: [{ columnName: 'clientId', condition: 'eq', value: '{{ params.id }}' }], + }, + }); + + const outcome = await runTableAction({ + handle, + block, + name: 'delete', + input: { id: '2' }, + ctx, + }); + + expect(outcome).toEqual({ error: 'Row not found' }); + expect(handle.getManyRowsAndCount).toHaveBeenCalledWith({ + filter: { + type: 'and', + filters: [{ columnName: 'clientId', condition: 'eq', value: '42' }], + }, + sortBy: undefined, + take: 50, + }); + expect(handle.deleteRows).not.toHaveBeenCalled(); + }); + test.each([ ['delete', tableBlock({ editable: true })], ['update', tableBlock({ deletable: true })], @@ -39,7 +89,7 @@ describe('runTableAction', () => { ])('refuses "%s" when the block does not allow it', async (name, block) => { const handle = handleWithColumns(); - const outcome = await runTableAction({ handle, block, name, input: { id: '1' } }); + const outcome = await runTableAction({ handle, block, name, input: { id: '1' }, ctx }); expect(outcome).toEqual({ error: 'Action not found' }); expect(handle.deleteRows).not.toHaveBeenCalled(); @@ -54,6 +104,7 @@ describe('runTableAction', () => { block: tableBlock({ deletable: true }), name: 'delete', input: { id }, + ctx, }); expect(outcome).toEqual({ error: 'Invalid row id' }); @@ -68,6 +119,7 @@ describe('runTableAction', () => { block: tableBlock({ deletable: true }), name: 'delete', input: { id: '7' }, + ctx, }); expect(handle.deleteRows).toHaveBeenCalledWith({ filter: idFilter(7) }); @@ -82,6 +134,7 @@ describe('runTableAction', () => { block: tableBlock({ editable: true }), name: 'update', input: { id: '3', name: 'Ada', amount: '12.5', paid: 'true', dueAt: '2024-01-02T10:30' }, + ctx, }); expect(handle.updateRows).toHaveBeenCalledWith({ @@ -99,6 +152,7 @@ describe('runTableAction', () => { block: tableBlock({ editable: true }), name: 'update', input: { id: '3', amount: '', paid: 'false', dueAt: '' }, + ctx, }); expect(handle.updateRows).toHaveBeenCalledWith({ @@ -118,6 +172,7 @@ describe('runTableAction', () => { block: tableBlock({ editable: true }), name: 'update', input: { id: '3', [column]: value }, + ctx, }); expect(outcome).toEqual({ error: `Invalid value for "${column}"` }); @@ -132,6 +187,7 @@ describe('runTableAction', () => { block: tableBlock({ editable: true, columns: ['name'] }), name: 'update', input: { id: '3', name: 'Ada', amount: '1', createdAt: '2020-01-01' }, + ctx, }); expect(handle.updateRows).toHaveBeenCalledWith({ filter: idFilter(3), data: { name: 'Ada' } }); diff --git a/packages/cli/src/modules/apps/actions/app-actions.controller.ts b/packages/cli/src/modules/apps/actions/app-actions.controller.ts index 928a321e183..70f0229e6c0 100644 --- a/packages/cli/src/modules/apps/actions/app-actions.controller.ts +++ b/packages/cli/src/modules/apps/actions/app-actions.controller.ts @@ -7,20 +7,19 @@ import { posix } from 'node:path'; import { applyCors } from '@/utils/cors.util'; -import { AppRepository } from '../app.repository'; -import { AppVersionRepository } from '../app-version.repository'; +import { blockStaticData } from '../rendering/block-context'; +import type { BlockRenderContext } from '../rendering/types'; +import { AppRequestAuth } from '../serving/app-request-auth'; import { appBasePath, pagePath } from '../serving/page-menu'; -import { isDynamicRoute } from '../serving/resolve-page-path'; -import { AppTokenService, bearerToken } from '../serving/app-token.service'; -import { ViewerService, type Viewer } from '../serving/viewer.service'; +import { resolvePagePath } from '../serving/resolve-page-path'; import { AppCodeRuntime } from '../runtime/app-code-runtime'; import { PageContextFactory } from '../runtime/page-context.factory'; import { runTableAction } from './table-actions'; -/** `_path` rides along in the POST body only to route the redirect; a workflow - * or code action never sees it or the status keys as input. */ -const RESERVED_INPUT_KEYS = new Set(['_path', '_form', '_status', '_message']); +/** Status keys ride along in the POST body only to route the redirect; a workflow + * or code action never sees them as input. */ +const RESERVED_INPUT_KEYS = new Set(['_form', '_status', '_message']); const stripReservedKeys = (input: Record): Record => { const result: Record = {}; @@ -37,23 +36,37 @@ const MAX_BODY_BYTES = 1024 * 1024; type ActionOutcome = { redirect?: string } | { data: unknown } | { error: string }; -/** Ancestor route segments only, so no `:param` value is required — used when - * a request carries no `_path` (a `code` action called directly, not via a - * rendered form). Falls back to the app root if an ancestor is dynamic. */ -function staticPagePath( - pages: AppVersionSnapshot['pages'], - pageId: string, +type SnapshotPage = AppVersionSnapshot['pages'][number]; + +/** The page the action was triggered from, with its route params and canonical path. */ +type RenderedPage = { page: SnapshotPage; params: Record; path: string }; + +/** + * Resolves the `_path` query of an action URL (the rendered page's path, put + * there by `ctx.actionUrl()`) to a page of the snapshot. A layout block's + * action page can differ from the rendered page, so the URL carries both. + */ +function resolveRenderedPage( + pages: SnapshotPage[], namespace: string, -): string { - const byId = new Map(pages.map((p) => [p.id, p])); - const segments: string[] = []; - let current = byId.get(pageId); - while (current) { - if (isDynamicRoute(current.route)) return appBasePath(namespace); - if (current.route !== '') segments.unshift(current.route); - current = current.parentPageId ? byId.get(current.parentPageId) : undefined; + rawPath: unknown, +): RenderedPage | undefined { + const base = appBasePath(namespace); + if (typeof rawPath !== 'string' || (rawPath !== base && !rawPath.startsWith(`${base}/`))) { + return undefined; } - return pagePath(namespace, segments); + let segments: string[]; + try { + segments = rawPath + .slice(base.length) + .split('/') + .filter((segment) => segment !== '') + .map(decodeURIComponent); + } catch { + return undefined; + } + const resolved = resolvePagePath(pages, segments); + return resolved && { ...resolved, path: pagePath(namespace, segments) }; } /** Normalized first, so `/apps/ns/../../x` does not pass on its prefix alone. */ @@ -72,19 +85,16 @@ function prefersHtml(req: Request): boolean { /** * Runs one App action: a `code` block's exported `actions[name]`, a `form` - * block's `submit`, or a `button` block whose target is a workflow. Per - * `serving-and-actions.md`, unauthenticated (`skipAuth`) — the access token - * in the `Authorization` header is this endpoint's only credential. + * block's `submit`, a `button` block whose target is a workflow, or a `table` + * row action. Per `serving-and-actions.md`, unauthenticated (`skipAuth`) — the + * access token in the `Authorization` header is this endpoint's only credential. */ @RootLevelController('/apps') export class AppActionsController { constructor( - private readonly appTokenService: AppTokenService, - private readonly appRepository: AppRepository, - private readonly appVersionRepository: AppVersionRepository, + private readonly appRequestAuth: AppRequestAuth, private readonly pageContextFactory: PageContextFactory, private readonly appCodeRuntime: AppCodeRuntime, - private readonly viewerService: ViewerService, private readonly logger: Logger, ) {} @@ -111,60 +121,45 @@ export class AppActionsController { return; } - const token = bearerToken(req); - const payload = token === undefined ? null : this.appTokenService.verifyAccess(token); - const app = payload ? await this.appRepository.findByNamespace(namespace) : null; - if (!app || app.id !== payload?.appId) { - res.status(401).json({ error: 'Invalid access token' }); + const authorized = await this.appRequestAuth.authorize(req, namespace); + if ('error' in authorized) { + res.status(authorized.status).json({ error: authorized.error }); return; } + const { app, viewer, pages } = authorized; - const viewer = await this.viewerService.fromToken(payload); - if (app.auth === 'n8n' && !viewer) { - res.status(401).json({ error: 'Sign in required' }); - return; - } - - if (!app.activeVersionId) { - res.status(404).json({ error: 'This app has no published version' }); - return; - } - const version = await this.appVersionRepository.findSnapshot(app.activeVersionId); - if (!version) { - res.status(404).json({ error: 'This app has no published version' }); - return; - } - const { snapshot } = version; - - const page = snapshot.pages.find((p) => p.id === pageId); + const actionPage = pages.find((p) => p.id === pageId); // A layout block of this page runs from every page that inherits the layout; // its action URL names the owner page, so the lookup covers `layout` too. - const block = page - ? [...(page.content ?? []), ...(page.layout ?? [])].find((b) => b.id === blockId) + const block = actionPage + ? [...(actionPage.content ?? []), ...(actionPage.layout ?? [])].find((b) => b.id === blockId) : undefined; - if (!page || !block) { + if (!actionPage || !block) { res.status(404).json({ error: 'Action not found' }); return; } + const rendered = resolveRenderedPage(pages, namespace, req.query._path) ?? { + page: actionPage, + params: {}, + path: appBasePath(namespace), + }; + const ctx: BlockRenderContext = { + app: { id: app.id, name: app.name, namespace, projectId: app.projectId, theme: app.theme }, + page: { id: rendered.page.id, route: rendered.page.route, path: rendered.path }, + actionPageId: actionPage.id, + params: rendered.params, + query: {}, + viewer, + menu: [], + baseUrl: `${req.protocol}://${req.get('host') ?? ''}`, + preview: false, + }; const input = stripReservedKeys((req.body as Record | undefined) ?? {}); - const rawPath = (req.body as Record | undefined)?._path; - const pathForRedirect = - typeof rawPath === 'string' && isWithinApp(rawPath, namespace) - ? rawPath - : staticPagePath(snapshot.pages, pageId, namespace); let outcome: ActionOutcome; try { - outcome = await this.dispatch({ - app, - page, - block, - name, - input, - viewer, - baseUrl: `${req.protocol}://${req.get('host') ?? ''}`, - }); + outcome = await this.dispatch(ctx, block, name, input); } catch (error) { outcome = { error: error instanceof Error ? error.message : String(error) }; } @@ -177,38 +172,19 @@ export class AppActionsController { outcome: 'error' in outcome ? 'error' : 'redirect' in outcome ? 'redirect' : 'data', }); - this.respond(res, outcome, namespace, pathForRedirect, blockId, req); + this.respond(res, outcome, namespace, rendered.path, blockId, req); } - private async dispatch(input: { - app: { id: string; name: string; namespace: string; projectId: string }; - page: AppVersionSnapshot['pages'][number]; - block: AppLayoutBlock; - name: string; - input: Record; - viewer: Viewer | null; - baseUrl: string; - }): Promise { - const { app, page, block, name, viewer, baseUrl } = input; - - const staticData = { - app, - page: { id: page.id, route: page.route, path: '' }, - actionPageId: page.id, - blockId: block.id, - params: {}, - query: {}, - viewer, - menu: [], - baseUrl, - }; + private async dispatch( + ctx: BlockRenderContext, + block: AppLayoutBlock, + name: string, + input: Record, + ): Promise { + const staticData = blockStaticData(ctx, block.id); if (block.type === 'code') { - const actionContext = this.pageContextFactory.buildAction({ - ...staticData, - logs: [], - input: input.input, - }); + const actionContext = this.pageContextFactory.buildAction({ ...staticData, logs: [], input }); const { value } = await this.appCodeRuntime.runAction( block.data.source, name, @@ -221,7 +197,7 @@ export class AppActionsController { if (block.type === 'form' && name === 'submit') { const pageContext = this.pageContextFactory.build({ ...staticData, logs: [] }); return this.toFormOutcome( - await pageContext.workflows.submitForm(block.data.workflowId, input.input), + await pageContext.workflows.submitForm(block.data.workflowId, input), block.id, ); } @@ -229,7 +205,7 @@ export class AppActionsController { if (block.type === 'button' && block.data.target.kind === 'workflow' && name === 'run') { const pageContext = this.pageContextFactory.build({ ...staticData, logs: [] }); return this.toFormOutcome( - await pageContext.workflows.execute(block.data.target.workflowId, input.input), + await pageContext.workflows.execute(block.data.target.workflowId, input), block.id, ); } @@ -237,7 +213,7 @@ export class AppActionsController { if (block.type === 'table') { const pageContext = this.pageContextFactory.build({ ...staticData, logs: [] }); const handle = await pageContext.dataTables.get(block.data.source.dataTableId); - return await runTableAction({ handle, block, name, input: input.input }); + return await runTableAction({ handle, block, name, input, ctx }); } return { error: 'Action not found' }; diff --git a/packages/cli/src/modules/apps/actions/table-actions.ts b/packages/cli/src/modules/apps/actions/table-actions.ts index 96e2b41372a..e66aa9f7acd 100644 --- a/packages/cli/src/modules/apps/actions/table-actions.ts +++ b/packages/cli/src/modules/apps/actions/table-actions.ts @@ -1,5 +1,7 @@ import type { TableBlock } from '@n8n/api-types'; +import { visibleRows } from '../rendering/blocks/table-rows'; +import type { BlockRenderContext } from '../rendering/types'; import type { AppDataTableColumn, AppDataTableFilter, @@ -55,11 +57,14 @@ export async function runTableAction({ block, name, input, + ctx, }: { handle: AppDataTableHandle; block: TableBlock; name: string; input: Record; + /** The rendered page's context; its params decide which rows the block shows. */ + ctx: BlockRenderContext; }): Promise { const allowed = (name === 'delete' && block.data.deletable === true) || @@ -68,6 +73,8 @@ export async function runTableAction({ const id = Number(input.id); if (!Number.isInteger(id) || id <= 0) return { error: 'Invalid row id' }; + const shown = await visibleRows(handle, block, ctx); + if (!shown.some((row) => row.id === id)) return { error: 'Row not found' }; const filter: AppDataTableFilter = { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: id }], diff --git a/packages/cli/src/modules/apps/app.entity.ts b/packages/cli/src/modules/apps/app.entity.ts index 5a8c66e15f6..f8f6f797d67 100644 --- a/packages/cli/src/modules/apps/app.entity.ts +++ b/packages/cli/src/modules/apps/app.entity.ts @@ -23,6 +23,10 @@ export class App extends WithTimestampsAndStringId { @Column({ type: 'varchar', length: 16, default: 'public' }) auth: AppAuth; + /** TSX source of the shared components code blocks import from `app/components`. */ + @Column({ type: 'text', nullable: true }) + components: string | null; + @ManyToOne(() => Project) @JoinColumn({ name: 'projectId' }) project: Project; diff --git a/packages/cli/src/modules/apps/app.repository.ts b/packages/cli/src/modules/apps/app.repository.ts index ff1f05dd3fe..701907e8fef 100644 --- a/packages/cli/src/modules/apps/app.repository.ts +++ b/packages/cli/src/modules/apps/app.repository.ts @@ -28,7 +28,10 @@ export class AppRepository extends Repository { return await this.findBy({ projectId }); } - async updateApp(app: App, updates: Partial>) { + async updateApp( + app: App, + updates: Partial>, + ) { if ( updates.namespace !== undefined && updates.namespace !== app.namespace && diff --git a/packages/cli/src/modules/apps/apps.service.ts b/packages/cli/src/modules/apps/apps.service.ts index 6ff6d035d86..bccf9777f4c 100644 --- a/packages/cli/src/modules/apps/apps.service.ts +++ b/packages/cli/src/modules/apps/apps.service.ts @@ -15,7 +15,7 @@ import { type PageToRender, type RenderErrors, } from './rendering/page-renderer'; -import { sanitizeHtml } from './rendering/sanitize-html'; +import { sanitizeLayoutPreviewHtml } from './rendering/sanitize-html'; import type { InvalidPageContent } from './errors/app-content-invalid.error'; import { UrlService } from '@/services/url.service'; @@ -183,7 +183,11 @@ export class AppsService { } if (invalidPages.length > 0) throw new AppContentInvalidError({ pages: invalidPages }); - const snapshot: AppVersionSnapshot = { pages: snapshotPages, theme: app.theme ?? null }; + const snapshot: AppVersionSnapshot = { + pages: snapshotPages, + theme: app.theme ?? null, + components: app.components ?? null, + }; const version = await this.appVersionRepository.createFromSnapshot(appId, snapshot, userId); await this.appRepository.setActiveVersionId(app, version.id); @@ -230,7 +234,11 @@ export class AppsService { const input = await this.draftPageToRender(appId, pageId, undefined, {}); if (!input.layout) return { ownerPageId: null, html: null, errors: {} }; const { html, errors } = await renderLayout({ ...input, layout: input.layout }); - return { ownerPageId: input.layout.ownerPageId, html: sanitizeHtml(html), errors }; + return { + ownerPageId: input.layout.ownerPageId, + html: sanitizeLayoutPreviewHtml(html), + errors, + }; } private async draftPageToRender( diff --git a/packages/cli/src/modules/apps/rendering/__tests__/sanitize-html.test.ts b/packages/cli/src/modules/apps/rendering/__tests__/sanitize-html.test.ts index d0005b8a64d..49f1b530b64 100644 --- a/packages/cli/src/modules/apps/rendering/__tests__/sanitize-html.test.ts +++ b/packages/cli/src/modules/apps/rendering/__tests__/sanitize-html.test.ts @@ -1,4 +1,4 @@ -import { sanitizeHtml } from '../sanitize-html'; +import { sanitizeHtml, sanitizeLayoutPreviewHtml } from '../sanitize-html'; describe('sanitizeHtml', () => { test('keeps an allowed element with an allowed attribute', () => { @@ -61,3 +61,29 @@ describe('sanitizeHtml', () => { expect(sanitizeHtml('hi')).toBe('hi'); }); }); + +describe('sanitizeLayoutPreviewHtml', () => { + test('keeps form controls without anything that could submit or run them', () => { + expect( + sanitizeLayoutPreviewHtml( + "
", + ), + ).toBe( + '
', + ); + }); + + test('still strips script, style and iframe with their content', () => { + expect( + sanitizeLayoutPreviewHtml( + "
", + ), + ).toBe('
'); + }); + + test('keeps the html-block rules for everything else', () => { + expect(sanitizeLayoutPreviewHtml("x")).toBe( + 'x', + ); + }); +}); diff --git a/packages/cli/src/modules/apps/rendering/blocks/table-rows.ts b/packages/cli/src/modules/apps/rendering/blocks/table-rows.ts new file mode 100644 index 00000000000..ac3c17be649 --- /dev/null +++ b/packages/cli/src/modules/apps/rendering/blocks/table-rows.ts @@ -0,0 +1,42 @@ +import type { TableBlock } from '@n8n/api-types'; + +import { interpolate } from '../interpolate'; +import type { BlockRenderContext } from '../types'; + +import type { + AppDataTableFilter, + AppDataTableHandle, + AppDataTableRowResult, +} from '../../runtime/page-context.factory'; + +const interpolateFilter = ( + filter: AppDataTableFilter | undefined, + ctx: BlockRenderContext, +): AppDataTableFilter | undefined => { + if (!filter) return undefined; + return { + ...filter, + filters: filter.filters.map((f) => ({ + ...f, + value: typeof f.value === 'string' ? interpolate(f.value, ctx) : f.value, + })), + }; +}; + +/** + * The rows a table block shows on the rendered page: its filter with the + * page's params resolved, its sort and its limit. Row actions accept only + * rows this query returns, so a visitor can change what they can see. + */ +export const visibleRows = async ( + handle: AppDataTableHandle, + block: TableBlock, + ctx: BlockRenderContext, +): Promise => { + const { data } = await handle.getManyRowsAndCount({ + filter: interpolateFilter(block.data.filter, ctx), + sortBy: block.data.sortBy, + take: block.data.limit, + }); + return data; +}; diff --git a/packages/cli/src/modules/apps/rendering/blocks/table.renderer.ts b/packages/cli/src/modules/apps/rendering/blocks/table.renderer.ts index 40f30f2f684..d099447e1e7 100644 --- a/packages/cli/src/modules/apps/rendering/blocks/table.renderer.ts +++ b/packages/cli/src/modules/apps/rendering/blocks/table.renderer.ts @@ -1,12 +1,12 @@ import { Container } from '@n8n/di'; import { blockStaticData } from '../block-context'; -import { interpolate } from '../interpolate'; import { renderPartial } from '../templates'; import type { BlockRenderContext, BlockRenderer } from '../types'; +import { visibleRows } from './table-rows'; import { SYSTEM_COLUMNS, writableColumns } from '../../actions/table-actions'; -import type { AppDataTableColumn, AppDataTableFilter } from '../../runtime/page-context.factory'; +import type { AppDataTableColumn } from '../../runtime/page-context.factory'; import { PageContextFactory } from '../../runtime/page-context.factory'; const EDIT_QUERY_KEY = '_edit'; @@ -34,20 +34,6 @@ const formatCell = (value: unknown): string => { const formatInputValue = (value: unknown, type: AppDataTableColumn['type']): string => type === 'date' && value instanceof Date ? value.toISOString().slice(0, 16) : formatCell(value); -const interpolateFilter = ( - filter: AppDataTableFilter | undefined, - ctx: BlockRenderContext, -): AppDataTableFilter | undefined => { - if (!filter) return undefined; - return { - ...filter, - filters: filter.filters.map((f) => ({ - ...f, - value: typeof f.value === 'string' ? interpolate(f.value, ctx) : f.value, - })), - }; -}; - const ACTION_STATUS_KEYS = ['_form', '_status', '_message']; /** The page's own URL with `_edit` set (or removed when `edit` is undefined) and the last action's status dropped. */ @@ -69,11 +55,7 @@ export const tableBlockRenderer: BlockRenderer<'table'> = { }); const handle = await pageContext.dataTables.get(block.data.source.dataTableId); - const { data: rows } = await handle.getManyRowsAndCount({ - filter: interpolateFilter(block.data.filter, ctx), - sortBy: block.data.sortBy, - take: block.data.limit, - }); + const rows = await visibleRows(handle, block, ctx); const { editable = false, deletable = false } = block.data; const editPrefix = `${block.id}:`; diff --git a/packages/cli/src/modules/apps/rendering/sanitize-html.ts b/packages/cli/src/modules/apps/rendering/sanitize-html.ts index 9292c4873f7..217b1100b78 100644 --- a/packages/cli/src/modules/apps/rendering/sanitize-html.ts +++ b/packages/cli/src/modules/apps/rendering/sanitize-html.ts @@ -89,11 +89,39 @@ const INLINE_TAGS: Record = { br: [], }; -const sanitize = (dirty: string, whiteList: Record): string => +/** + * Form controls the layout preview keeps, inert: no `action`, `formaction` + * or handler attribute is listed, so nothing in the editor can submit or run. + */ +const LAYOUT_PREVIEW_TAGS: Record = { + ...ALLOWED_TAGS, + form: GLOBAL_ATTRS, + label: [...GLOBAL_ATTRS, 'for'], + input: [ + ...GLOBAL_ATTRS, + 'type', + 'name', + 'value', + 'placeholder', + 'disabled', + 'checked', + 'required', + 'step', + ], + select: [...GLOBAL_ATTRS, 'name', 'disabled', 'required', 'multiple'], + option: [...GLOBAL_ATTRS, 'value', 'selected'], + textarea: [...GLOBAL_ATTRS, 'name', 'placeholder', 'disabled', 'required', 'rows'], +}; + +const sanitize = ( + dirty: string, + whiteList: Record, + stripBodyOf = ['script', 'style', 'iframe', 'form'], +): string => xss(dirty, { whiteList, stripIgnoreTag: true, - stripIgnoreTagBody: ['script', 'style', 'iframe', 'form'], + stripIgnoreTagBody: stripBodyOf, onTagAttr(_tag, name, value) { if (name === 'href') return isAllowedHref(value) ? undefined : ''; if (name === 'src') return isAllowedSrc(value) ? undefined : ''; @@ -107,5 +135,12 @@ const sanitize = (dirty: string, whiteList: Record): string => */ export const sanitizeHtml = (dirty: string): string => sanitize(dirty, ALLOWED_TAGS); +/** + * Sanitizes a rendered layout for the editor's own origin, where it is inserted + * with `v-html`: everything the `html` block may keep plus inert form controls. + */ +export const sanitizeLayoutPreviewHtml = (dirty: string): string => + sanitize(dirty, LAYOUT_PREVIEW_TAGS, ['script', 'style', 'iframe']); + /** Sanitizes the text of a `paragraph`, `header` or `list` block down to inline formatting. */ export const sanitizeInlineHtml = (dirty: string): string => sanitize(dirty, INLINE_TAGS); diff --git a/packages/cli/src/modules/apps/rendering/styles/app-chat.css b/packages/cli/src/modules/apps/rendering/styles/app-chat.css new file mode 100644 index 00000000000..85d90267379 --- /dev/null +++ b/packages/cli/src/modules/apps/rendering/styles/app-chat.css @@ -0,0 +1 @@ +/* Filled by the agent-chat block work; imported from app.css. */ diff --git a/packages/cli/src/modules/apps/rendering/styles/app.css b/packages/cli/src/modules/apps/rendering/styles/app.css index 352ac4e417f..6683d135b87 100644 --- a/packages/cli/src/modules/apps/rendering/styles/app.css +++ b/packages/cli/src/modules/apps/rendering/styles/app.css @@ -18,6 +18,7 @@ @import '@n8n/design-system/theme.css' layer(base); @import 'tailwindcss/theme' layer(theme); @import 'tailwindcss/utilities' layer(utilities); +@import './app-chat.css'; /* Scanned for utility classes; table/form/button partials belong to another workstream. */ @source '../../../../../templates/app-page.handlebars'; diff --git a/packages/cli/src/modules/apps/runtime/__tests__/app-code-runtime.test.ts b/packages/cli/src/modules/apps/runtime/__tests__/app-code-runtime.test.ts index 8bdd987e955..9620da1e237 100644 --- a/packages/cli/src/modules/apps/runtime/__tests__/app-code-runtime.test.ts +++ b/packages/cli/src/modules/apps/runtime/__tests__/app-code-runtime.test.ts @@ -71,7 +71,7 @@ describe('AppCodeRuntime', () => { actionPageId: 'parent-page', }); expect(value).toBe( - 'Home,clients* https://n8n.example.com/apps/my-app/_actions/parent-page/block-1/go', + 'Home,clients* https://n8n.example.com/apps/my-app/_actions/parent-page/block-1/go?_path=%2Fapps%2Fmy-app', ); }); diff --git a/packages/cli/src/modules/apps/runtime/__tests__/page-context.factory.test.ts b/packages/cli/src/modules/apps/runtime/__tests__/page-context.factory.test.ts index 53b5f0504ed..19527fe4dab 100644 --- a/packages/cli/src/modules/apps/runtime/__tests__/page-context.factory.test.ts +++ b/packages/cli/src/modules/apps/runtime/__tests__/page-context.factory.test.ts @@ -94,19 +94,21 @@ describe('PageContextFactory', () => { }); describe('actionUrl', () => { - it('builds a URL scoped to the given block, with no query', () => { + it('builds a URL scoped to the given block that carries the rendered page path', () => { const { factory } = buildFactory(); - const ctx = factory.build(buildInput()); + const ctx = factory.build( + buildInput({ page: { id: 'page-1', route: ':id', path: '/apps/my-app/clients/42' } }), + ); expect(ctx.actionUrl('submit')).toBe( - 'https://n8n.example.com/apps/my-app/_actions/page-1/block-1/submit', + 'https://n8n.example.com/apps/my-app/_actions/page-1/block-1/submit?_path=%2Fapps%2Fmy-app%2Fclients%2F42', ); }); it('names the page that owns the block, not the page being rendered', () => { const { factory } = buildFactory(); const ctx = factory.build(buildInput({ actionPageId: 'parent-page' })); - expect(ctx.actionUrl('go')).toBe( - 'https://n8n.example.com/apps/my-app/_actions/parent-page/block-1/go', + expect(ctx.actionUrl('go')).toMatch( + /^https:\/\/n8n\.example\.com\/apps\/my-app\/_actions\/parent-page\/block-1\/go\?_path=/, ); }); }); diff --git a/packages/cli/src/modules/apps/runtime/action-url.ts b/packages/cli/src/modules/apps/runtime/action-url.ts new file mode 100644 index 00000000000..4bf2cc90332 --- /dev/null +++ b/packages/cli/src/modules/apps/runtime/action-url.ts @@ -0,0 +1,20 @@ +export type ActionUrlInput = { + baseUrl: string; + app: { namespace: string }; + /** The page whose `content` or `layout` holds the block. */ + actionPageId: string; + blockId: string; + /** The page being rendered; its path travels as `_path` so the action knows the route params and where to redirect. */ + page: { path: string }; +}; + +/** The action URL around its `name`, so the isolate can build it without a host call. */ +export const actionUrlParts = (input: ActionUrlInput) => ({ + prefix: `${input.baseUrl}/apps/${input.app.namespace}/_actions/${input.actionPageId}/${input.blockId}/`, + suffix: `?_path=${encodeURIComponent(input.page.path)}`, +}); + +export const buildActionUrl = (input: ActionUrlInput, name: string): string => { + const { prefix, suffix } = actionUrlParts(input); + return `${prefix}${name}${suffix}`; +}; diff --git a/packages/cli/src/modules/apps/runtime/app-code-runtime.ts b/packages/cli/src/modules/apps/runtime/app-code-runtime.ts index 070bb9f4be5..d73bdc21f1a 100644 --- a/packages/cli/src/modules/apps/runtime/app-code-runtime.ts +++ b/packages/cli/src/modules/apps/runtime/app-code-runtime.ts @@ -5,6 +5,7 @@ import { createHash } from 'node:crypto'; import { UserError } from 'n8n-workflow'; import { z } from 'zod'; +import { actionUrlParts } from './action-url'; import { AppIsolatePool, type AppIsolateSlot } from './app-isolate-pool'; import type { AppActionContext, AppPageContext, PageContextInput } from './page-context.factory'; @@ -181,8 +182,7 @@ function __buildCtx(staticData) { get: function (name) { return __call('credentials.get', [name]); }, }, actionUrl: function (name) { - return staticData.baseUrl + '/apps/' + staticData.app.namespace + '/_actions/' - + staticData.actionPageId + '/' + staticData.blockId + '/' + name; + return staticData.actionUrl.prefix + name + staticData.actionUrl.suffix; }, fetch: async function (url, init) { var res = await __call('fetch', [url, init || {}]); @@ -424,11 +424,15 @@ export class AppCodeRuntime { }); const value = (await Promise.race([ - context.evalClosure(runScript, [staticData], { - timeout: CALL_TIMEOUT_MS, - arguments: { copy: true }, - result: { promise: true, copy: true }, - }), + context.evalClosure( + runScript, + [{ ...staticData, actionUrl: actionUrlParts(staticData) }], + { + timeout: CALL_TIMEOUT_MS, + arguments: { copy: true }, + result: { promise: true, copy: true }, + }, + ), timeout, ])) as T; diff --git a/packages/cli/src/modules/apps/runtime/page-context.factory.ts b/packages/cli/src/modules/apps/runtime/page-context.factory.ts index b8ca3af1efe..fb3e1fa6f03 100644 --- a/packages/cli/src/modules/apps/runtime/page-context.factory.ts +++ b/packages/cli/src/modules/apps/runtime/page-context.factory.ts @@ -27,6 +27,7 @@ import { WorkflowRunner } from '@/workflow-runner'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import type { MenuItem } from '../serving/page-menu'; +import { buildActionUrl } from './action-url'; import { DataTableProxyService } from '../../data-table/data-table-proxy.service'; import { DataTableService } from '../../data-table/data-table.service'; import { @@ -264,8 +265,7 @@ export class PageContextFactory { dataTables, workflows, credentials, - actionUrl: (name) => - `${input.baseUrl}/apps/${app.namespace}/_actions/${input.actionPageId}/${input.blockId}/${name}`, + actionUrl: (name) => buildActionUrl(input, name), fetch: async (url, init) => await this.fetch(url, init), log: (...args) => { input.logs.push(args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')); diff --git a/packages/cli/src/modules/apps/serving/__tests__/app-token.service.test.ts b/packages/cli/src/modules/apps/serving/__tests__/app-token.service.test.ts index 116feb2adb5..2120ebc7037 100644 --- a/packages/cli/src/modules/apps/serving/__tests__/app-token.service.test.ts +++ b/packages/cli/src/modules/apps/serving/__tests__/app-token.service.test.ts @@ -52,16 +52,35 @@ describe('AppTokenService', () => { expiresIn: 600, }); expect(jwtService.sign).toHaveBeenCalledWith( - { kind: 'app-access', appId: 'app-1', viewerId: 'user-1' }, + { kind: 'app-access', appId: 'app-1', viewerId: 'user-1', mode: 'published' }, { expiresIn: 600 }, ); expect(service.verifyAccess(pair!.accessToken)).toEqual({ appId: 'app-1', viewerId: 'user-1', + mode: 'published', }); await expect(service.exchangeCode(code)).resolves.toBeNull(); }); + test('carries a draft mode from the code record into the access token', async () => { + const code = await service.issueCode({ ...record, mode: 'draft' }); + + const pair = await service.exchangeCode(code); + + expect(service.verifyAccess(pair!.accessToken)).toMatchObject({ mode: 'draft' }); + }); + + test('treats an access token without a mode as published', () => { + const token = JSON.stringify({ kind: 'app-access', appId: 'app-1', viewerId: null }); + + expect(service.verifyAccess(token)).toEqual({ + appId: 'app-1', + viewerId: null, + mode: 'published', + }); + }); + test('rejects an unknown code', async () => { await expect(service.exchangeCode('nope')).resolves.toBeNull(); }); diff --git a/packages/cli/src/modules/apps/serving/__tests__/viewer.service.test.ts b/packages/cli/src/modules/apps/serving/__tests__/viewer.service.test.ts index 2061bd77aef..23c602a81f6 100644 --- a/packages/cli/src/modules/apps/serving/__tests__/viewer.service.test.ts +++ b/packages/cli/src/modules/apps/serving/__tests__/viewer.service.test.ts @@ -10,7 +10,7 @@ import { ViewerService } from '../viewer.service'; const user = mock({ id: 'user-1', email: 'ada@example.com', firstName: 'Ada' }); -const payload = { appId: 'app-1', viewerId: 'user-1' }; +const payload = { appId: 'app-1', viewerId: 'user-1', mode: 'published' as const }; const request = (overrides: { authorization?: string; cookie?: string } = {}) => mock({ diff --git a/packages/cli/src/modules/apps/serving/app-request-auth.ts b/packages/cli/src/modules/apps/serving/app-request-auth.ts new file mode 100644 index 00000000000..8233d6d9c49 --- /dev/null +++ b/packages/cli/src/modules/apps/serving/app-request-auth.ts @@ -0,0 +1,57 @@ +import type { AppVersionSnapshot } from '@n8n/api-types'; +import { Service } from '@n8n/di'; +import type { Request } from 'express'; + +import type { App } from '../app.entity'; +import { AppRepository } from '../app.repository'; +import { AppVersionRepository } from '../app-version.repository'; +import { AppTokenService, bearerToken, type AppAccessPayload } from './app-token.service'; +import { ViewerService, type Viewer } from './viewer.service'; + +export type AuthorizedAppRequest = { + app: App; + viewer: Viewer | null; + payload: AppAccessPayload; + pages: AppVersionSnapshot['pages']; + components: string | null; +}; + +export type RejectedAppRequest = { status: 401 | 404; error: string }; + +/** + * The credential check every endpoint a served page calls back to shares: + * the access token in the `Authorization` header is the only credential, it + * must belong to the App named in the URL, and an `n8n` App needs a viewer. + */ +@Service() +export class AppRequestAuth { + constructor( + private readonly appTokenService: AppTokenService, + private readonly appRepository: AppRepository, + private readonly appVersionRepository: AppVersionRepository, + private readonly viewerService: ViewerService, + ) {} + + async authorize( + req: Request, + namespace: string, + ): Promise { + const token = bearerToken(req); + const payload = token === undefined ? null : this.appTokenService.verifyAccess(token); + const app = payload ? await this.appRepository.findByNamespace(namespace) : null; + if (!app || !payload || app.id !== payload.appId) { + return { status: 401, error: 'Invalid access token' }; + } + + const viewer = await this.viewerService.fromToken(payload); + if (app.auth === 'n8n' && !viewer) return { status: 401, error: 'Sign in required' }; + + const version = app.activeVersionId + ? await this.appVersionRepository.findSnapshot(app.activeVersionId) + : null; + if (!version) return { status: 404, error: 'This app has no published version' }; + + const { pages, components } = version.snapshot; + return { app, viewer, payload, pages, components }; + } +} diff --git a/packages/cli/src/modules/apps/serving/app-token.service.ts b/packages/cli/src/modules/apps/serving/app-token.service.ts index 57064edc930..cc9ca8e68c2 100644 --- a/packages/cli/src/modules/apps/serving/app-token.service.ts +++ b/packages/cli/src/modules/apps/serving/app-token.service.ts @@ -7,18 +7,24 @@ import { AuthService } from '@/auth/auth.service'; import { CacheService } from '@/services/cache/cache.service'; import { JwtService } from '@/services/jwt.service'; +/** `published` serves the active version; `draft` (editor preview) the current Page rows. */ +const accessModeSchema = z.enum(['published', 'draft']); +export type AppAccessMode = z.infer; + /** Who a code or refresh token stands for. `sessionToken` is the raw n8n auth cookie * captured at navigation; a refresh re-validates it so an n8n logout ends the app session. */ export type AppSessionRecord = { appId: string; viewerId: string | null; sessionToken: string | null; + mode?: AppAccessMode; }; const accessPayloadSchema = z.object({ kind: z.literal('app-access'), appId: z.string().min(1), viewerId: z.string().nullable(), + mode: accessModeSchema.default('published'), }); export type AppAccessPayload = Omit, 'kind'>; @@ -81,15 +87,20 @@ export class AppTokenService { } const parsed = accessPayloadSchema.safeParse(decoded); if (!parsed.success) return null; - const { appId, viewerId } = parsed.data; - return { appId, viewerId }; + const { appId, viewerId, mode } = parsed.data; + return { appId, viewerId, mode }; } private async issuePair(record: AppSessionRecord): Promise { const refreshToken = randomBytes(32).toString('hex'); await this.cacheService.set(`apps:refresh:${refreshToken}`, record, REFRESH_TTL_MS); const accessToken = this.jwtService.sign( - { kind: 'app-access', appId: record.appId, viewerId: record.viewerId }, + { + kind: 'app-access', + appId: record.appId, + viewerId: record.viewerId, + mode: record.mode ?? 'published', + }, { expiresIn: ACCESS_TTL_SECONDS }, ); return { accessToken, refreshToken, expiresIn: ACCESS_TTL_SECONDS }; diff --git a/packages/cli/test/integration/apps/app-actions.controller.integration.test.ts b/packages/cli/test/integration/apps/app-actions.controller.integration.test.ts index d4d4f35ffec..ab5a426d421 100644 --- a/packages/cli/test/integration/apps/app-actions.controller.integration.test.ts +++ b/packages/cli/test/integration/apps/app-actions.controller.integration.test.ts @@ -76,6 +76,7 @@ async function publishAppWithBlocks(blocks: AppBlock[], namespace = 'acme') { const snapshot: AppVersionSnapshot = { pages: [{ id: page.id, route: page.route, parentPageId: null, content: blocks, layout: null }], theme: null, + components: null, }; const version = await appVersionRepository.createFromSnapshot(app.id, snapshot, owner.id); await appRepository.setActiveVersionId(app, version.id); @@ -110,11 +111,11 @@ describe('POST /apps/:namespace/_actions/:pageId/:blockId/:name', () => { expect(response.body).toEqual({ data: { hello: 'Ada' } }); }); - test('builds ctx.actionUrl() without a query', async () => { + test('builds ctx.actionUrl() carrying the rendered page path, and falls back to the app root', async () => { const { page, bearer } = await publishAppWithBlocks([ codeBlock( 'block-1', - "export function render() { return ''; } export const actions = { go: (ctx) => ({ data: ctx.actionUrl('go') }) };", + "export function render() { return ''; } export const actions = { go: (ctx) => ({ data: { url: ctx.actionUrl('go'), path: ctx.page.path } }) };", ), ]); @@ -124,9 +125,56 @@ describe('POST /apps/:namespace/_actions/:pageId/:blockId/:name', () => { .send({}) .expect(200); - expect(response.body.data).toMatch( - new RegExp(`^http://[^/]+${actionUrl('acme', page.id, 'block-1', 'go')}$`), + expect(response.body.data).toEqual({ + url: expect.stringMatching( + new RegExp( + `^http://[^/]+${actionUrl('acme', page.id, 'block-1', 'go')}\\?_path=%2Fapps%2Facme$`, + ), + ), + path: '/apps/acme', + }); + }); + + test('resolves the rendered page from _path: route params and redirect target', async () => { + const { app, page, bearer } = await publishAppWithBlocks([]); + const clients = await pageRepository.createPage(app.id, null, 'clients'); + const client = await pageRepository.createPage(app.id, clients.id, ':id'); + const block = codeBlock( + 'block-1', + "export function render() { return ''; } export const actions = { go: (ctx) => ({ data: { params: ctx.params, path: ctx.page.path } }), back: () => ({ redirect: '?_status=ok' }) };", ); + const snapshot: AppVersionSnapshot = { + pages: [ + { id: page.id, route: '', parentPageId: null, content: [], layout: null }, + { id: clients.id, route: 'clients', parentPageId: null, content: [], layout: null }, + { id: client.id, route: ':id', parentPageId: clients.id, content: [block], layout: null }, + ], + theme: null, + components: null, + }; + const version = await appVersionRepository.createFromSnapshot(app.id, snapshot, owner.id); + await appRepository.setActiveVersionId(app, version.id); + + const response = await visitor + .post(`${actionUrl('acme', client.id, 'block-1', 'go')}?_path=%2Fapps%2Facme%2Fclients%2F42`) + .set('Authorization', bearer) + .send({}) + .expect(200); + expect(response.body.data).toEqual({ params: { id: '42' }, path: '/apps/acme/clients/42' }); + + await visitor + .post(`${actionUrl('acme', client.id, 'block-1', 'back')}?_path=/apps/acme/clients/42`) + .set('Authorization', bearer) + .send({}) + .expect(303) + .expect('Location', '/apps/acme/clients/42?_status=ok'); + + await visitor + .post(`${actionUrl('acme', client.id, 'block-1', 'back')}?_path=/apps/other/x`) + .set('Authorization', bearer) + .send({}) + .expect(303) + .expect('Location', '/apps/acme?_status=ok'); }); test('redirects (303) when the action returns { redirect }', async () => { @@ -244,6 +292,7 @@ describe('POST /apps/:namespace/_actions/:pageId/:blockId/:name', () => { { id: child.id, route: 'child', parentPageId: page.id, content: null, layout: null }, ], theme: null, + components: null, }; const version = await appVersionRepository.createFromSnapshot(app.id, snapshot, owner.id); await appRepository.setActiveVersionId(app, version.id); @@ -256,7 +305,7 @@ describe('POST /apps/:namespace/_actions/:pageId/:blockId/:name', () => { expect(response.body.data.menu).toEqual([]); expect(response.body.data.url).toMatch( - new RegExp(`${actionUrl('acme', page.id, 'layout-code', 'go')}$`), + new RegExp(`${actionUrl('acme', page.id, 'layout-code', 'go')}\\?_path=`), ); }); @@ -318,6 +367,68 @@ describe('POST /apps/:namespace/_actions/:pageId/:blockId/:name', () => { expect(await listRows()).toEqual([]); }); + test('refuses a row the table does not show on the rendered page', async () => { + const { app, page, bearer } = await publishAppWithBlocks([]); + const dataTable = await createDataTable(ownerProject, { + columns: [{ name: 'owner', type: 'string' }], + data: [{ owner: 'ada' }, { owner: 'grace' }], + }); + const block: AppBlock = { + id: 'table-1', + type: 'table', + data: { + source: { dataTableId: dataTable.id }, + limit: 50, + deletable: true, + filter: { + type: 'and', + filters: [{ columnName: 'owner', condition: 'eq', value: '{{ params.owner }}' }], + }, + }, + }; + const owners = await pageRepository.createPage(app.id, null, 'owners'); + const ownerPage = await pageRepository.createPage(app.id, owners.id, ':owner'); + const snapshot: AppVersionSnapshot = { + pages: [ + { id: page.id, route: '', parentPageId: null, content: [], layout: null }, + { id: owners.id, route: 'owners', parentPageId: null, content: [], layout: null }, + { + id: ownerPage.id, + route: ':owner', + parentPageId: owners.id, + content: [block], + layout: null, + }, + ], + theme: null, + components: null, + }; + const version = await appVersionRepository.createFromSnapshot(app.id, snapshot, owner.id); + await appRepository.setActiveVersionId(app, version.id); + const listRows = async () => + (await Container.get(DataTableService).getManyRowsAndCount(dataTable.id, ownerProject.id, {})) + .data; + const [ada, grace] = await listRows(); + const deleteUrl = `${actionUrl('acme', ownerPage.id, 'table-1', 'delete')}?_path=/apps/acme/owners/ada`; + + const refused = await visitor + .post(deleteUrl) + .set('Authorization', bearer) + .send({ id: String(grace.id) }) + .expect(400); + expect(refused.body).toEqual({ error: 'Row not found' }); + expect(await listRows()).toHaveLength(2); + + await visitor + .post(deleteUrl) + .set('Authorization', bearer) + .type('form') + .send({ id: String(ada.id) }) + .expect(303) + .expect('Location', '/apps/acme/owners/ada?_form=table-1&_status=ok'); + expect(await listRows()).toEqual([expect.objectContaining({ owner: 'grace' })]); + }); + test('refuses a table action the block does not allow', async () => { const { block, listRows } = await createTableWithBlock({ editable: true }); const { page, bearer } = await publishAppWithBlocks([block]); diff --git a/packages/cli/test/integration/apps/app-auth.integration.test.ts b/packages/cli/test/integration/apps/app-auth.integration.test.ts index 9504a767205..211f113c727 100644 --- a/packages/cli/test/integration/apps/app-auth.integration.test.ts +++ b/packages/cli/test/integration/apps/app-auth.integration.test.ts @@ -67,6 +67,7 @@ async function publishApp(blocks: AppBlock[], auth: 'public' | 'n8n', namespace const snapshot: AppVersionSnapshot = { pages: [{ id: page.id, route: '', parentPageId: null, content: blocks, layout: null }], theme: null, + components: null, }; const version = await appVersionRepository.createFromSnapshot(app.id, snapshot, owner.id); await appRepository.setActiveVersionId(app, version.id); @@ -313,7 +314,9 @@ describe('GET /rest/projects/:projectId/apps/:appId/pages/:pageId/preview', () = .get(`/projects/${ownerProject.id}/apps/${app.id}/pages/${page.id}/preview`) .expect(200); - expect(response.text).toMatch(new RegExp(`/apps/acme/_actions/${page.id}/block-1/go"`)); + expect(response.text).toMatch( + new RegExp(`/apps/acme/_actions/${page.id}/block-1/go\\?_path=%2Fapps%2Facme"`), + ); expectNoToken(response.text); }); }); diff --git a/packages/cli/test/integration/apps/app-serving.controller.integration.test.ts b/packages/cli/test/integration/apps/app-serving.controller.integration.test.ts index 8bba9d08aaf..c7c1fe552d9 100644 --- a/packages/cli/test/integration/apps/app-serving.controller.integration.test.ts +++ b/packages/cli/test/integration/apps/app-serving.controller.integration.test.ts @@ -54,6 +54,7 @@ const publish = async (app: Awaited>) => { layout: page.layout as AppLayout | null, })), theme: null, + components: null, }, owner.id, ); diff --git a/packages/cli/test/integration/apps/apps.controller.integration.test.ts b/packages/cli/test/integration/apps/apps.controller.integration.test.ts index 2f02255cef1..e1fdce282a6 100644 --- a/packages/cli/test/integration/apps/apps.controller.integration.test.ts +++ b/packages/cli/test/integration/apps/apps.controller.integration.test.ts @@ -239,7 +239,8 @@ describe('GET /projects/:projectId/apps/:appId/pages/:pageId/preview', () => { const errors = JSON.parse(response.headers['x-n8n-app-render-errors']); expect(Object.keys(errors)).toEqual(['broken']); - expect(errors.broken).toMatch(/Parse error[^]*\nat /); + expect(errors.broken).toMatch(/Parse error/); + expect(errors.broken).not.toMatch(/\nat /); expect(response.text).toContain('Still here'); expect(response.text).not.toContain('Parse error'); }); diff --git a/packages/cli/test/migration/1789030187576-add-components-to-app.test.ts b/packages/cli/test/migration/1789030187576-add-components-to-app.test.ts new file mode 100644 index 00000000000..af360061ab6 --- /dev/null +++ b/packages/cli/test/migration/1789030187576-add-components-to-app.test.ts @@ -0,0 +1,152 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + undoLastSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { randomUUID } from 'node:crypto'; + +const MIGRATION_NAME = 'AddComponentsToApp1789030187576'; +const APP_TABLE = 'app'; +const PAGE_TABLE = 'page'; + +describe('AddComponentsToApp migration', () => { + let dataSource: DataSource; + let appId: string; + + async function withContext(fn: (context: TestMigrationContext) => Promise): Promise { + const context = createTestMigrationContext(dataSource); + try { + return await fn(context); + } finally { + await context.queryRunner.release(); + } + } + + beforeAll(async () => { + await Container.get(DbConnection).init(); + dataSource = Container.get(DataSource); + }); + + beforeEach(async () => { + await withContext(async (context) => await context.queryRunner.clearDatabase()); + await initDbUpToMigration(MIGRATION_NAME); + dataSource = Container.get(DataSource); + await withContext(async (context) => { + const projectId = await insertProject(context); + appId = await insertApp(context, projectId); + }); + }); + + afterAll(async () => { + await Container.get(DbConnection).close(); + }); + + async function insertProject(context: TestMigrationContext) { + const table = context.escape.tableName('project'); + const now = new Date(); + const id = randomUUID(); + await context.runQuery( + `INSERT INTO ${table} ("id", "name", "type", "customTelemetryTags", "createdAt", "updatedAt") + VALUES (:id, :name, :type, :tags, :createdAt, :updatedAt)`, + { id, name: 'Project', type: 'team', tags: '[]', createdAt: now, updatedAt: now }, + ); + return id; + } + + async function insertApp(context: TestMigrationContext, projectId: string) { + const table = context.escape.tableName(APP_TABLE); + const now = new Date(); + const id = randomUUID(); + await context.runQuery( + `INSERT INTO ${table} ("id", "name", "namespace", "projectId", "createdAt", "updatedAt") + VALUES (:id, :name, :namespace, :projectId, :createdAt, :updatedAt)`, + { id, name: 'My App', namespace: 'my-app', projectId, createdAt: now, updatedAt: now }, + ); + return id; + } + + async function insertPage(context: TestMigrationContext, route: string) { + const table = context.escape.tableName(PAGE_TABLE); + const now = new Date(); + const id = randomUUID(); + await context.runQuery( + `INSERT INTO ${table} ("id", "appId", "route", "createdAt", "updatedAt") + VALUES (:id, :appId, :route, :createdAt, :updatedAt)`, + { id, appId, route, createdAt: now, updatedAt: now }, + ); + return id; + } + + async function getComponents(context: TestMigrationContext) { + const table = context.escape.tableName(APP_TABLE); + const [row] = await context.runQuery>( + `SELECT "components" FROM ${table} WHERE "id" = :id`, + { id: appId }, + ); + return row.components; + } + + /** Column presence via the catalog: on SQLite a quoted unknown column is a string literal, not an error. */ + async function hasColumn(context: TestMigrationContext, column: string) { + if (context.isSqlite) { + const rows: Array<{ name: string }> = await context.queryRunner.query( + `PRAGMA table_info(${context.escape.tableName(APP_TABLE)})`, + ); + return rows.some((row) => row.name === column); + } + const rows: Array<{ column_name: string }> = await context.queryRunner.query( + 'SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = $2', + [`${context.tablePrefix}${APP_TABLE}`, column], + ); + return rows.length > 0; + } + + it('adds a nullable components column that existing apps read as null', async () => { + await runSingleMigration(MIGRATION_NAME); + + await withContext(async (context) => { + expect(await getComponents(context)).toBeNull(); + }); + }); + + it('keeps the pages of existing apps', async () => { + const pageId = await withContext(async (context) => await insertPage(context, 'home')); + + await runSingleMigration(MIGRATION_NAME); + + await withContext(async (context) => { + const table = context.escape.tableName(PAGE_TABLE); + const rows = await context.runQuery>(`SELECT "id" FROM ${table}`); + expect(rows).toEqual([{ id: pageId }]); + }); + }); + + it('stores a components source', async () => { + await runSingleMigration(MIGRATION_NAME); + const source = 'export const Card = (props) =>
{props.children}
;'; + + await withContext(async (context) => { + const table = context.escape.tableName(APP_TABLE); + await context.runQuery(`UPDATE ${table} SET "components" = :source WHERE "id" = :id`, { + source, + id: appId, + }); + expect(await getComponents(context)).toBe(source); + }); + }); + + it('drops the column on revert', async () => { + await runSingleMigration(MIGRATION_NAME); + await undoLastSingleMigration(); + dataSource = Container.get(DataSource); + + await withContext(async (context) => { + expect(await hasColumn(context, 'components')).toBe(false); + }); + }); +});