mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-21 20:20:03 +02:00
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:
parent
7fc3f4a72b
commit
76385b3d3e
3
docs/generated/postgres-schema/README.md
generated
3
docs/generated/postgres-schema/README.md
generated
|
|
@ -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
|
||||
|
|
|
|||
2
docs/generated/postgres-schema/public.app.md
generated
2
docs/generated/postgres-schema/public.app.md
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
docs/generated/postgres-schema/public.page.md
generated
1
docs/generated/postgres-schema/public.page.md
generated
|
|
@ -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
|
||||
|
|
|
|||
1
docs/generated/postgres-schema/public.project.md
generated
1
docs/generated/postgres-schema/public.project.md
generated
|
|
@ -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
|
||||
|
|
|
|||
3
docs/generated/sqlite-schema/README.md
generated
3
docs/generated/sqlite-schema/README.md
generated
|
|
@ -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
|
||||
|
|
|
|||
4
docs/generated/sqlite-schema/app.md
generated
4
docs/generated/sqlite-schema/app.md
generated
|
|
@ -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
|
||||
|
|
|
|||
1
docs/generated/sqlite-schema/app_version.md
generated
1
docs/generated/sqlite-schema/app_version.md
generated
|
|
@ -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
|
||||
|
|
|
|||
1
docs/generated/sqlite-schema/page.md
generated
1
docs/generated/sqlite-schema/page.md
generated
|
|
@ -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
|
||||
|
|
|
|||
1
docs/generated/sqlite-schema/project.md
generated
1
docs/generated/sqlite-schema/project.md
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
13
packages/@n8n/api-types/src/schemas/app-layout-presets.ts
Normal file
13
packages/@n8n/api-types/src/schemas/app-layout-presets.ts
Normal 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[] = [];
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' } });
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
42
packages/cli/src/modules/apps/rendering/blocks/table-rows.ts
Normal file
42
packages/cli/src/modules/apps/rendering/blocks/table-rows.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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}:`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
/* Filled by the agent-chat block work; imported from 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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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=/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
20
packages/cli/src/modules/apps/runtime/action-url.ts
Normal file
20
packages/cli/src/modules/apps/runtime/action-url.ts
Normal 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}`;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(' '));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
57
packages/cli/src/modules/apps/serving/app-request-auth.ts
Normal file
57
packages/cli/src/modules/apps/serving/app-request-auth.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const publish = async (app: Awaited<ReturnType<typeof createApp>>) => {
|
|||
layout: page.layout as AppLayout | null,
|
||||
})),
|
||||
theme: null,
|
||||
components: null,
|
||||
},
|
||||
owner.id,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user