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 <noreply@anthropic.com>
This commit is contained in:
Dimitri Lavrenük 2026-09-10 11:13:58 +02:00
parent 7fc3f4a72b
commit 76385b3d3e
No known key found for this signature in database
54 changed files with 918 additions and 177 deletions

View File

@ -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

View File

@ -6,6 +6,7 @@
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| activeVersionId | varchar(36) | | true | | [public.app_version](public.app_version.md) | app_version served at /apps/\<namespace\>; 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -6,7 +6,7 @@
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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(),
}) {}

View File

@ -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(),
}) {}

View File

@ -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';

View File

@ -47,6 +47,11 @@ describe('appBlockSchema', () => {
type: 'code',
data: { source: 'export function render() { return "<p>hi</p>"; }' },
},
{
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);
});

View File

@ -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 = () => <div />;',
});
expect(parsed.components).toContain('Card');
});
});

View File

@ -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);
});

View File

@ -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<typeof formBlockSchema>;
export type ButtonBlock = z.infer<typeof buttonBlockSchema>;
export type HtmlBlock = z.infer<typeof htmlBlockSchema>;
export type CodeBlock = z.infer<typeof codeBlockSchema>;
export type AgentChatBlock = z.infer<typeof agentChatBlockSchema>;

View File

@ -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[] = [];

View File

@ -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<typeof appVersionSnapshotSchema>;

View File

@ -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<typeof appThemeSchema>;
/** 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']);

View File

@ -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 });
}
}

View File

@ -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;
}

View File

@ -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 |

View File

@ -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`

View File

@ -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 ────────────────────────────────────────────────────────────

View File

@ -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 {

View File

@ -1079,7 +1079,7 @@ export interface InstanceAiAppService {
): Promise<PageSummary & { content: AppContent | null; layout: AppLayout | null }>;
createPage(
appId: string,
input: { route: string; parentPageId?: string; content?: AppContent },
input: { route: string; parentPageId?: string; content?: AppContent; layout?: AppLayout },
): Promise<PageSummary>;
updatePage(
appId: string,

View File

@ -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 () => "<form action='/x' onsubmit='evil()'><input name='q' /></form>",
});
const app = mock<App>({
id: 'app-1',
@ -20,6 +26,7 @@ const app = mock<App>({
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>({ ...app, components: 'export const Card = () => <div />;' }),
);
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('<form><input name="q" /></form>');
});
test('reports a layout block whose renderer throws, keyed by block id', async () => {
const index = page({
id: 'index',

View File

@ -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['data']> = {}): 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<AppDataTableHandle>();
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' } });

View File

@ -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<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {};
@ -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<string, string>; 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<string, unknown> | undefined) ?? {});
const rawPath = (req.body as Record<string, unknown> | 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<string, unknown>;
viewer: Viewer | null;
baseUrl: string;
}): Promise<ActionOutcome> {
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<string, unknown>,
): Promise<ActionOutcome> {
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' };

View File

@ -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<string, unknown>;
/** The rendered page's context; its params decide which rows the block shows. */
ctx: BlockRenderContext;
}): Promise<TableActionOutcome> {
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 }],

View File

@ -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;

View File

@ -28,7 +28,10 @@ export class AppRepository extends Repository<App> {
return await this.findBy({ projectId });
}
async updateApp(app: App, updates: Partial<Pick<App, 'name' | 'namespace' | 'theme' | 'auth'>>) {
async updateApp(
app: App,
updates: Partial<Pick<App, 'name' | 'namespace' | 'theme' | 'auth' | 'components'>>,
) {
if (
updates.namespace !== undefined &&
updates.namespace !== app.namespace &&

View File

@ -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(

View File

@ -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('<marquee>hi</marquee>')).toBe('hi');
});
});
describe('sanitizeLayoutPreviewHtml', () => {
test('keeps form controls without anything that could submit or run them', () => {
expect(
sanitizeLayoutPreviewHtml(
"<form method='POST' action='/x' onsubmit='evil()'><label for='a'>A</label><input id='a' name='a' type='text' required onfocus='evil()' /><select name='s' multiple><option value='1' selected>1</option></select><textarea name='t' rows='2'></textarea><button type='submit' formaction='/y'>Go</button></form>",
),
).toBe(
'<form><label for="a">A</label><input id="a" name="a" type="text" required /><select name="s" multiple><option value="1" selected>1</option></select><textarea name="t" rows="2"></textarea><button type="submit">Go</button></form>',
);
});
test('still strips script, style and iframe with their content', () => {
expect(
sanitizeLayoutPreviewHtml(
"<form><script>alert(1)</script><style>a{}</style><iframe src='https://x'></iframe><input name='a' /></form>",
),
).toBe('<form><input name="a" /></form>');
});
test('keeps the html-block rules for everything else', () => {
expect(sanitizeLayoutPreviewHtml("<a href='javascript:alert(1)' style='x'>x</a>")).toBe(
'<a>x</a>',
);
});
});

View File

@ -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<AppDataTableRowResult[]> => {
const { data } = await handle.getManyRowsAndCount({
filter: interpolateFilter(block.data.filter, ctx),
sortBy: block.data.sortBy,
take: block.data.limit,
});
return data;
};

View File

@ -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}:`;

View File

@ -89,11 +89,39 @@ const INLINE_TAGS: Record<string, string[]> = {
br: [],
};
const sanitize = (dirty: string, whiteList: Record<string, string[]>): 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<string, string[]> = {
...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<string, string[]>,
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, string[]>): 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);

View File

@ -0,0 +1 @@
/* Filled by the agent-chat block work; imported from app.css. */

View File

@ -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';

View File

@ -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',
);
});

View File

@ -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=/,
);
});
});

View File

@ -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}`;
};

View File

@ -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;

View File

@ -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(' '));

View File

@ -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();
});

View File

@ -10,7 +10,7 @@ import { ViewerService } from '../viewer.service';
const user = mock<User>({ 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<Request>({

View File

@ -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<AuthorizedAppRequest | RejectedAppRequest> {
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 };
}
}

View File

@ -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<typeof accessModeSchema>;
/** 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<z.infer<typeof accessPayloadSchema>, '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<AppTokenPair> {
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 };

View File

@ -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]);

View File

@ -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);
});
});

View File

@ -54,6 +54,7 @@ const publish = async (app: Awaited<ReturnType<typeof createApp>>) => {
layout: page.layout as AppLayout | null,
})),
theme: null,
components: null,
},
owner.id,
);

View File

@ -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');
});

View File

@ -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<T>(fn: (context: TestMigrationContext) => Promise<T>): Promise<T> {
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<Array<{ components: string | null }>>(
`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<Array<{ id: string }>>(`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) => <div class="app-card">{props.children}</div>;';
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);
});
});
});