From 2c8aec6fd2586488d9d2a3fdb66cfc8f6be7fd7a Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 11 Sep 2026 11:15:41 +0200 Subject: [PATCH] feat(core): Let built apps read and write bound data tables (#38332) --- packages/@n8n/api-types/src/index.ts | 6 + .../__tests__/app-binding.schema.test.ts | 75 +++- .../__tests__/instance-ai.schema.test.ts | 44 ++- .../src/schemas/app-binding.schema.ts | 50 ++- .../src/schemas/instance-ai.schema.ts | 34 +- packages/@n8n/app-sdk/README.md | 13 +- .../@n8n/app-sdk/src/__tests__/tables.test.ts | 129 ++++++ packages/@n8n/app-sdk/src/index.ts | 208 ++++++++-- .../instance-ai/skills/app-builder/SKILL.md | 100 +++-- .../skills/app-builder/references/app-sdk.md | 95 ++++- .../app-builder/templates/vue/AI_RULES.md | 2 +- .../templates/vue/src/n8n-bindings.d.ts | 1 + .../src/stream/__tests__/map-chunk.test.ts | 1 + .../src/tools/__tests__/apps.tool.test.ts | 294 +++++++++++++- .../@n8n/instance-ai/src/tools/apps.tool.ts | 247 +++++++++--- .../apps/__tests__/apps.service.test.ts | 184 ++++++++- packages/cli/src/modules/apps/apps.module.ts | 1 + packages/cli/src/modules/apps/apps.service.ts | 247 +++++++++--- .../binding-data-table-not-found.error.ts | 8 + .../errors/binding-project-mismatch.error.ts | 6 +- .../app-table-runtime.service.test.ts | 221 +++++++++++ .../apps/runtime/app-runtime.controller.ts | 27 +- .../modules/apps/runtime/app-runtime.error.ts | 2 + .../apps/runtime/app-runtime.service.ts | 5 +- .../runtime/app-table-runtime.controller.ts | 98 +++++ .../apps/runtime/app-table-runtime.service.ts | 159 ++++++++ packages/cli/src/modules/apps/runtime/cors.ts | 29 ++ ...app-runtime.controller.integration.test.ts | 4 +- ...ble-runtime.controller.integration.test.ts | 370 ++++++++++++++++++ .../frontend/@n8n/i18n/src/locales/en.json | 20 +- .../__tests__/AppBindingApproval.test.ts | 61 +++ .../InstanceAiConfirmationPanel.test.ts | 1 + .../__tests__/SettingsInstanceAiView.test.ts | 11 + .../components/AppBindingApproval.vue | 62 ++- .../views/SettingsInstanceAiView.vue | 2 +- .../src/features/apps/AppDetailsView.test.ts | 114 +++++- .../src/features/apps/AppDetailsView.vue | 41 +- .../src/features/apps/useAppDeletion.ts | 8 +- 38 files changed, 2687 insertions(+), 293 deletions(-) create mode 100644 packages/@n8n/app-sdk/src/__tests__/tables.test.ts create mode 100644 packages/cli/src/modules/apps/errors/binding-data-table-not-found.error.ts create mode 100644 packages/cli/src/modules/apps/runtime/__tests__/app-table-runtime.service.test.ts create mode 100644 packages/cli/src/modules/apps/runtime/app-table-runtime.controller.ts create mode 100644 packages/cli/src/modules/apps/runtime/app-table-runtime.service.ts create mode 100644 packages/cli/src/modules/apps/runtime/cors.ts create mode 100644 packages/cli/test/integration/apps/app-table-runtime.controller.integration.test.ts diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 917ba29c535..7ad7221b01a 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -196,8 +196,14 @@ export { appBindingSchema, appBindingsSchema, workflowBindingSchema, + dataTableBindingSchema, + dataTablePermissionSchema, type AppBinding, + type DataTablePermission, type DescribedBinding, + type DescribedWorkflowBinding, + type DescribedDataTableBinding, + type DescribedMissingBinding, type OutputSource, } from './schemas/app-binding.schema'; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/app-binding.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/app-binding.schema.test.ts index ea84281fdf0..f3bc02d99c3 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/app-binding.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/app-binding.schema.test.ts @@ -1,6 +1,12 @@ import { appBindingSchema, appBindingsSchema, type DescribedBinding } from '../app-binding.schema'; const binding = (key: string) => ({ key, kind: 'workflow', workflowId: 'wf-1' }); +const tableBinding = (key: string, permissions: string[] = ['read']) => ({ + key, + kind: 'dataTable', + dataTableId: 'dt-1', + permissions, +}); describe('app-binding.schema', () => { describe('appBindingSchema', () => { @@ -21,7 +27,39 @@ describe('app-binding.schema', () => { it('rejects an unknown kind', () => { expect( - appBindingSchema.safeParse({ key: 'orders', kind: 'dataTable', dataTableId: 'dt-1' }) + appBindingSchema.safeParse({ key: 'orders', kind: 'executions', workflowId: 'wf-1' }) + .success, + ).toBe(false); + }); + + it('accepts a data table binding with read, write, or both permissions', () => { + expect(appBindingSchema.safeParse(tableBinding('tasks', ['read'])).success).toBe(true); + expect(appBindingSchema.safeParse(tableBinding('tasks', ['write'])).success).toBe(true); + expect(appBindingSchema.safeParse(tableBinding('tasks', ['read', 'write'])).success).toBe( + true, + ); + }); + + it('requires at least one data table permission', () => { + expect(appBindingSchema.safeParse(tableBinding('tasks', [])).success).toBe(false); + const { permissions: _, ...withoutPermissions } = tableBinding('tasks'); + expect(appBindingSchema.safeParse(withoutPermissions).success).toBe(false); + }); + + it('rejects an unknown data table permission', () => { + expect(appBindingSchema.safeParse(tableBinding('tasks', ['admin'])).success).toBe(false); + expect(appBindingSchema.safeParse(tableBinding('tasks', ['read', 'delete'])).success).toBe( + false, + ); + }); + + it('rejects a missing or oversized dataTableId', () => { + expect( + appBindingSchema.safeParse({ key: 'tasks', kind: 'dataTable', permissions: ['read'] }) + .success, + ).toBe(false); + expect( + appBindingSchema.safeParse({ ...tableBinding('tasks'), dataTableId: 'x'.repeat(37) }) .success, ).toBe(false); }); @@ -48,6 +86,19 @@ describe('app-binding.schema', () => { ); }); + it('accepts workflow and data table bindings together when keys are unique', () => { + expect( + appBindingsSchema.safeParse([binding('submit'), tableBinding('tasks', ['read', 'write'])]) + .success, + ).toBe(true); + }); + + it('rejects a key shared by a workflow and a data table binding', () => { + expect(appBindingsSchema.safeParse([binding('tasks'), tableBinding('tasks')]).success).toBe( + false, + ); + }); + it('accepts 50 bindings and rejects 51', () => { const fifty = Array.from({ length: 50 }, (_, i) => binding(`k${i}`)); expect(appBindingsSchema.safeParse(fifty).success).toBe(true); @@ -78,5 +129,27 @@ describe('app-binding.schema', () => { expect(typed.outputSource.kind).toBe('execution'); expect(untyped.outputSource.kind).toBe('unknown'); }); + + it('narrows on kind and missing', () => { + const table: DescribedBinding = { + key: 'tasks', + kind: 'dataTable', + dataTableId: 'dt-1', + name: 'Tasks', + permissions: ['read', 'write'], + columns: [{ name: 'title', type: 'string' }], + row: { type: 'object', properties: { title: { type: ['string', 'null'] } } }, + }; + const missing: DescribedBinding = { + key: 'tasks', + kind: 'dataTable', + name: 'tasks', + missing: true, + }; + const columnCount = (b: DescribedBinding) => + b.missing || b.kind !== 'dataTable' ? 0 : b.columns.length; + expect(columnCount(table)).toBe(1); + expect(columnCount(missing)).toBe(0); + }); }); }); diff --git a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts index b39b9527697..bc9d80de9cc 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts @@ -258,6 +258,12 @@ describe('applyBranchReadOnlyOverrides', () => { expect(result.mutateDataTableRows).toBe('blocked'); expect(result.cleanupTestExecutions).toBe('blocked'); expect(result.bindAppWorkflow).toBe('blocked'); + expect(result.bindAppDataTable).toBe('blocked'); + }); + + it('requires approval for app bindings by default', () => { + expect(DEFAULT_INSTANCE_AI_PERMISSIONS.bindAppWorkflow).toBe('require_approval'); + expect(DEFAULT_INSTANCE_AI_PERMISSIONS.bindAppDataTable).toBe('require_approval'); }); it('should preserve safe permissions even when set to always_allow', () => { @@ -317,9 +323,10 @@ describe('confirmationRequestPayloadSchema', () => { expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload); }); - it('preserves the app binding details of a bind approval', () => { + it('preserves the app binding details of a workflow bind approval', () => { const payload = makeConfirmation({ appBinding: { + kind: 'workflow', appId: 'app-1', appName: 'Runner', appNamespace: 'runner', @@ -332,6 +339,41 @@ describe('confirmationRequestPayloadSchema', () => { expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload); }); + it('preserves the app binding details of a data table bind approval', () => { + const payload = makeConfirmation({ + appBinding: { + kind: 'dataTable', + appId: 'app-1', + appName: 'Board', + appNamespace: 'board', + dataTableId: 'dt-1', + dataTableName: 'Tasks', + key: 'tasks', + permissions: ['read', 'write'], + projectId: 'proj-1', + }, + }); + + expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload); + }); + + it('rejects an app binding without a kind', () => { + const result = confirmationRequestPayloadSchema.safeParse( + makeConfirmation({ + appBinding: { + appId: 'app-1', + appName: 'Runner', + appNamespace: 'runner', + workflowId: 'wf-1', + workflowName: 'Echo', + key: 'submit', + } as unknown as InstanceAiConfirmationRequestPayload['appBinding'], + }), + ); + + expect(result.success).toBe(false); + }); + it('requires a credential destination to be an exact HTTP origin', () => { const result = confirmationRequestPayloadSchema.safeParse( makeConfirmation({ diff --git a/packages/@n8n/api-types/src/schemas/app-binding.schema.ts b/packages/@n8n/api-types/src/schemas/app-binding.schema.ts index 0340bc4450a..990e4fa1c5c 100644 --- a/packages/@n8n/api-types/src/schemas/app-binding.schema.ts +++ b/packages/@n8n/api-types/src/schemas/app-binding.schema.ts @@ -1,6 +1,8 @@ import type { JSONSchema7 } from 'json-schema'; import { z } from 'zod'; +import type { dataTableColumnTypeSchema } from './data-table.schema'; + // The key is the URL segment under /apps//api/workflows/ and the SDK argument. const bindingKeySchema = z .string() @@ -15,9 +17,22 @@ export const workflowBindingSchema = z.object({ workflowId: z.string().min(1).max(36), }); -// Discriminated on `kind` so later kinds (dataTable, workflowList, executions) add a -// member here instead of a column. -export const appBindingSchema = z.discriminatedUnion('kind', [workflowBindingSchema]); +/** `read` allows listing rows; `write` allows insert, update, and delete. */ +export const dataTablePermissionSchema = z.enum(['read', 'write']); + +export const dataTableBindingSchema = z.object({ + key: bindingKeySchema, + kind: z.literal('dataTable'), + dataTableId: z.string().min(1).max(36), + permissions: z.array(dataTablePermissionSchema).min(1).max(2), +}); + +// Discriminated on `kind` so later kinds (workflowList, executions) add a member here +// instead of a column. +export const appBindingSchema = z.discriminatedUnion('kind', [ + workflowBindingSchema, + dataTableBindingSchema, +]); export const appBindingsSchema = z .array(appBindingSchema) @@ -28,13 +43,14 @@ export const appBindingsSchema = z ); export type AppBinding = z.infer; +export type DataTablePermission = z.infer; /** Where the output fields come from: one successful execution, or nothing yet. */ export type OutputSource = | { kind: 'execution'; executionId: string; at: string } | { kind: 'unknown' }; -export type DescribedBinding = { +export type DescribedWorkflowBinding = { key: string; kind: 'workflow'; workflowId: string; @@ -45,4 +61,30 @@ export type DescribedBinding = { /** The item array the runtime returns as `output`; its items are an open object until an execution typed them. */ output: JSONSchema7; outputSource: OutputSource; + missing?: false; }; + +export type DescribedDataTableBinding = { + key: string; + kind: 'dataTable'; + dataTableId: string; + name: string; + permissions: DataTablePermission[]; + columns: Array<{ name: string; type: z.infer }>; + /** One row as the runtime returns it: `id`, `createdAt`, `updatedAt`, then every user column as nullable. */ + row: JSONSchema7; + missing?: false; +}; + +/** The bound resource no longer exists; `name` falls back to the key so the row still renders. */ +export type DescribedMissingBinding = { + key: string; + kind: 'workflow' | 'dataTable'; + name: string; + missing: true; +}; + +export type DescribedBinding = + | DescribedWorkflowBinding + | DescribedDataTableBinding + | DescribedMissingBinding; diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index 7b9f5b9744d..9b2a5ae276c 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { dataTablePermissionSchema } from './app-binding.schema'; import { appNameSchema, appNamespaceSchema } from './app.schema'; import type { McpRegistryServerIconResponse } from './mcp-registry.schema'; import { TimeZoneSchema } from './timezone.schema'; @@ -275,14 +276,29 @@ export const webSearchMetaSchema = z.object({ export type WebSearchMeta = z.infer; /** What the `apps` tool is about to connect, for the bind approval card. */ -export const appBindingMetaSchema = z.object({ - appId: z.string(), - appName: z.string(), - appNamespace: z.string(), - workflowId: z.string(), - workflowName: z.string(), - key: z.string(), -}); +export const appBindingMetaSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('workflow'), + appId: z.string(), + appName: z.string(), + appNamespace: z.string(), + workflowId: z.string(), + workflowName: z.string(), + key: z.string(), + }), + z.object({ + kind: z.literal('dataTable'), + appId: z.string(), + appName: z.string(), + appNamespace: z.string(), + dataTableId: z.string(), + dataTableName: z.string(), + key: z.string(), + permissions: z.array(dataTablePermissionSchema).min(1).max(2), + // For the link to the data table in the approval card. + projectId: z.string(), + }), +]); export type AppBindingMeta = z.infer; export const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']); @@ -1890,6 +1906,7 @@ const instanceAiPermissionsSchema = z.object({ restoreWorkflowVersion: instanceAiPermissionModeSchema, executeMcpTool: instanceAiPermissionModeSchema, bindAppWorkflow: instanceAiPermissionModeSchema, + bindAppDataTable: instanceAiPermissionModeSchema, }); export type InstanceAiPermissions = z.infer; @@ -1917,6 +1934,7 @@ export const DEFAULT_INSTANCE_AI_PERMISSIONS: InstanceAiPermissions = { restoreWorkflowVersion: 'require_approval', executeMcpTool: 'require_approval', bindAppWorkflow: 'require_approval', + bindAppDataTable: 'require_approval', }; /** diff --git a/packages/@n8n/app-sdk/README.md b/packages/@n8n/app-sdk/README.md index ccfbaa3fed6..1fa62e02651 100644 --- a/packages/@n8n/app-sdk/README.md +++ b/packages/@n8n/app-sdk/README.md @@ -1,8 +1,8 @@ # @n8n/app-sdk -Browser client for the workflows bound to a built n8n app. It posts to the -app's runtime API, `/apps//api/workflows/`, with no -dependencies. +Browser client for the workflows and data tables bound to a built n8n app. It +calls the app's runtime API, `/apps//api/workflows/` and +`/apps//api/tables//rows`, with no dependencies. ```ts import { n8n, N8nAppError } from '@n8n/app-sdk'; @@ -10,6 +10,13 @@ import { n8n, N8nAppError } from '@n8n/app-sdk'; const result = await n8n.workflows.run('submit', { email: 'a@b.c' }); ``` +`n8n.tables.` reads and writes the rows of a bound data table: +`list({ filter, search, sortBy, take, skip })` returns `{ count, data }`; +`insert(rows)`, `update(filter, data)` and `delete(filter)` return the affected +rows as `{ data }`. Filters use the n8n data table filter shape +(`{ type?: 'and' | 'or', filters: [{ columnName, condition?, value }] }`). A +read-only binding answers writes with a `403 permission_denied` `N8nAppError`. + The app-builder skill in `@n8n/instance-ai` documents the API, the error codes and the generated `src/n8n-bindings.d.ts` that types the bound keys. diff --git a/packages/@n8n/app-sdk/src/__tests__/tables.test.ts b/packages/@n8n/app-sdk/src/__tests__/tables.test.ts new file mode 100644 index 00000000000..fe582eae45e --- /dev/null +++ b/packages/@n8n/app-sdk/src/__tests__/tables.test.ts @@ -0,0 +1,129 @@ +import { createClient, N8nAppError } from '../index.js'; + +const jsonResponse = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status }); + +const row = { id: 1, createdAt: '2026-09-10T00:00:00.000Z', updatedAt: '2026-09-10T00:00:00.000Z' }; + +describe('tables', () => { + const fetchMock = vi.fn(); + const client = createClient({ baseUrl: '/apps/board/api/' }); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('lists rows with filter, sort, paging and search in the query string', async () => { + const body = { count: 1, data: [{ ...row, title: 'a', done: false }] }; + fetchMock.mockResolvedValue(jsonResponse(200, body)); + + const result = await client.tables.tasks.list({ + filter: { filters: [{ columnName: 'done', value: false }] }, + sortBy: 'title:asc', + take: 50, + skip: 10, + search: 'a', + }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe( + '/apps/board/api/tables/tasks/rows?filter=%7B%22filters%22%3A%5B%7B%22columnName%22%3A%22done%22%2C%22value%22%3Afalse%7D%5D%7D&search=a&sortBy=title%3Aasc&take=50&skip=10', + ); + expect(init).toMatchObject({ method: 'GET', body: undefined }); + expect(result).toEqual(body); + }); + + it('lists without a query string when no option is given', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { count: 0, data: [] })); + + await client.tables.tasks.list(); + + expect(fetchMock.mock.calls[0][0]).toBe('/apps/board/api/tables/tasks/rows'); + }); + + it('posts new rows as { data } and returns the stored rows', async () => { + const body = { data: [{ ...row, title: 'a', done: null }] }; + fetchMock.mockResolvedValue(jsonResponse(201, body)); + + const result = await client.tables.tasks.insert([{ title: 'a' }]); + + expect(fetchMock).toHaveBeenCalledWith('/apps/board/api/tables/tasks/rows', { + method: 'POST', + headers: [['Content-Type', 'application/json']], + body: '{"data":[{"title":"a"}]}', + signal: undefined, + }); + expect(result).toEqual(body); + }); + + it('patches with { filter, data }', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { data: [{ ...row, title: 'a', done: true }] })); + + await client.tables.tasks.update({ filters: [{ columnName: 'id', value: 1 }] }, { done: true }); + + expect(fetchMock.mock.calls[0]).toEqual([ + '/apps/board/api/tables/tasks/rows', + expect.objectContaining({ + method: 'PATCH', + body: '{"filter":{"filters":[{"columnName":"id","value":1}]},"data":{"done":true}}', + }), + ]); + }); + + it('deletes with the filter in the query string', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { data: [{ ...row, title: 'a', done: true }] })); + + await client.tables.tasks.delete({ type: 'or', filters: [{ columnName: 'id', value: 1 }] }); + + expect(fetchMock.mock.calls[0]).toEqual([ + '/apps/board/api/tables/tasks/rows?filter=%7B%22type%22%3A%22or%22%2C%22filters%22%3A%5B%7B%22columnName%22%3A%22id%22%2C%22value%22%3A1%7D%5D%7D', + expect.objectContaining({ method: 'DELETE', body: undefined }), + ]); + }); + + it('encodes the binding key in the path', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { count: 0, data: [] })); + + await createClient({ baseUrl: '/x' }).tables['to-do'].list(); + + expect(fetchMock.mock.calls[0][0]).toBe('/x/tables/to-do/rows'); + }); + + it('throws N8nAppError with the server code on 403 permission_denied', async () => { + fetchMock.mockResolvedValue( + jsonResponse(403, { code: 'permission_denied', message: 'read-only binding' }), + ); + + const error = await client.tables.tasks.insert([{ title: 'a' }]).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(N8nAppError); + expect(error).toMatchObject({ + status: 403, + code: 'permission_denied', + message: 'read-only binding', + }); + }); + + it('throws invalid_response when the body is not a row list', async () => { + fetchMock.mockResolvedValue(jsonResponse(200, { rows: [] })); + + await expect(client.tables.tasks.list()).rejects.toMatchObject({ + status: 200, + code: 'invalid_response', + }); + }); + + it('rethrows the abort error of the caller unchanged', async () => { + const controller = new AbortController(); + const abortError = new DOMException('Aborted', 'AbortError'); + controller.abort(); + fetchMock.mockRejectedValue(abortError); + + await expect(client.tables.tasks.list({ signal: controller.signal })).rejects.toBe(abortError); + }); +}); diff --git a/packages/@n8n/app-sdk/src/index.ts b/packages/@n8n/app-sdk/src/index.ts index 8d3c72ad65c..20c353fe51e 100644 --- a/packages/@n8n/app-sdk/src/index.ts +++ b/packages/@n8n/app-sdk/src/index.ts @@ -1,8 +1,9 @@ /** * Augmented by the generated `src/n8n-bindings.d.ts` of an app with - * `workflows: { : { input; output } }`, so `run` keys and inputs are checked at - * build time. Kept empty here: interface merging rejects a property that is declared - * twice with different types, so the SDK cannot declare `workflows` itself. + * `workflows: { : { input; output } }` and `tables: { : { row } }`, so keys, + * inputs and rows are checked at build time. Kept empty here: interface merging rejects + * a property that is declared twice with different types, so the SDK cannot declare + * `workflows` or `tables` itself. */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface Bindings {} @@ -11,6 +12,14 @@ type AnyWorkflows = Record; type Workflows = Bindings extends { workflows: infer W extends AnyWorkflows } ? W : AnyWorkflows; type WorkflowKey = Extract; +type AnyTables = Record }>; +type Tables = Bindings extends { tables: infer T extends AnyTables } ? T : AnyTables; +type TableKey = Extract; +type Row = Tables[K]['row']; +/** What the app sends: the server fills `id`, `createdAt` and `updatedAt`. */ +type NewRow = Partial, 'id' | 'createdAt' | 'updatedAt'>>; +type ColumnName = Extract, string>; + /** Every app is public, so the visitor is anonymous. Reserved for a signed-in visitor later. */ export type Principal = null; @@ -24,6 +33,39 @@ export interface RunResult { principal: Principal; } +/** Same shape as the n8n REST data table filter; `type` defaults to `and`, `condition` to `eq`. */ +export interface TableFilter { + type?: 'and' | 'or'; + filters: Array<{ + columnName: ColumnName; + condition?: 'eq' | 'neq' | 'like' | 'ilike' | 'gt' | 'gte' | 'lt' | 'lte'; + value: string | number | boolean | null; + }>; +} + +export interface TableListOptions { + filter?: TableFilter; + /** Case-insensitive substring match over every column as text. */ + search?: string; + sortBy?: `${ColumnName}:${'asc' | 'desc'}`; + /** Default 10, at most 250. */ + take?: number; + skip?: number; +} + +export interface TableClient { + list( + opts?: TableListOptions & { signal?: AbortSignal }, + ): Promise<{ count: number; data: Array> }>; + insert(rows: Array>, opts?: { signal?: AbortSignal }): Promise<{ data: Array> }>; + update( + filter: TableFilter, + data: NewRow, + opts?: { signal?: AbortSignal }, + ): Promise<{ data: Array> }>; + delete(filter: TableFilter, opts?: { signal?: AbortSignal }): Promise<{ data: Array> }>; +} + export class N8nAppError extends Error { constructor( readonly status: number, @@ -44,6 +86,7 @@ export interface N8nAppClient { opts?: { signal?: AbortSignal }, ): Promise>; }; + tables: { [K in TableKey]: TableClient }; } function isRecord(value: unknown): value is Record { @@ -56,6 +99,14 @@ function isRunResult(value: unknown): value is RunResult { ); } +function isRowList(value: unknown): value is { data: R[] } { + return isRecord(value) && Array.isArray(value.data); +} + +function isRowPage(value: unknown): value is { count: number; data: R[] } { + return isRecord(value) && Array.isArray(value.data) && typeof value.count === 'number'; +} + async function readJson(response: Response): Promise { try { return await response.json(); @@ -85,46 +136,123 @@ function defaultBaseUrl(): string { return `/${root}/${namespace}/api`; } -export function createClient(opts: { baseUrl?: string } = {}): N8nAppClient { - return { - workflows: { - async run(key, input, runOpts) { - const baseUrl = (opts.baseUrl ?? defaultBaseUrl()).replace(/\/+$/, ''); - const response = await fetch(`${baseUrl}/workflows/${encodeURIComponent(key)}`, { - method: 'POST', - headers: [['Content-Type', 'application/json']], - body: JSON.stringify(input ?? {}), - signal: runOpts?.signal, - }).catch((error: unknown) => { - // A 429 from the rate limiter carries no CORS headers, so the browser reports it - // as a network error. The caller's own abort stays an AbortError. - if (runOpts?.signal?.aborted) throw error; - throw new N8nAppError( - 0, - 'request_failed', - 'The request did not reach n8n: network error, or a rate-limited response the browser could not read.', - ); - }); - const body = await readJson(response); +/** One runtime API call: the JSON body on 2xx, an `N8nAppError` otherwise. */ +async function call( + path: string, + init: { method: string; body?: unknown; signal?: AbortSignal }, + baseUrl: string | undefined, +): Promise<{ status: number; body: unknown }> { + const base = (baseUrl ?? defaultBaseUrl()).replace(/\/+$/, ''); + const response = await fetch(`${base}${path}`, { + method: init.method, + headers: [['Content-Type', 'application/json']], + body: init.body === undefined ? undefined : JSON.stringify(init.body), + signal: init.signal, + }).catch((error: unknown) => { + // A 429 from the rate limiter carries no CORS headers, so the browser reports it + // as a network error. The caller's own abort stays an AbortError. + if (init.signal?.aborted) throw error; + throw new N8nAppError( + 0, + 'request_failed', + 'The request did not reach n8n: network error, or a rate-limited response the browser could not read.', + ); + }); + const body = await readJson(response); - if (!response.ok) { - const error = isRecord(body) ? body : {}; - throw new N8nAppError( - response.status, - typeof error.code === 'string' ? error.code : 'request_failed', - typeof error.message === 'string' - ? error.message - : `Request failed (${response.status})`, - error.issues, - ); - } - if (!isRunResult(body)) { - throw new N8nAppError(response.status, 'invalid_response', 'Unexpected response body'); - } - return body; - }, + if (!response.ok) { + const error = isRecord(body) ? body : {}; + throw new N8nAppError( + response.status, + typeof error.code === 'string' ? error.code : 'request_failed', + typeof error.message === 'string' ? error.message : `Request failed (${response.status})`, + error.issues, + ); + } + return { status: response.status, body }; +} + +const invalidResponse = (status: number) => + new N8nAppError(status, 'invalid_response', 'Unexpected response body'); + +function tableClient(key: K, baseUrl: string | undefined): TableClient { + const rows = `/tables/${encodeURIComponent(key)}/rows`; + const query = (params: Record) => { + const search = new URLSearchParams(); + for (const [name, value] of Object.entries(params)) { + if (value !== undefined) search.set(name, value); + } + const encoded = search.toString(); + return encoded ? `?${encoded}` : ''; + }; + return { + async list(opts = {}) { + const path = + rows + + query({ + filter: opts.filter && JSON.stringify(opts.filter), + search: opts.search, + sortBy: opts.sortBy, + take: opts.take === undefined ? undefined : String(opts.take), + skip: opts.skip === undefined ? undefined : String(opts.skip), + }); + const { status, body } = await call(path, { method: 'GET', signal: opts.signal }, baseUrl); + if (!isRowPage>(body)) throw invalidResponse(status); + return body; + }, + async insert(data, opts) { + const { status, body } = await call( + rows, + { method: 'POST', body: { data }, signal: opts?.signal }, + baseUrl, + ); + if (!isRowList>(body)) throw invalidResponse(status); + return body; + }, + async update(filter, data, opts) { + const { status, body } = await call( + rows, + { method: 'PATCH', body: { filter, data }, signal: opts?.signal }, + baseUrl, + ); + if (!isRowList>(body)) throw invalidResponse(status); + return body; + }, + async delete(filter, opts) { + const path = rows + query({ filter: JSON.stringify(filter) }); + const { status, body } = await call( + path, + { method: 'DELETE', signal: opts?.signal }, + baseUrl, + ); + if (!isRowList>(body)) throw invalidResponse(status); + return body; }, }; } +export function createClient(opts: { baseUrl?: string } = {}): N8nAppClient { + return { + workflows: { + async run(key, input, runOpts) { + const { status, body } = await call( + `/workflows/${encodeURIComponent(key)}`, + { method: 'POST', body: input ?? {}, signal: runOpts?.signal }, + opts.baseUrl, + ); + if (!isRunResult(body)) throw invalidResponse(status); + return body; + }, + }, + // Keyed by binding key, so `n8n.tables.tasks` needs no registration step. + tables: new Proxy( + {}, + { + get: (_target, key) => + typeof key === 'string' ? tableClient(key, opts.baseUrl) : undefined, + }, + ), + }; +} + export const n8n: N8nAppClient = createClient(); diff --git a/packages/@n8n/instance-ai/skills/app-builder/SKILL.md b/packages/@n8n/instance-ai/skills/app-builder/SKILL.md index 1a3ba574760..8f1ea6e21f9 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/SKILL.md +++ b/packages/@n8n/instance-ai/skills/app-builder/SKILL.md @@ -32,8 +32,8 @@ back into the app sandbox when it is missing, or lays down the starter template for an empty app; `add-component` copies a component from this skill's own catalog (built on `@ark-ui/vue`) into an app — `create` uses it for the two the starter page needs, and every other component goes through it too — and -`bind`/`unbind`/`bindings` manage the n8n workflows the app may call (see -"Calling n8n workflows"). +`bind`/`unbind`/`bindings` manage the n8n workflows and data tables the app +may use (see "Connecting n8n workflows and data tables"). The user sees a live preview of the source in the workspace. n8n runs a dev server for it: every file you write appears in the preview by itself (hot @@ -63,10 +63,10 @@ also publish with the Publish button above the preview, without you. work. Tell the user what changed and stop; the preview shows it. 3. Preview errors (compile errors, uncaught exceptions) arrive as context on the user's next message. Fix them before anything else. A - `binding_not_found` or `invalid_input` error from `n8n.workflows.run` - means the key is not bound or the input does not match the workflow's - fields: read the `bind` result or call `apps(action="bindings", appId)`, - then fix the call or re-bind. You cannot see the page: do not claim visual + `binding_not_found` or `invalid_input` error from `n8n.workflows.run` or + `n8n.tables.` means the key is not bound or the input does not match + the workflow's fields or the table's columns: read the `bind` result or + call `apps(action="bindings", appId)`, then fix the call or re-bind. You cannot see the page: do not claim visual results. 4. Publish only when the user asks to publish, deploy, share or go live: `apps(action="publish", appId)`. The user sees a confirmation card first @@ -112,6 +112,12 @@ directory already has files; edit them instead. - Static export only. No server code, no SSR, no API routes, no server functions. Everything runs in the browser. +- Store data in an n8n data table. When the app keeps lists, records or + state that must survive a reload or be shared between visitors, create a + table with `data-tables(action="create")` if none fits, bind it with + `apps(action="bind")` and use `n8n.tables.` (see "Data tables" + below). Do not use `localStorage` or in-memory arrays for data the user + expects to persist. - Never call `/rest` or other n8n internal endpoints from the app. - The app is served under a base path. Every asset and route URL is relative or built from the base: `APP_BASE` is set during the build (with a trailing @@ -143,10 +149,14 @@ directory already has files; edit them instead. every dependency costs build memory. - Never paste file contents into the chat; point at the file path. -## Calling n8n workflows +## Connecting n8n workflows and data tables -An app calls an n8n workflow only through `@n8n/app-sdk` and only by a key you -bound first. Bind before you write the code that calls it. +An app calls an n8n workflow or reads a data table only through `@n8n/app-sdk` +and only by a key you bound first. Bind before you write the code that uses it. +Workflows and data tables go in separate `bind` calls; each call asks the user +for approval with its own card. + +### Workflows 1. Find the workflow. It must be published and start with the trigger "When Executed by Another Workflow" (`n8n-nodes-base.executeWorkflowTrigger`) in @@ -206,29 +216,75 @@ bound first. Bind before you write the code that calls it. it is still running. See `references/app-sdk.md` for the full API and the error codes. 4. `apps(action="unbind", appId, key)` removes a binding; - `apps(action="bindings", appId)` lists them. Both keep - `src/n8n-bindings.d.ts` current. + `apps(action="bindings", appId)` lists them. Both rewrite + `src/n8n-bindings.d.ts` from the current bindings. + +### Data tables + +A data table is the storage of an app. Use one for every list, record or +state that must survive a reload or be shared between visitors (tasks, +entries, settings); `localStorage` and in-memory arrays lose that data. + +1. Find the table in the app's project with `data-tables(action="list", + projectId)`. If it does not exist, create it first: + `data-tables(action="create", name, projectId, columns=[{ name, type }])` + with column types `string`, `number`, `boolean` or `date`. The result + carries `table.id`. +2. `apps(action="bind", appId, bindings=[{ key: "tasks", kind: "dataTable", + dataTableId, permissions: ["read", "write"] }])`. Request `write` only + when the app inserts, updates or deletes rows; a display needs `read` + only. The result lists the binding with `name`, `columns` and `row` (the + JSON Schema of one row) and rewrites `src/n8n-bindings.d.ts` with + `tables..row`: `{ id: number; createdAt: string; updatedAt: string; + : | null }`, where a `date` column is an ISO string. + `{ denied, reason }` means the table is in another project, the user lacks + access to it, or they said no: read `reason`. +3. Call it from the app: + + ```ts + import { n8n } from '@n8n/app-sdk'; + + const { data: tasks } = await n8n.tables.tasks.list({ take: 250, sortBy: 'createdAt:desc' }); + const { data: [created] } = await n8n.tables.tasks.insert([{ title, status: 'todo' }]); + await n8n.tables.tasks.update({ filters: [{ columnName: 'id', value: task.id }] }, { status: 'done' }); + await n8n.tables.tasks.delete({ filters: [{ columnName: 'id', value: task.id }] }); + ``` + + `list` returns `{ count, data }` with at most 250 rows per call (`take`, + default 10; page with `skip`). `insert`, `update` and `delete` return the + affected rows as `{ data }`. Errors are `N8nAppError` as for workflows: + `permission_denied` (403) when the binding lacks `write`, + `table_not_found` (404) when the table was deleted, `invalid_input` (400) + for a value or filter the columns reject. See `references/app-sdk.md`. +4. After `data-tables(action="add-column" | "delete-column" | + "rename-column")` on a bound table, call `apps(action="bindings", appId)` + before you touch the code that uses it: it rewrites `src/n8n-bindings.d.ts` + from the current columns. Rules: - Only bound keys. Never `fetch` `/rest`, `/webhook`, `/api`, or - `/apps//api` by hand; never put a workflow id in the app. + `/apps//api` by hand; never put a workflow or table id in the + app. - `src/n8n-bindings.d.ts` and `vendor/n8n-app-sdk.tgz` are generated by - `apps`. Do not edit them; re-run `bind` to change the types. -- The workflow runs as the app's project, with the project's credentials. - All apps are public: a bound workflow is callable by anyone with the app - URL. Do not bind a workflow the user would not expose. + `apps`. Do not edit them; re-run `bind` or `bindings` to change the types. +- The workflow runs, and the table is read and written, as the app's + project, with the project's credentials. All apps are public: a bound + workflow is callable, and a bound table is readable (and with `write`, + changeable), by anyone with the app URL. Do not bind a workflow or table + the user would not expose. - A passthrough trigger (no declared fields) accepts any input: the app cannot type-check it and the server does not validate it. Prefer triggers with declared fields; the `bind` result warns about each passthrough one. ## Who may open the app -All apps are public: anyone with the URL opens the app and can run its bound -workflows; `result.principal` is `null`. The runtime API is callable from the -app's own page only (CORS; another site's page gets `403 forbidden_origin`). -Like a public webhook, anyone who can reach the instance can call a bound -workflow, so bind only workflows that may be public. +All apps are public: anyone with the URL opens the app and can use its bound +workflows and tables; `result.principal` is `null`. The runtime API is +callable from the app's own page only (CORS; another site's page gets +`403 forbidden_origin`). Like a public webhook, anyone who can reach the +instance can call a bound workflow or table, so bind only workflows and tables +that may be public. ## Template @@ -260,7 +316,7 @@ apps// src/router.ts createWebHistory(import.meta.env.BASE_URL) src/App.vue RouterView shell src/pages/Home.vue one component per route - src/n8n-bindings.d.ts types for n8n.workflows.run, written by bind (do not edit) + src/n8n-bindings.d.ts types for n8n.workflows.run and n8n.tables, written by bind (do not edit) src/components/ui/ catalog components (button, switch from create; more via add-component) src/lib/utils.ts cn() helper every component imports ``` diff --git a/packages/@n8n/instance-ai/skills/app-builder/references/app-sdk.md b/packages/@n8n/instance-ai/skills/app-builder/references/app-sdk.md index 04893ccaf64..7920c837dfe 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/references/app-sdk.md +++ b/packages/@n8n/instance-ai/skills/app-builder/references/app-sdk.md @@ -1,9 +1,11 @@ -# `@n8n/app-sdk`: calling bound workflows from an app +# `@n8n/app-sdk`: calling bound workflows and data tables from an app -The SDK is a small browser client (no dependencies) that posts to the app's -runtime API, `/apps//api/workflows/`. n8n serves that API next -to the app, resolves the key through the app's bindings and runs the published -workflow as the app's project. Workflow ids never appear in the app. +The SDK is a small browser client (no dependencies) that calls the app's +runtime API, `/apps//api/workflows/` and +`/apps//api/tables//rows`. n8n serves that API next to the +app, resolves the key through the app's bindings and runs the published +workflow, or reads and writes the table, as the app's project. Workflow and +table ids never appear in the app. `apps(action="create")` writes the package to `vendor/n8n-app-sdk.tgz`; the template's `package.json` depends on `"@n8n/app-sdk": "file:vendor/n8n-app-sdk.tgz"`. @@ -19,13 +21,14 @@ change, if ever needed, ships under a new path next to the existing one. ## Bind before code A key is callable only after `apps(action="bind")`, which asks the user for -approval: it exposes the workflow to everyone who can open the app. Bind also writes -`src/n8n-bindings.d.ts`, a module augmentation of the SDK's `Bindings` -interface, so `n8n.workflows.run` is typed for the bound keys and their -input fields. +approval: it exposes the workflow or table to everyone who can open the app. +Bind also writes `src/n8n-bindings.d.ts`, a module augmentation of the SDK's +`Bindings` interface, so `n8n.workflows.run` is typed for the bound keys and +their input fields, and `n8n.tables.` for the bound keys and their rows. -Generated file, for one workflow with declared fields and a typed output, and -one passthrough workflow that has not run yet: +Generated file, for one workflow with declared fields and a typed output, one +passthrough workflow that has not run yet, and one data table with the columns +`title` (string), `priority` (number), `done` (boolean) and `due` (date): ```ts // Generated by `apps bind`. Do not edit; re-run bind. @@ -37,10 +40,19 @@ declare module '@n8n/app-sdk' { "submit": { input: { "email"?: string | null; "amount"?: number | null }; output: Array<{ "reply": string; "count"?: number | null }> }; "notify": { input: Record; output: Array> }; }; + tables: { + "tasks": { row: { "id": number; "createdAt": string; "updatedAt": string; "title": string | null; "priority": number | null; "done": boolean | null; "due": string | null } }; + }; } } ``` +`row` is one row as the runtime returns it: `id`, `createdAt` and `updatedAt` +are set by n8n; every user column is nullable; a `date` column is an ISO +string. `bind` and `bindings` report it as JSON Schema (`row`) next to +`columns`. After a column change on a bound table, call +`apps(action="bindings", appId)` to rewrite the file. + `bind` and `bindings` report `input` and `output` as JSON Schema (draft-07), and the file renders that schema as TypeScript: `type` unions become unions, `required` properties lose the `?`, an object with `additionalProperties: true` @@ -70,8 +82,8 @@ input (`executions(action="run", workflowId, inputData)`), then call regenerate the file. The shape reflects that one run; re-run `bindings` after changing the workflow. -Before the first bind the file declares `workflows: {}`: there is nothing to -call yet, so bind first. +Before the first bind the file declares `workflows: {}` and `tables: {}`: there +is nothing to call yet, so bind first. ## API @@ -123,7 +135,43 @@ interface RunResult { - `outputTruncated: true` with `output: null`: the response was binary data, which v1 does not return. -On any other status `run` throws `N8nAppError`: +### Tables + +```ts +import { n8n } from '@n8n/app-sdk'; +import type { TableFilter } from '@n8n/app-sdk'; + +// Read. `take` defaults to 10 and is capped at 250; page with `skip`. +const { count, data } = await n8n.tables.tasks.list({ + filter: { type: 'and', filters: [{ columnName: 'done', value: false }] }, + sortBy: 'createdAt:desc', + take: 250, +}); + +// Write. The server fills id, createdAt and updatedAt; at most 100 rows per insert. +const { data: inserted } = await n8n.tables.tasks.insert([{ title: 'Write docs' }]); +const byId: TableFilter<'tasks'> = { filters: [{ columnName: 'id', value: inserted[0].id }] }; +const { data: updated } = await n8n.tables.tasks.update(byId, { done: true }); +const { data: deleted } = await n8n.tables.tasks.delete(byId); +``` + +- `list(opts?)` → `{ count, data: Row[] }`. `opts`: `filter`, `search` + (case-insensitive substring match over every column as text), `sortBy` + (`':asc' | ':desc'`), `take`, `skip`, `signal`. +- `insert(rows)` → `{ data: Row[] }` with the created rows. A row is a partial + object of the user columns. +- `update(filter, data)` → `{ data: Row[] }` with the changed rows. `filter` + needs at least one entry. +- `delete(filter)` → `{ data: Row[] }` with the removed rows. `filter` needs + at least one entry. + +A filter is `{ type?: 'and' | 'or', filters: [{ columnName, condition?, value }] }`; +`type` defaults to `and`, `condition` to `eq` (also `neq`, `like`, `ilike`, +`gt`, `gte`, `lt`, `lte`). `value` is `string | number | boolean | null`; a +`date` column takes an ISO string. + +On any other status `run` throws `N8nAppError`; a table call throws it on every +non-2xx answer: ```ts class N8nAppError extends Error { @@ -141,11 +189,13 @@ class N8nAppError extends Error { | `workflow_not_published` | 409 | The workflow has no published version. Publish it in n8n; no app change needed. | | `workflow_incompatible` | 409 | The workflow lost its "When Executed by Another Workflow" trigger or gained a Form node. | | `workflow_not_callable` | 403 | The workflow's "This workflow can be called by" setting excludes the app's project. | -| `invalid_input` | 400 | The body does not match the trigger fields; `issues` lists each rejected field's `path` and zod `code` (for example `invalid_type`). Fix the call. | +| `table_not_found` | 404 | The bound data table was deleted or moved out of the project. Re-bind another one. | +| `permission_denied` | 403 | The table binding lacks the `write` permission for insert, update or delete. Re-bind with `permissions: ["read", "write"]` if the user agrees. | +| `invalid_input` | 400 | The body does not match the trigger fields, or a table call has a filter, sort or value the columns reject (for example more than 100 rows, or no filter on update/delete); `issues` lists each rejected field's `path` and zod `code`. Fix the call. | | `forbidden_origin` | 403 | The request came from another site's page. The API answers only the app's own page (`Origin: null`) or the instance origin. | | `payload_too_large` | 413 | The body is over 1 MiB. | -| `too_many_requests` | 429 | The instance already holds its maximum of concurrent app runs (default 10). Retry after a moment. | -| `execution_failed` | 500 | n8n could not start the run. Retry later; the message is safe to show. | +| `too_many_requests` | 429 | The instance already holds its maximum of concurrent app runs (default 10). Retry after a moment. Workflow calls only. | +| `execution_failed` | 500 | n8n could not start the run, or the table call failed inside n8n. Retry later; the message is safe to show. | | `request_failed` | 0/any | No readable response: network error, or a 429 from the rate limiter (the browser hides it, `status` is 0). Retry later. | | `no_base_url` | 0 | `run` was called outside a browser or Vite build without `createClient({ baseUrl })`. | @@ -157,8 +207,8 @@ All apps are public: anyone with the URL opens the app. The runtime API is callable from the app's own page only (CORS: the served page has the opaque origin `null`; the instance origin is allowed too; any other origin gets `403 forbidden_origin` and no CORS headers). Like a public webhook, anyone who -can reach the instance can call a bound workflow, so bind only workflows that -may be public. `principal` is `null`. +can reach the instance can call a bound workflow or table, so bind only +workflows and tables that may be public. `principal` is `null`. ## Runtime facts @@ -167,8 +217,13 @@ may be public. `principal` is `null`. mode `integrated` and the custom data `appId`, `appNamespace` and `appBindingKey`. - Only the published version runs. Draft changes take effect on publish. -- Rate limit: 60 calls per minute per IP by default (instance setting). +- Rate limit: 60 calls per minute per IP for each runtime route by default + (instance setting). - Concurrency: at most 10 calls per main process hold a run at the same time by default (instance setting). A call holds its run until it ends or until the call answers 202. - Body limit: 1 MiB, JSON object only (arrays are rejected). +- Table calls run as the app's project too, with the permissions of the + binding (`read`: list; `write`: insert, update, delete). Each table route + has its own IP rate limit and does not count against the run + concurrency. `list` returns at most 250 rows; `insert` takes at most 100. diff --git a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/AI_RULES.md b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/AI_RULES.md index ed36b6e968f..bd1f21f3234 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/AI_RULES.md +++ b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/AI_RULES.md @@ -5,7 +5,7 @@ Stack: Vite, Vue 3, TypeScript, vue-router, Tailwind v4, this skill's own compon - `src/main.ts` boots the app, `src/router.ts` holds the routes, `src/pages/` holds one component per route. `src/components/ui/` holds catalog components added via `add-component` (only `button` and `switch` exist from `create`); shared app components go in `src/components/`. `src/style.css` holds the template defaults (Tailwind + the `:root`/`.dark` CSS variables every component reads) — do not edit it. Put any theme variable you want to change into `src/theme-overrides.css`'s `:root { }` block instead; a Theme-tab save merges its own keys onto that file rather than replacing it, so your edits to any other variable survive. `src/theme-mode.ts` is the one exception — a Theme-tab save always overwrites it. - Do not start a dev server; n8n runs one for the live preview, and dependencies are installed at `create`. Every edit appears in the preview by itself (hot module reload). Never run a build to check your work. Call `apps` (`action: "publish"`) only when the user asks to publish, deploy or share the app: the user confirms, then n8n builds the current source and updates `/apps//`. Preview errors arrive as context on the user's next message; fix them before anything else. - The build sandbox has 512 MiB of memory. Publishing runs `npm run build` (`vite build` only) and serves `dist/`; keep type checking out of the build script. A new dependency needs `npm install` in the app directory by you. -- Workflows: the app calls n8n workflows only through `@n8n/app-sdk` (`import { n8n, N8nAppError } from '@n8n/app-sdk'`, `await n8n.workflows.run('', input)`) and only for keys bound with `apps(action="bind")`. Bind first; the `bind` result lists each key with its input and output schema. `vendor/n8n-app-sdk.tgz` and `src/n8n-bindings.d.ts` are generated; do not edit them. Never call `/rest`, `/webhook` or `/apps/*/api` by hand. All apps are public: the runtime API is callable from the app's own page only (CORS), but like a public webhook, anyone who can reach the instance can call a bound workflow, so bind only workflows that may be public; `result.principal` is `null`. +- Workflows: the app calls n8n workflows only through `@n8n/app-sdk` (`import { n8n, N8nAppError } from '@n8n/app-sdk'`, `await n8n.workflows.run('', input)`) and only for keys bound with `apps(action="bind")`. Bind first; the `bind` result lists each key with its input and output schema. Data tables work the same way: bind with `kind: "dataTable"`, then `n8n.tables..list/insert/update/delete`; the `bind` result lists the columns. Store data that must survive a reload in a data table, not in `localStorage` or an in-memory array. `vendor/n8n-app-sdk.tgz` and `src/n8n-bindings.d.ts` are generated; do not edit them. Never call `/rest`, `/webhook` or `/apps/*/api` by hand. All apps are public: the runtime API is callable from the app's own page only (CORS), but like a public webhook, anyone who can reach the instance can call a bound workflow or table, so bind only ones that may be public; `result.principal` is `null`. - Build UI from the components in `src/components/ui/`, e.g. `import { Button } from '@/components/ui/button'`. Need a component that isn't there yet? Call `apps(action: "add-component", component: "")` to pull it in — do not hand-write a component that catalog already provides. - Style everything with Tailwind utility classes reading the theme's CSS variables (`bg-primary`, `text-muted-foreground`, `rounded-lg`). No hex colors, no inline styles, no `dark:` variants (dark mode comes from the `.dark` class the theme mode sets). - For interactive behavior a component doesn't already cover, use `@ark-ui/vue` primitives directly and style them with utilities and `data-[state=…]:` variants. diff --git a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/src/n8n-bindings.d.ts b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/src/n8n-bindings.d.ts index 54b5c496920..723e026f4fd 100644 --- a/packages/@n8n/instance-ai/skills/app-builder/templates/vue/src/n8n-bindings.d.ts +++ b/packages/@n8n/instance-ai/skills/app-builder/templates/vue/src/n8n-bindings.d.ts @@ -3,5 +3,6 @@ import '@n8n/app-sdk'; declare module '@n8n/app-sdk' { interface Bindings { workflows: {}; + tables: {}; } } diff --git a/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts b/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts index fa8b757ce2d..de6f393bcfb 100644 --- a/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts +++ b/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts @@ -558,6 +558,7 @@ describe('mapAgentChunkToEvent', () => { it('maps the app binding details of a bind approval and drops an invalid one', () => { const appBinding = { + kind: 'workflow', appId: 'app-1', appName: 'Runner', appNamespace: 'runner', diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts index 3deb3c28117..e3e11550838 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/apps.tool.test.ts @@ -74,6 +74,47 @@ const STORED_BINDINGS = [ { key: 'submit', kind: 'workflow' as const, workflowId: 'wf-1' }, { key: 'notify', kind: 'workflow' as const, workflowId: 'wf-2' }, ]; +const TASKS_COLUMNS = [ + { name: 'title', type: 'string' as const }, + { name: 'priority', type: 'number' as const }, + { name: 'done', type: 'boolean' as const }, + { name: 'due', type: 'date' as const }, +]; +// The row schema the apps service emits for these columns. +const TASKS_BINDING = { + key: 'tasks', + kind: 'dataTable' as const, + dataTableId: 'dt-1', + name: 'Tasks', + permissions: ['read' as const, 'write' as const], + columns: TASKS_COLUMNS, + row: { + type: 'object' as const, + properties: { + id: { type: 'number' as const }, + createdAt: { type: 'string' as const, format: 'date-time' }, + updatedAt: { type: 'string' as const, format: 'date-time' }, + title: { type: ['string', 'null'] as Array<'string' | 'null'> }, + priority: { type: ['number', 'null'] as Array<'number' | 'null'> }, + done: { type: ['boolean', 'null'] as Array<'boolean' | 'null'> }, + due: { type: ['string', 'null'] as Array<'string' | 'null'>, format: 'date-time' }, + }, + required: ['id', 'createdAt', 'updatedAt', 'title', 'priority', 'done', 'due'], + additionalProperties: false, + }, +}; +const MISSING_BINDING = { + key: 'gone', + kind: 'dataTable' as const, + name: 'gone', + missing: true as const, +}; +const STORED_TASKS_BINDING = { + key: 'tasks', + kind: 'dataTable' as const, + dataTableId: 'dt-1', + permissions: ['read', 'write'], +}; const TYPES_PATH = 'apps/greeter/src/n8n-bindings.d.ts'; const ok = (stdout = '') => ({ exitCode: 0, stdout, stderr: '' }); @@ -1078,6 +1119,7 @@ describe('apps tool', () => { '\t\t\t"submit": { input: { "text"?: string | null; "amount"?: number | null; "flag"?: boolean | null; "items"?: unknown[] | null; "meta"?: Record | null; "raw"?: unknown }; output: Array<{ "reply": string }> };', '\t\t\t"notify": { input: Record; output: Array> };', '\t\t};', + '\t\ttables: {};', '\t}', '}', '', @@ -1085,6 +1127,57 @@ describe('apps tool', () => { ); }); + it('renders one row type per bound data table with the system columns and every column type', () => { + const types = renderBindingsTypes([ + TASKS_BINDING, + { + ...TASKS_BINDING, + key: 'notes', + columns: [], + row: { + type: 'object', + properties: { + id: { type: 'number' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + required: ['id', 'createdAt', 'updatedAt'], + additionalProperties: false, + }, + }, + ]); + + expect(types).toBe( + [ + '// Generated by `apps bind`. Do not edit; re-run bind.', + "import '@n8n/app-sdk';", + "declare module '@n8n/app-sdk' {", + '\tinterface Bindings {', + '\t\tworkflows: {};', + '\t\ttables: {', + '\t\t\t"tasks": { row: { "id": number; "createdAt": string; "updatedAt": string; "title": string | null; "priority": number | null; "done": boolean | null; "due": string | null } };', + '\t\t\t"notes": { row: { "id": number; "createdAt": string; "updatedAt": string } };', + '\t\t};', + '\t}', + '}', + '', + ].join('\n'), + ); + }); + + it('skips a binding whose resource is missing', () => { + const types = renderBindingsTypes([ + SUBMIT_BINDING, + MISSING_BINDING, + { ...MISSING_BINDING, key: 'lost', kind: 'workflow' }, + ]); + + expect(types).toContain('"submit": { input:'); + expect(types).toContain('\t\ttables: {};'); + expect(types).not.toContain('gone'); + expect(types).not.toContain('lost'); + }); + it('makes required properties non-optional and types every observed output kind', () => { const types = renderBindingsTypes([ { @@ -1135,8 +1228,8 @@ describe('apps tool', () => { expect(types).toContain('output: Array>'); }); - it('renders an empty workflows map when nothing is bound', () => { - expect(renderBindingsTypes([])).toContain('\t\tworkflows: {};\n'); + it('renders empty workflows and tables maps when nothing is bound', () => { + expect(renderBindingsTypes([])).toContain('\t\tworkflows: {};\n\t\ttables: {};\n'); expect(renderBindingsTypes([])).not.toContain('workflows: {\n'); }); @@ -1249,6 +1342,75 @@ describe('apps tool', () => { }); expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); }); + + it('saves a data table binding in the discriminated shape and regenerates the types', async () => { + const context = createMockContext(); + appServiceMock(context, 'getBindings').mockResolvedValue({ + bindings: [SUBMIT_BINDING], + warnings: [], + stored: STORED_BINDINGS.slice(0, 1), + }); + const saved = { bindings: [SUBMIT_BINDING, TASKS_BINDING], warnings: [] }; + appServiceMock(context, 'setBindings').mockResolvedValue(saved); + + const result = await runAction( + context, + { action: 'bind', bindings: [STORED_TASKS_BINDING] }, + approved, + ); + + expect(appServiceMock(context, 'setBindings')).toHaveBeenCalledWith('app-1', [ + STORED_BINDINGS[0], + STORED_TASKS_BINDING, + ]); + expect(writeFileMock(context)).toHaveBeenCalledWith( + TYPES_PATH, + renderBindingsTypes(saved.bindings), + expect.objectContaining({ recursive: true }), + ); + expect(result).toMatchObject({ bindings: saved.bindings }); + }); + + it('returns denied when workflows and data tables are mixed in one call', async () => { + const context = createMockContext(); + + const result = await runAction( + context, + { action: 'bind', bindings: [STORED_BINDINGS[0], STORED_TASKS_BINDING] }, + approved, + ); + + expect(result).toEqual({ + denied: true, + reason: 'Bind workflows and data tables in separate calls.', + }); + expect(appServiceMock(context, 'get')).not.toHaveBeenCalled(); + expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); + }); + + it.each([ + { key: 'submit', kind: 'workflow' }, + { key: 'submit', kind: 'workflow', workflowId: 'wf-1', dataTableId: 'dt-1' }, + { key: 'tasks', kind: 'dataTable', dataTableId: 'dt-1' }, + { key: 'tasks', kind: 'dataTable', permissions: ['read'] }, + { + key: 'tasks', + kind: 'dataTable', + dataTableId: 'dt-1', + permissions: ['read'], + workflowId: 'wf-1', + }, + ])('returns denied for the fields of %j', async (binding) => { + const context = createMockContext(); + + const result = await runAction(context, { action: 'bind', bindings: [binding] }, approved); + + expect(result).toMatchObject({ + denied: true, + reason: expect.stringContaining(`Binding "${binding.key}": kind "${binding.kind}"`), + }); + expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); + }); }); describe('bind approval', () => { @@ -1310,6 +1472,7 @@ describe('apps tool', () => { 'Connect workflow "Echo" (wf-1) to app "Greeter" as "submit" (callable by anyone with the app URL)', severity: 'warning', appBinding: { + kind: 'workflow', appId: 'app-1', appName: 'Greeter', appNamespace: 'greeter', @@ -1321,10 +1484,10 @@ describe('apps tool', () => { expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); }); - it('falls back to plain text when the preview cannot describe the workflow', async () => { + it('falls back to plain text when the preview reports the workflow as missing', async () => { const context = createMockContext(); appServiceMock(context, 'previewBindings').mockResolvedValue({ - bindings: [], + bindings: [{ ...MISSING_BINDING, key: 'submit', kind: 'workflow' }], warnings: ['gone'], }); const suspend = vi.fn().mockResolvedValue('suspended'); @@ -1371,6 +1534,110 @@ describe('apps tool', () => { }); }); + describe('bind approval for a data table', () => { + const bindInput = { action: 'bind', bindings: [STORED_TASKS_BINDING] }; + + it('is gated by bindAppDataTable, not bindAppWorkflow', async () => { + const context = createMockContext({ + permissions: { bindAppWorkflow: 'always_allow', bindAppDataTable: 'blocked' }, + } as never); + const suspend = vi.fn(); + + const result = await runAction(context, bindInput, { resumeData: undefined, suspend }); + + expect(result).toEqual({ denied: true, reason: 'Action blocked by admin' }); + expect(suspend).not.toHaveBeenCalled(); + expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); + }); + + it('suspends with the table card for a single binding', async () => { + const context = createMockContext(); + appServiceMock(context, 'previewBindings').mockResolvedValue({ + bindings: [TASKS_BINDING], + warnings: [], + }); + const suspend = vi.fn().mockResolvedValue('suspended'); + + const result = await runAction(context, bindInput, { resumeData: undefined, suspend }); + + expect(result).toBe('suspended'); + expect(appServiceMock(context, 'previewBindings')).toHaveBeenCalledWith('app-1', [ + STORED_TASKS_BINDING, + ]); + expect(suspend).toHaveBeenCalledWith({ + requestId: expect.any(String), + message: + 'Connect data table "Tasks" (dt-1) to app "Greeter" as "tasks" with read and write access (anyone with the app URL gets this access)', + severity: 'warning', + appBinding: { + kind: 'dataTable', + appId: 'app-1', + appName: 'Greeter', + appNamespace: 'greeter', + dataTableId: 'dt-1', + dataTableName: 'Tasks', + key: 'tasks', + permissions: ['read', 'write'], + projectId: 'proj-1', + }, + }); + expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); + }); + + it('suspends with plain text naming every table when several are bound at once', async () => { + const context = createMockContext(); + appServiceMock(context, 'previewBindings').mockResolvedValue({ + bindings: [TASKS_BINDING, MISSING_BINDING], + warnings: ['gone'], + }); + const suspend = vi.fn().mockResolvedValue('suspended'); + + await runAction( + context, + { + action: 'bind', + bindings: [ + STORED_TASKS_BINDING, + { key: 'gone', kind: 'dataTable', dataTableId: 'dt-2', permissions: ['read'] }, + ], + }, + { resumeData: undefined, suspend }, + ); + + expect(suspend).toHaveBeenCalledWith({ + requestId: expect.any(String), + message: + 'Connect data table "Tasks" (dt-1) to app "Greeter" as "tasks" with read and write access; ' + + 'Connect data table "gone" (dt-2) to app "Greeter" as "gone" with read access ' + + '(anyone with the app URL gets this access)', + severity: 'warning', + }); + }); + + it('is denied when the user rejects the card', async () => { + const context = createMockContext(); + + const result = await runAction(context, bindInput, { resumeData: { approved: false } }); + + expect(result).toEqual({ denied: true, reason: 'User denied the action' }); + expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); + }); + + it('skips the card when the admin set bindAppDataTable to always_allow', async () => { + const context = createMockContext({ + permissions: { bindAppWorkflow: 'blocked', bindAppDataTable: 'always_allow' }, + } as never); + const suspend = vi.fn(); + + await runAction(context, bindInput, { resumeData: undefined, suspend }); + + expect(suspend).not.toHaveBeenCalled(); + expect(appServiceMock(context, 'setBindings')).toHaveBeenCalledWith('app-1', [ + STORED_TASKS_BINDING, + ]); + }); + }); + describe('unbind', () => { it('drops the key, saves the rest and regenerates the types', async () => { const context = createMockContext(); @@ -1419,19 +1686,28 @@ describe('apps tool', () => { }); describe('bindings', () => { - it('lists the current bindings with their warnings', async () => { + it('lists the current bindings with their warnings and rewrites the types', async () => { const context = createMockContext(); appServiceMock(context, 'getBindings').mockResolvedValue({ - bindings: [SUBMIT_BINDING], + bindings: [SUBMIT_BINDING, TASKS_BINDING], warnings: ['w'], - stored: [STORED_BINDINGS[0]], + stored: [STORED_BINDINGS[0], STORED_TASKS_BINDING], }); const result = await runAction(context, { action: 'bindings' }); - expect(result).toEqual({ appId: 'app-1', bindings: [SUBMIT_BINDING], warnings: ['w'] }); + expect(result).toEqual({ + appId: 'app-1', + bindings: [SUBMIT_BINDING, TASKS_BINDING], + typesPath: 'src/n8n-bindings.d.ts', + warnings: ['w'], + }); expect(appServiceMock(context, 'setBindings')).not.toHaveBeenCalled(); - expect(writeFileMock(context)).not.toHaveBeenCalled(); + expect(writeFileMock(context)).toHaveBeenCalledWith( + TYPES_PATH, + renderBindingsTypes([SUBMIT_BINDING, TASKS_BINDING]), + expect.objectContaining({ recursive: true }), + ); }); }); diff --git a/packages/@n8n/instance-ai/src/tools/apps.tool.ts b/packages/@n8n/instance-ai/src/tools/apps.tool.ts index e3b27a05707..ab385bcffb1 100644 --- a/packages/@n8n/instance-ai/src/tools/apps.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/apps.tool.ts @@ -1,18 +1,21 @@ /** * Apps tool — create an app, restore its stored source into the app's own - * sandbox, bind n8n workflows it may call, and publish it as a served version - * once the user confirms. The agent edits files with the workspace tools and - * `sandbox: 'app'` in between; the live preview follows. + * sandbox, bind n8n workflows and data tables it may use, and publish it as a + * served version once the user confirms. The agent edits files with the + * workspace tools and `sandbox: 'app'` in between; the live preview follows. */ import { Tool } from '@n8n/agents'; import { getWorkspaceRoot } from '@n8n/agents/sandbox'; import { appBindingMetaSchema, + dataTablePermissionSchema, instanceAiApprovalResumeSchema, instanceAiConfirmationSeveritySchema, type AppBinding, type AppBindingMeta, type DescribedBinding, + type DescribedDataTableBinding, + type DescribedWorkflowBinding, } from '@n8n/api-types'; import { getErrorMessage } from '@n8n/utils/errors/get-error-message'; import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; @@ -23,7 +26,7 @@ import { z } from 'zod'; import { APPS_TOOL_ID } from './tool-ids'; import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas'; import { SANDBOX_RUNTIME_SKILLS_DIR } from '../skills/materialize-runtime-skills'; -import type { InstanceAiContext } from '../types'; +import type { AppSummary, InstanceAiContext } from '../types'; import { escapeSingleQuotes } from '../workspace/sandbox-fs'; import { NPM_INSTALL_FLAGS } from '../workspace/sandbox-setup'; @@ -109,6 +112,8 @@ const addComponentSchema = z.object({ ), }); +// A flat object, not a union per kind: the sanitized schema the model sees flattens a +// nested union to this shape anyway, so the per-kind field check lives in `handleBind`. const bindSchema = z.object({ action: z.literal('bind'), appId: z.string(), @@ -118,13 +123,22 @@ const bindSchema = z.object({ key: z .string() .describe( - 'Slug the app calls the workflow by, e.g. "submit" (lowercase, digits, hyphens)', + 'Slug the app calls the workflow or table by, e.g. "submit" or "tasks" (lowercase, digits, hyphens)', + ), + kind: z.enum(['workflow', 'dataTable']), + workflowId: z.string().optional().describe('For kind "workflow"'), + dataTableId: z.string().optional().describe('For kind "dataTable"'), + permissions: z + .array(dataTablePermissionSchema) + .optional() + .describe( + 'For kind "dataTable": "read" lists rows; "write" inserts, updates and deletes them', ), - kind: z.literal('workflow'), - workflowId: z.string(), }), ) - .describe('Bindings to add or replace by key; other bindings stay'), + .describe( + 'Bindings to add or replace by key; other bindings stay. One kind per call: workflows and data tables are approved separately.', + ), }); const unbindSchema = z.object({ @@ -156,6 +170,10 @@ type PublishInput = z.infer; type RestoreInput = z.infer; type AddComponentInput = z.infer; type BindInput = z.infer; +type FlatBinding = BindInput['bindings'][number]; +type WorkflowBinding = Extract; +type DataTableBinding = Extract; +type BindPermission = 'bindAppWorkflow' | 'bindAppDataTable'; type UnbindInput = z.infer; type BindingsInput = z.infer; type AppsInput = @@ -398,22 +416,36 @@ export function jsonSchemaToTs(schema: JSONSchema7Definition): string { ); } +const isWorkflowBinding = (binding: DescribedBinding): binding is DescribedWorkflowBinding => + binding.kind === 'workflow' && !binding.missing; +const isDataTableBinding = (binding: DescribedBinding): binding is DescribedDataTableBinding => + binding.kind === 'dataTable' && !binding.missing; + /** * `src/n8n-bindings.d.ts`: keys, input schema and observed output schema of the bound - * workflows as types for `n8n.workflows.run`. + * workflows as types for `n8n.workflows.run`, and the row type of each bound data table + * for `n8n.tables.`. A binding whose resource is gone gets no entry. */ export function renderBindingsTypes(bindings: DescribedBinding[]): string { - const workflows = bindings.map( + const liveWorkflows = bindings.filter(isWorkflowBinding); + const workflows = liveWorkflows.map( (binding) => `\t\t\t${JSON.stringify(binding.key)}: { input: ${jsonSchemaToTs(binding.input)}; output: ${jsonSchemaToTs(binding.output)} };`, ); - const sources = bindings.flatMap((binding) => + const tables = bindings + .filter(isDataTableBinding) + .map( + (binding) => `\t\t\t${JSON.stringify(binding.key)}: { row: ${jsonSchemaToTs(binding.row)} };`, + ); + const sources = liveWorkflows.flatMap((binding) => binding.outputSource.kind === 'execution' ? [ `// output of ${JSON.stringify(binding.key)} inferred from execution ${binding.outputSource.executionId} (${binding.outputSource.at}); re-run \`apps bindings\` after changing the workflow`, ] : [], ); + const block = (name: string, lines: string[]) => + lines.length > 0 ? [`\t\t${name}: {`, ...lines, '\t\t};'] : [`\t\t${name}: {};`]; // Tabs, like the rest of the template. return [ '// Generated by `apps bind`. Do not edit; re-run bind.', @@ -421,9 +453,8 @@ export function renderBindingsTypes(bindings: DescribedBinding[]): string { "import '@n8n/app-sdk';", "declare module '@n8n/app-sdk' {", '\tinterface Bindings {', - ...(workflows.length > 0 - ? ['\t\tworkflows: {', ...workflows, '\t\t};'] - : ['\t\tworkflows: {};']), + ...block('workflows', workflows), + ...block('tables', tables), '\t}', '}', '', @@ -985,20 +1016,22 @@ async function resolveWorkflowName(context: InstanceAiContext, workflowId: strin } /** - * The `bindAppWorkflow` permission, the way `workflows` asks before a publish: `null` - * when the action may proceed, the reason when it may not; suspends for the card - * when the user has not answered it yet. + * The `bindAppWorkflow` or `bindAppDataTable` permission, the way `workflows` asks before + * a publish: `null` when the action may proceed, the reason when it may not; suspends for + * the card when the user has not answered it yet. */ -async function requireBindAppWorkflowApproval( +async function requireBindApproval( context: InstanceAiContext, ctx: ConfirmationToolContext, + permission: BindPermission, describe: () => Promise<{ message: string; appBinding?: AppBindingMeta }>, ): Promise { - if (context.permissions?.bindAppWorkflow === 'blocked') { + const mode = context.permissions?.[permission]; + if (mode === 'blocked') { return { denied: true, reason: 'Action blocked by admin' }; } const resumeData = ctx.resumeData; - const needsApproval = context.permissions?.bindAppWorkflow !== 'always_allow'; + const needsApproval = mode !== 'always_allow'; if (needsApproval && (resumeData === undefined || resumeData === null)) { return await ctx.suspend({ requestId: nanoid(), @@ -1012,9 +1045,93 @@ async function requireBindAppWorkflowApproval( return null; } +type BoundApp = Omit; +type BindDescription = { message: string; appBinding?: AppBindingMeta }; + +/** The model passes one flat object per binding; the service wants the discriminated union. */ +function toAppBinding(flat: FlatBinding): AppBinding | AppActionDenied { + const { key, kind, workflowId, dataTableId, permissions } = flat; + if (kind === 'workflow') { + return workflowId !== undefined && dataTableId === undefined && permissions === undefined + ? { key, kind, workflowId } + : { denied: true, reason: `Binding "${key}": kind "workflow" takes workflowId only.` }; + } + return dataTableId !== undefined && permissions !== undefined && workflowId === undefined + ? { key, kind, dataTableId, permissions } + : { + denied: true, + reason: `Binding "${key}": kind "dataTable" takes dataTableId and permissions ("read", "write") only.`, + }; +} + +async function describeWorkflowBind( + context: InstanceAiContext, + app: BoundApp, + bindings: WorkflowBinding[], +): Promise { + const appService = requireAppService(context); + const lines = await Promise.all( + bindings.map( + async ({ key, workflowId }) => + `Connect workflow "${await resolveWorkflowName(context, workflowId)}" (${workflowId}) to app "${app.name}" as "${key}"`, + ), + ); + // One line: the card renders the message as plain HTML text, which folds newlines. + const message = `${lines.join('; ')} (callable by anyone with the app URL)`; + // The structured card shows one binding. A multi-binding call, or a workflow the + // preview cannot resolve (the write refuses it with the reason), gets the plain text. + const [described] = + bindings.length === 1 ? (await appService.previewBindings(app.id, bindings)).bindings : []; + if (!described || !isWorkflowBinding(described)) return { message }; + return { + message, + appBinding: { + kind: 'workflow', + appId: app.id, + appName: app.name, + appNamespace: app.namespace, + workflowId: described.workflowId, + workflowName: described.name, + key: described.key, + }, + }; +} + +async function describeDataTableBind( + context: InstanceAiContext, + app: BoundApp, + bindings: DataTableBinding[], +): Promise { + // The preview is the one source of a table's name; a table it cannot resolve reads as its key. + const described = (await requireAppService(context).previewBindings(app.id, bindings)).bindings; + const nameOf = (key: string) => described.find((binding) => binding.key === key)?.name ?? key; + const lines = bindings.map( + ({ key, dataTableId, permissions }) => + `Connect data table "${nameOf(key)}" (${dataTableId}) to app "${app.name}" as "${key}" with ${permissions.join(' and ')} access`, + ); + const message = `${lines.join('; ')} (anyone with the app URL gets this access)`; + const [only] = described; + if (bindings.length !== 1 || !only || !isDataTableBinding(only)) return { message }; + return { + message, + appBinding: { + kind: 'dataTable', + appId: app.id, + appName: app.name, + appNamespace: app.namespace, + dataTableId: only.dataTableId, + dataTableName: only.name, + key: only.key, + permissions: only.permissions, + projectId: app.projectId, + }, + }; +} + /** * Upsert by key: a binding with an existing key replaces it, the others stay. A bind - * exposes a workflow to anyone with the app's URL, so it asks the user first. + * exposes a workflow or a data table to anyone with the app's URL, so it asks the user + * first; one card per call, so a call binds one kind. */ async function handleBind( context: InstanceAiContext, @@ -1025,48 +1142,45 @@ async function handleBind( if (!input.bindings?.length) { return { denied: true, - reason: 'Pass at least one binding as { key, kind: "workflow", workflowId }.', + reason: + 'Pass at least one binding as { key, kind: "workflow", workflowId } or { key, kind: "dataTable", dataTableId, permissions }.', }; } - const appService = requireAppService(context); - const app = await appService.get(input.appId); + const bindings: AppBinding[] = []; + for (const flat of input.bindings) { + const binding = toAppBinding(flat); + if ('denied' in binding) return binding; + bindings.push(binding); + } + const workflows = bindings.filter((b): b is WorkflowBinding => b.kind === 'workflow'); + const tables = bindings.filter((b): b is DataTableBinding => b.kind === 'dataTable'); + if (workflows.length > 0 && tables.length > 0) { + return { denied: true, reason: 'Bind workflows and data tables in separate calls.' }; + } + const app = await requireAppService(context).get(input.appId); - const denied = await requireBindAppWorkflowApproval(context, ctx, async () => { - const lines = await Promise.all( - input.bindings.map( - async ({ key, workflowId }) => - `Connect workflow "${await resolveWorkflowName(context, workflowId)}" (${workflowId}) to app "${app.name}" as "${key}"`, - ), - ); - // One line: the card renders the message as plain HTML text, which folds newlines. - const message = `${lines.join('; ')} (callable by anyone with the app URL)`; - // The structured card shows one binding. A multi-binding call, or a workflow the - // preview cannot resolve (the write refuses it with the reason), gets the plain text. - const [described] = - input.bindings.length === 1 - ? (await appService.previewBindings(app.id, input.bindings)).bindings - : []; - if (!described) return { message }; - return { - message, - appBinding: { - appId: app.id, - appName: app.name, - appNamespace: app.namespace, - workflowId: described.workflowId, - workflowName: described.name, - key: described.key, - }, - }; - }); + const denied = + tables.length > 0 + ? await requireBindApproval( + context, + ctx, + 'bindAppDataTable', + async () => await describeDataTableBind(context, app, tables), + ) + : await requireBindApproval( + context, + ctx, + 'bindAppWorkflow', + async () => await describeWorkflowBind(context, app, workflows), + ); if (denied) return denied; - const replaced = new Set(input.bindings.map((binding) => binding.key)); + const replaced = new Set(bindings.map((binding) => binding.key)); return await replaceBindings( context, app.id, app.namespace, - (current) => [...current.filter((binding) => !replaced.has(binding.key)), ...input.bindings], + (current) => [...current.filter((binding) => !replaced.has(binding.key)), ...bindings], ctx.abortSignal, ); } @@ -1086,11 +1200,22 @@ async function handleUnbind( ); } -async function handleBindings(context: InstanceAiContext, input: BindingsInput) { +/** + * Lists and re-describes the bindings. The types follow the current resources (a + * published trigger, a new execution sample, a changed table column), so the file is + * rewritten here too. + */ +async function handleBindings( + context: InstanceAiContext, + input: BindingsInput, + abortSignal?: AbortSignal, +) { const appService = requireAppService(context); + const { workspace } = requireSandbox(context, abortSignal); const app = await appService.get(input.appId); const { bindings, warnings } = await appService.getBindings(app.id); - return { appId: app.id, bindings, warnings }; + await writeBindingsTypes(workspace, app.namespace, bindings, abortSignal); + return { appId: app.id, bindings, typesPath: BINDINGS_TYPES_PATH, warnings }; } async function readTarball( @@ -1140,10 +1265,10 @@ export function createAppsTool(context: InstanceAiContext) { '`publish` returns the published `url` on success, `{ denied }` when the user declines, or `{ error, stage, message, log }` to fix and retry. ' + "`restore` fills apps// for an existing app when the app's sandbox does not have it yet: it unpacks the stored source, or copies the starter template when the app has no source (`scaffolded: true`). " + "`add-component` copies a component from this app-builder skill's own catalog (built on @ark-ui/vue) into src/components/ui/ — `create` uses it for the two the starter page needs, and every other component goes through it too. " + - '`bind` lets the app call n8n workflows by key through `@n8n/app-sdk` (`n8n.workflows.run(key, input)`): ' + - 'pass `{ key, kind: "workflow", workflowId }` entries, and it rewrites src/n8n-bindings.d.ts with the input types. ' + - 'It asks the user for approval first: every app is public, so a bound workflow is callable by anyone with the app URL. ' + - '`unbind` removes a key; `bindings` lists the current ones. Bind before writing code that calls a workflow.', + '`bind` lets the app call n8n workflows by key through `@n8n/app-sdk` (`n8n.workflows.run(key, input)`) and read or write data table rows (`n8n.tables..list/insert/update/delete`): ' + + 'pass `{ key, kind: "workflow", workflowId }` or `{ key, kind: "dataTable", dataTableId, permissions: ["read", "write"] }` entries (one kind per call), and it rewrites src/n8n-bindings.d.ts with the input and row types. ' + + 'It asks the user for approval first: every app is public, so a bound workflow or table is reachable by anyone with the app URL. ' + + '`unbind` removes a key; `bindings` lists the current ones and rewrites the types (call it after a bound table changes columns). Bind before writing code that uses a workflow or table.', ) .input(inputSchema) .suspend(confirmationSuspendSchema) @@ -1163,7 +1288,7 @@ export function createAppsTool(context: InstanceAiContext) { case 'unbind': return await handleUnbind(context, input, ctx.abortSignal); case 'bindings': - return await handleBindings(context, input); + return await handleBindings(context, input, ctx.abortSignal); } }) .build(); diff --git a/packages/cli/src/modules/apps/__tests__/apps.service.test.ts b/packages/cli/src/modules/apps/__tests__/apps.service.test.ts index 6cbff653a55..12bdd4abff6 100644 --- a/packages/cli/src/modules/apps/__tests__/apps.service.test.ts +++ b/packages/cli/src/modules/apps/__tests__/apps.service.test.ts @@ -7,6 +7,10 @@ import { mock } from 'vitest-mock-extended'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; import { WorkflowToolUnavailableError } from '@/modules/agents/tools/workflow-tool-unavailable-error'; import type { WorkflowToolWorkflowLoader } from '@/modules/agents/tools/workflow-tool-workflow-loader.service'; +import type { DataTableColumn } from '@/modules/data-table/data-table-column.entity'; +import type { DataTable } from '@/modules/data-table/data-table.entity'; +import type { DataTableService } from '@/modules/data-table/data-table.service'; +import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import type { AppVersion } from '../app-version.entity'; @@ -16,6 +20,7 @@ import type { AppRepository } from '../app.repository'; import { AppsService } from '../apps.service'; import { AppNotFoundError } from '../errors/app-not-found.error'; import { AppQuotaExceededError } from '../errors/app-quota-exceeded.error'; +import { BindingDataTableNotFoundError } from '../errors/binding-data-table-not-found.error'; import { BindingIncompatibleError } from '../errors/binding-incompatible.error'; import { BindingNotFoundError } from '../errors/binding-not-found.error'; import { BindingProjectMismatchError } from '../errors/binding-project-mismatch.error'; @@ -41,6 +46,7 @@ describe('AppsService', () => { globalConfig, mock(), mock(), + mock(), ); }); @@ -118,6 +124,17 @@ const binding = (key = 'submit', workflowId = 'wf-1'): AppBinding => ({ workflowId, }); +const tableBinding = ( + permissions: Array<'read' | 'write'> = ['read', 'write'], + key = 'tasks', +): AppBinding => ({ key, kind: 'dataTable', dataTableId: 'dt-1', permissions }); + +const table = (overrides: Partial = {}): DataTable => + ({ id: 'dt-1', name: 'Tasks', projectId: 'proj-1', ...overrides }) as DataTable; + +const column = (name: string, type: DataTableColumn['type']) => + ({ name, type, dataTableId: 'dt-1' }) as DataTableColumn; + const notPublished = () => new WorkflowToolUnavailableError('not_published', 'not published'); const successfulExecution = (items: IDataObject[]): IExecutionResponse => @@ -156,6 +173,7 @@ describe('AppsService bindings', () => { let workflowFinderService: ReturnType>; let workflowLoader: ReturnType>; let executionPersistence: ReturnType>; + let dataTableService: ReturnType>; let service: AppsService; let app: App; @@ -164,6 +182,7 @@ describe('AppsService bindings', () => { workflowFinderService = mock(); workflowLoader = mock(); executionPersistence = mock(); + dataTableService = mock(); executionPersistence.findMultipleExecutions.mockResolvedValue([ successfulExecution([{ reply: 'hi' }]), ]); @@ -175,6 +194,7 @@ describe('AppsService bindings', () => { mock({ executions: { maxDisplaySize: 1024 } }), workflowLoader, executionPersistence, + dataTableService, ); app = { id: 'app-1', projectId: 'proj-1', bindings: [] } as unknown as App; appRepository.findOneBy.mockResolvedValue(app); @@ -267,6 +287,68 @@ describe('AppsService bindings', () => { expect(result.bindings.map((b) => b.key)).toEqual(['submit']); expect(result.warnings).toEqual([]); }); + + it('rejects a data table the user cannot read', async () => { + dataTableService.findDataTablesByIdsForUser.mockResolvedValue([]); + + await expect( + service.setBindings('app-1', [tableBinding(['read'])], user), + ).rejects.toBeInstanceOf(BindingDataTableNotFoundError); + expect(dataTableService.findDataTablesByIdsForUser).toHaveBeenCalledWith(['dt-1'], user, [ + 'dataTable:readRow', + ]); + expect(appRepository.updateBindings).not.toHaveBeenCalled(); + }); + + it('requires the write scope for a write binding', async () => { + dataTableService.findDataTablesByIdsForUser.mockResolvedValue([]); + + await expect(service.setBindings('app-1', [tableBinding()], user)).rejects.toBeInstanceOf( + BindingDataTableNotFoundError, + ); + expect(dataTableService.findDataTablesByIdsForUser).toHaveBeenCalledWith(['dt-1'], user, [ + 'dataTable:readRow', + 'dataTable:writeRow', + ]); + }); + + it('rejects a data table owned by another project', async () => { + dataTableService.findDataTablesByIdsForUser.mockResolvedValue([ + table({ projectId: 'proj-2' }), + ]); + + await expect(service.setBindings('app-1', [tableBinding()], user)).rejects.toThrow( + 'Binding \'tasks\': data table "Tasks" belongs to another project.', + ); + expect(appRepository.updateBindings).not.toHaveBeenCalled(); + }); + + it('rejects a data table binding without permissions', async () => { + await expect(service.setBindings('app-1', [tableBinding([])], user)).rejects.toBeInstanceOf( + InvalidBindingsError, + ); + expect(dataTableService.findDataTablesByIdsForUser).not.toHaveBeenCalled(); + }); + + it('binds a data table and describes its columns', async () => { + dataTableService.findDataTablesByIdsForUser.mockResolvedValue([table()]); + dataTableService.validateDataTableExists.mockResolvedValue(table()); + dataTableService.getColumns.mockResolvedValue([column('title', 'string')]); + + const result = await service.setBindings('app-1', [tableBinding(['read'])], user); + + expect(appRepository.updateBindings).toHaveBeenCalledWith(app, [tableBinding(['read'])]); + expect(result.bindings).toEqual([ + expect.objectContaining({ + key: 'tasks', + kind: 'dataTable', + name: 'Tasks', + permissions: ['read'], + columns: [{ name: 'title', type: 'string' }], + }), + ]); + expect(result.warnings).toEqual([]); + }); }); describe('removeBinding', () => { @@ -361,7 +443,9 @@ describe('AppsService bindings', () => { const result = await service.describeBindings(app); - expect(result.bindings[0].input).toEqual(inputOf({ message: stringField('message') })); + expect(result.bindings[0]).toMatchObject({ + input: inputOf({ message: stringField('message') }), + }); expect(result.warnings).toEqual([]); }); @@ -373,13 +457,15 @@ describe('AppsService bindings', () => { const result = await service.describeBindings(app); - expect(result.bindings[0].input).toEqual({ type: 'object', additionalProperties: true }); + expect(result.bindings[0]).toMatchObject({ + input: { type: 'object', additionalProperties: true }, + }); expect(result.warnings).toEqual([ 'Binding \'submit\': workflow "Echo" accepts any input (trigger has no declared fields): the app cannot type-check its input and the server does not validate it. Declare fields on the trigger to get typed input.', ]); }); - it('leaves out a binding whose workflow no longer exists and warns', async () => { + it('keeps a binding whose workflow no longer exists as missing and warns', async () => { app.bindings = [binding('gone', 'wf-gone'), binding()]; workflowLoader.loadWorkflow.mockImplementation(async (_projectId, reference) => reference.workflowId === 'wf-1' ? workflow() : null, @@ -387,8 +473,96 @@ describe('AppsService bindings', () => { const result = await service.describeBindings(app); - expect(result.bindings.map((b) => b.key)).toEqual(['submit']); - expect(result.warnings).toEqual([expect.stringContaining("'gone'")]); + expect(result.bindings).toEqual([ + { key: 'gone', kind: 'workflow', name: 'gone', missing: true }, + expect.objectContaining({ key: 'submit', workflowId: 'wf-1' }), + ]); + expect(result.warnings).toEqual([ + "Binding 'gone': workflow 'wf-gone' no longer exists in the app's project.", + ]); + }); + + it('keeps a binding whose workflow lost its trigger as missing and warns', async () => { + app.bindings = [binding()]; + workflowLoader.loadWorkflow.mockResolvedValue( + workflow({ nodes: [{ ...triggerNode(), type: 'n8n-nodes-base.manualTrigger' }] }), + ); + + const result = await service.describeBindings(app); + + expect(result.bindings).toEqual([ + { key: 'submit', kind: 'workflow', name: 'submit', missing: true }, + ]); + expect(result.warnings).toEqual([expect.stringContaining('no longer starts with')]); + }); + + it('describes a data table binding with its columns and a nullable row schema', async () => { + app.bindings = [tableBinding()]; + dataTableService.validateDataTableExists.mockResolvedValue(table()); + dataTableService.getColumns.mockResolvedValue([ + column('title', 'string'), + column('points', 'number'), + column('done', 'boolean'), + column('due', 'date'), + ]); + + const result = await service.describeBindings(app); + + expect(dataTableService.validateDataTableExists).toHaveBeenCalledWith('dt-1', 'proj-1'); + expect(dataTableService.getColumns).toHaveBeenCalledWith('dt-1', 'proj-1'); + expect(result.bindings).toEqual([ + { + key: 'tasks', + kind: 'dataTable', + dataTableId: 'dt-1', + name: 'Tasks', + permissions: ['read', 'write'], + columns: [ + { name: 'title', type: 'string' }, + { name: 'points', type: 'number' }, + { name: 'done', type: 'boolean' }, + { name: 'due', type: 'date' }, + ], + row: { + type: 'object', + properties: { + id: { type: 'number' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + title: { type: ['string', 'null'] }, + points: { type: ['number', 'null'] }, + done: { type: ['boolean', 'null'] }, + due: { type: ['string', 'null'], format: 'date-time' }, + }, + required: ['id', 'createdAt', 'updatedAt', 'title', 'points', 'done', 'due'], + additionalProperties: false, + }, + }, + ]); + expect(result.warnings).toEqual([]); + }); + + it('keeps a binding whose data table no longer exists as missing and warns', async () => { + app.bindings = [tableBinding()]; + dataTableService.validateDataTableExists.mockRejectedValue( + new DataTableNotFoundError('dt-1'), + ); + + const result = await service.describeBindings(app); + + expect(result.bindings).toEqual([ + { key: 'tasks', kind: 'dataTable', name: 'tasks', missing: true }, + ]); + expect(result.warnings).toEqual([ + "Binding 'tasks': data table 'dt-1' no longer exists in the app's project.", + ]); + }); + + it('rethrows a data table failure that is not a missing table', async () => { + app.bindings = [tableBinding()]; + dataTableService.validateDataTableExists.mockRejectedValue(new Error('db down')); + + await expect(service.describeBindings(app)).rejects.toThrow('db down'); }); it('types the output from the latest successful execution of the workflow', async () => { diff --git a/packages/cli/src/modules/apps/apps.module.ts b/packages/cli/src/modules/apps/apps.module.ts index 359316344b6..910a1809044 100644 --- a/packages/cli/src/modules/apps/apps.module.ts +++ b/packages/cli/src/modules/apps/apps.module.ts @@ -9,6 +9,7 @@ export class AppsModule implements ModuleInterface { // Before the serving controller: routes register in import order, and its // catch-all would otherwise answer `/apps//api/*` with the SPA's index.html. await import('./runtime/app-runtime.controller.js'); + await import('./runtime/app-table-runtime.controller.js'); await import('./serving/app-serving.controller.js'); await import('./serving/app-inspector-script.controller.js'); diff --git a/packages/cli/src/modules/apps/apps.service.ts b/packages/cli/src/modules/apps/apps.service.ts index 464579ff63e..c50b7199f72 100644 --- a/packages/cli/src/modules/apps/apps.service.ts +++ b/packages/cli/src/modules/apps/apps.service.ts @@ -6,15 +6,17 @@ import { type CreateAppDto, type CreatePageDto, type DescribedBinding, + type DescribedWorkflowBinding, type UpdateAppDto, type UpdatePageDto, } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; import type { User, WorkflowEntity } from '@n8n/db'; import { Service } from '@n8n/di'; +import type { Scope } from '@n8n/permissions'; import { isRecord } from '@n8n/utils/is-record'; import type { JSONSchema7 } from 'json-schema'; -import { UnexpectedError, type INode } from 'n8n-workflow'; +import { UnexpectedError, type DataTableColumnType, type INode } from 'n8n-workflow'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { ExecutionPersistence } from '@/executions/execution-persistence'; @@ -25,6 +27,8 @@ import { } from '@/modules/agents/tools/workflow-tool-factory'; import { WorkflowToolUnavailableError } from '@/modules/agents/tools/workflow-tool-unavailable-error'; import { WorkflowToolWorkflowLoader } from '@/modules/agents/tools/workflow-tool-workflow-loader.service'; +import { DataTableService } from '@/modules/data-table/data-table.service'; +import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { AppVersionService } from './app-version.service'; @@ -33,6 +37,7 @@ import { AppRepository } from './app.repository'; import { deriveRoutesFromRouterSource } from './derive-routes'; import { AppNotFoundError } from './errors/app-not-found.error'; import { AppQuotaExceededError } from './errors/app-quota-exceeded.error'; +import { BindingDataTableNotFoundError } from './errors/binding-data-table-not-found.error'; import { BindingIncompatibleError } from './errors/binding-incompatible.error'; import { BindingNotFoundError } from './errors/binding-not-found.error'; import { BindingProjectMismatchError } from './errors/binding-project-mismatch.error'; @@ -50,6 +55,31 @@ import { PageRepository } from './page.repository'; const PASSTHROUGH_INPUT_SCHEMA: JSONSchema7 = { type: 'object', additionalProperties: true }; +type WorkflowBinding = Extract; +type DataTableBinding = Extract; +type Described = { binding: DescribedBinding; warnings: string[] }; + +/** Dates leave the runtime as ISO strings (JSON); every user column may hold `null`. */ +function rowColumnSchema(type: DataTableColumnType): JSONSchema7 { + if (type === 'date') return { type: ['string', 'null'], format: 'date-time' }; + return { type: [type, 'null'] }; +} + +function rowJsonSchema(columns: Array<{ name: string; type: DataTableColumnType }>): JSONSchema7 { + const properties: Record = { + id: { type: 'number' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + ...Object.fromEntries(columns.map(({ name, type }) => [name, rowColumnSchema(type)])), + }; + return { + type: 'object', + properties, + required: Object.keys(properties), + additionalProperties: false, + }; +} + /** `zod-to-json-schema` types its result as its own union; what it emits is draft-07. */ const isJsonSchema = (value: unknown): value is JSONSchema7 => isRecord(value); @@ -74,6 +104,7 @@ export class AppsService { private readonly globalConfig: GlobalConfig, private readonly workflowLoader: WorkflowToolWorkflowLoader, private readonly executionPersistence: ExecutionPersistence, + private readonly dataTableService: DataTableService, ) {} async createApp(projectId: string, dto: CreateAppDto) { @@ -144,10 +175,11 @@ export class AppsService { } /** - * Replaces the whole binding list. Each workflow is checked with the acting user's - * `workflow:execute` scope now; at call time the app acts as its project, so the - * workflow must also be owned by that project. Unpublished workflows are accepted - * (reported as a warning) so an agent can bind first and publish later. + * Replaces the whole binding list. Each resource is checked with the acting user's + * scopes now (`workflow:execute`; `dataTable:readRow` plus `dataTable:writeRow` for a + * `write` binding); at call time the app acts as its project, so the resource must also + * be owned by that project. Unpublished workflows are accepted (reported as a warning) + * so an agent can bind first and publish later. */ async setBindings(appId: string, bindings: AppBinding[], user: User) { const parsed = appBindingsSchema.safeParse(bindings); @@ -157,21 +189,10 @@ export class AppsService { const app = await this.getApp(appId); for (const binding of parsed.data) { - const workflow = await this.workflowFinderService.findWorkflowForUser( - binding.workflowId, - user, - ['workflow:execute'], - ); - if (!workflow) throw new BindingWorkflowNotFoundError(binding.key, binding.workflowId); - - const ownerProjectId = workflow.shared.find((s) => s.role === 'workflow:owner')?.projectId; - if (ownerProjectId !== app.projectId) { - throw new BindingProjectMismatchError(binding.key, workflow.name); - } - - const incompatibility = getWorkflowToolIncompatibilityReason(workflow); - if (incompatibility) { - throw new BindingIncompatibleError(binding.key, workflow.name, incompatibility); + if (binding.kind === 'dataTable') { + await this.checkDataTableBinding(binding, app.projectId, user); + } else { + await this.checkWorkflowBinding(binding, app.projectId, user); } } @@ -179,6 +200,40 @@ export class AppsService { return await this.describeBindings(updated); } + private async checkWorkflowBinding(binding: WorkflowBinding, projectId: string, user: User) { + const workflow = await this.workflowFinderService.findWorkflowForUser( + binding.workflowId, + user, + ['workflow:execute'], + ); + if (!workflow) throw new BindingWorkflowNotFoundError(binding.key, binding.workflowId); + + const ownerProjectId = workflow.shared.find((s) => s.role === 'workflow:owner')?.projectId; + if (ownerProjectId !== projectId) { + throw new BindingProjectMismatchError(binding.key, 'workflow', workflow.name); + } + + const incompatibility = getWorkflowToolIncompatibilityReason(workflow); + if (incompatibility) { + throw new BindingIncompatibleError(binding.key, workflow.name, incompatibility); + } + } + + private async checkDataTableBinding(binding: DataTableBinding, projectId: string, user: User) { + const scopes: Scope[] = binding.permissions.includes('write') + ? ['dataTable:readRow', 'dataTable:writeRow'] + : ['dataTable:readRow']; + const [table] = await this.dataTableService.findDataTablesByIdsForUser( + [binding.dataTableId], + user, + scopes, + ); + if (!table) throw new BindingDataTableNotFoundError(binding.key, binding.dataTableId); + if (table.projectId !== projectId) { + throw new BindingProjectMismatchError(binding.key, 'data table', table.name); + } + } + /** Drops one binding by key; the others are not re-checked. Returns the remaining ones described. */ async removeBinding(appId: string, key: string) { const app = await this.getApp(appId); @@ -188,53 +243,82 @@ export class AppsService { } /** - * Resolves each binding against the workflow the runtime will run: the published - * version, or the draft with a warning while none is published. Its trigger declares - * the input fields, so the generated types match what the runtime validates. A binding - * whose workflow left the project or lost its trigger since bind time is left out and - * reported as a warning, so the remaining bindings stay usable. The output schema comes - * from the latest successful execution; without one the items stay open, with a warning. + * Resolves each binding against the resource the runtime will use. A workflow binding + * resolves to the published version, or the draft with a warning while none is + * published; its trigger declares the input fields, so the generated types match what + * the runtime validates, and the output schema comes from the latest successful + * execution (open items with a warning without one). A data table binding resolves to + * its columns. A binding whose resource left the project, or whose workflow lost its + * trigger, since bind time stays in the list as `missing` with a warning, so the UI can + * still show and remove it while the others stay usable. */ async describeBindings( app: Pick, ): Promise<{ bindings: DescribedBinding[]; warnings: string[] }> { - const bindings: DescribedBinding[] = []; - const warnings: string[] = []; + const described: Described[] = []; for (const binding of app.bindings) { - const loaded = await this.loadBoundWorkflow(app.projectId, binding.workflowId); - if (!loaded) { - warnings.push( + described.push( + binding.kind === 'dataTable' + ? await this.describeDataTableBinding(app.projectId, binding) + : await this.describeWorkflowBinding(app.projectId, binding), + ); + } + return { + bindings: described.map(({ binding }) => binding), + warnings: described.flatMap(({ warnings }) => warnings), + }; + } + + private async describeWorkflowBinding( + projectId: string, + binding: WorkflowBinding, + ): Promise { + const missing = { + key: binding.key, + kind: binding.kind, + name: binding.key, + missing: true as const, + }; + const loaded = await this.loadBoundWorkflow(projectId, binding.workflowId); + if (!loaded) { + return { + binding: missing, + warnings: [ `Binding '${binding.key}': workflow '${binding.workflowId}' no longer exists in the app's project.`, - ); - continue; - } - const { workflow, published } = loaded; - if (getWorkflowToolIncompatibilityReason(workflow) !== null) { - warnings.push( + ], + }; + } + const { workflow, published } = loaded; + if (getWorkflowToolIncompatibilityReason(workflow) !== null) { + return { + binding: missing, + warnings: [ `Binding '${binding.key}': workflow "${workflow.name}" no longer starts with '${WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME}' or contains nodes an app cannot run.`, - ); - continue; - } - if (!published) { - warnings.push( - `Binding '${binding.key}': workflow "${workflow.name}" is not published. Types for "${binding.key}" come from the unpublished draft; the app gets an error until it is published.`, - ); - } - // `inferInputSchema` also treats a trigger without declared fields as passthrough. - const trigger = detectTriggerNode(workflow); - const passthrough = listWorkflowInputFields(trigger.node).length === 0; - if (passthrough) { - warnings.push( - `Binding '${binding.key}': workflow "${workflow.name}" accepts any input (trigger has no declared fields): the app cannot type-check its input and the server does not validate it. Declare fields on the trigger to get typed input.`, - ); - } - const { output, outputSource } = await this.inferOutput(workflow.id); - if (outputSource.kind === 'unknown') { - warnings.push( - `Binding '${binding.key}': output is untyped. Run the workflow once (executions run) and call \`apps bindings\` to type it from the result.`, - ); - } - bindings.push({ + ], + }; + } + const warnings: string[] = []; + if (!published) { + warnings.push( + `Binding '${binding.key}': workflow "${workflow.name}" is not published. Types for "${binding.key}" come from the unpublished draft; the app gets an error until it is published.`, + ); + } + // `inferInputSchema` also treats a trigger without declared fields as passthrough. + const trigger = detectTriggerNode(workflow); + const passthrough = listWorkflowInputFields(trigger.node).length === 0; + if (passthrough) { + warnings.push( + `Binding '${binding.key}': workflow "${workflow.name}" accepts any input (trigger has no declared fields): the app cannot type-check its input and the server does not validate it. Declare fields on the trigger to get typed input.`, + ); + } + const { output, outputSource } = await this.inferOutput(workflow.id); + if (outputSource.kind === 'unknown') { + warnings.push( + `Binding '${binding.key}': output is untyped. Run the workflow once (executions run) and call \`apps bindings\` to type it from the result.`, + ); + } + return { + binding: { key: binding.key, kind: binding.kind, workflowId: workflow.id, @@ -245,10 +329,45 @@ export class AppsService { : inputJsonSchema(trigger.node, trigger.triggerType), output, outputSource, - }); - } + }, + warnings, + }; + } - return { bindings, warnings }; + /** Scoped to the app's project, like the runtime: a table moved elsewhere reads as missing. */ + private async describeDataTableBinding( + projectId: string, + binding: DataTableBinding, + ): Promise { + try { + const table = await this.dataTableService.validateDataTableExists( + binding.dataTableId, + projectId, + ); + const columns = (await this.dataTableService.getColumns(binding.dataTableId, projectId)).map( + ({ name, type }) => ({ name, type }), + ); + return { + binding: { + key: binding.key, + kind: binding.kind, + dataTableId: binding.dataTableId, + name: table.name, + permissions: binding.permissions, + columns, + row: rowJsonSchema(columns), + }, + warnings: [], + }; + } catch (error) { + if (!(error instanceof DataTableNotFoundError)) throw error; + return { + binding: { key: binding.key, kind: binding.kind, name: binding.key, missing: true }, + warnings: [ + `Binding '${binding.key}': data table '${binding.dataTableId}' no longer exists in the app's project.`, + ], + }; + } } /** @@ -259,7 +378,7 @@ export class AppsService { */ private async inferOutput( workflowId: string, - ): Promise> { + ): Promise> { const [execution] = await this.executionPersistence.findMultipleExecutions( { select: ['id', 'mode', 'startedAt', 'stoppedAt', 'workflowId', 'jsonSizeBytes'], diff --git a/packages/cli/src/modules/apps/errors/binding-data-table-not-found.error.ts b/packages/cli/src/modules/apps/errors/binding-data-table-not-found.error.ts new file mode 100644 index 00000000000..45bddcd112c --- /dev/null +++ b/packages/cli/src/modules/apps/errors/binding-data-table-not-found.error.ts @@ -0,0 +1,8 @@ +import { NotFoundError } from '@/errors/response-errors/not-found.error'; + +/** Thrown when a binding's `dataTableId` doesn't resolve to a data table the acting user can read (and write, when asked). */ +export class BindingDataTableNotFoundError extends NotFoundError { + constructor(key: string, dataTableId: string) { + super(`Binding '${key}': could not find the data table '${dataTableId}'.`); + } +} diff --git a/packages/cli/src/modules/apps/errors/binding-project-mismatch.error.ts b/packages/cli/src/modules/apps/errors/binding-project-mismatch.error.ts index 87b7a02e0bf..73309bbf9f5 100644 --- a/packages/cli/src/modules/apps/errors/binding-project-mismatch.error.ts +++ b/packages/cli/src/modules/apps/errors/binding-project-mismatch.error.ts @@ -1,10 +1,10 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -/** The served app acts as its project, so it can only call workflows that project owns. */ +/** The served app acts as its project, so it can only reach workflows and data tables that project owns. */ export class BindingProjectMismatchError extends BadRequestError { - constructor(key: string, workflowName: string) { + constructor(key: string, resource: 'workflow' | 'data table', name: string) { super( - `Binding '${key}': workflow "${workflowName}" belongs to another project. Only workflows in the app's project can be bound.`, + `Binding '${key}': ${resource} "${name}" belongs to another project. Only ${resource}s in the app's project can be bound.`, ); } } diff --git a/packages/cli/src/modules/apps/runtime/__tests__/app-table-runtime.service.test.ts b/packages/cli/src/modules/apps/runtime/__tests__/app-table-runtime.service.test.ts new file mode 100644 index 00000000000..9e7855debf9 --- /dev/null +++ b/packages/cli/src/modules/apps/runtime/__tests__/app-table-runtime.service.test.ts @@ -0,0 +1,221 @@ +import { mock } from 'vitest-mock-extended'; + +import type { DataTable } from '@/modules/data-table/data-table.entity'; +import type { DataTableService } from '@/modules/data-table/data-table.service'; +import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; +import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error'; + +import type { App } from '../../app.entity'; +import type { AppRepository } from '../../app.repository'; +import { AppRuntimeError } from '../app-runtime.error'; +import { AppTableRuntimeService } from '../app-table-runtime.service'; + +const app = (permissions: Array<'read' | 'write'> = ['read', 'write']) => + ({ + id: 'app-1', + namespace: 'board', + projectId: 'proj-1', + bindings: [ + { key: 'submit', kind: 'workflow', workflowId: 'wf-1' }, + { key: 'tasks', kind: 'dataTable', dataTableId: 'dt-1', permissions }, + ], + }) as unknown as App; + +const row = { id: 1, title: 'a', createdAt: new Date(0), updatedAt: new Date(0) }; + +const failure = async (promise: Promise) => { + const error: unknown = await promise.catch((e: unknown) => e); + expect(error).toBeInstanceOf(AppRuntimeError); + return error as AppRuntimeError; +}; + +describe('AppTableRuntimeService', () => { + let appRepository: ReturnType>; + let dataTableService: ReturnType>; + let service: AppTableRuntimeService; + + beforeEach(() => { + appRepository = mock(); + dataTableService = mock(); + appRepository.findByNamespace.mockResolvedValue(app()); + dataTableService.validateDataTableExists.mockResolvedValue(mock({ id: 'dt-1' })); + service = new AppTableRuntimeService(appRepository, dataTableService); + }); + + describe('resolution', () => { + it('answers 404 app_not_found for a namespace no app owns', async () => { + appRepository.findByNamespace.mockResolvedValue(null); + + expect(await failure(service.listRows('nobody', 'tasks', {}))).toMatchObject({ + status: 404, + code: 'app_not_found', + }); + }); + + it('answers 404 binding_not_found for a key bound to a workflow', async () => { + expect(await failure(service.listRows('board', 'submit', {}))).toMatchObject({ + status: 404, + code: 'binding_not_found', + }); + expect(dataTableService.validateDataTableExists).not.toHaveBeenCalled(); + }); + + it('answers 403 permission_denied for a write on a read-only binding', async () => { + appRepository.findByNamespace.mockResolvedValue(app(['read'])); + + expect(await failure(service.insertRows('board', 'tasks', { data: [{}] }))).toMatchObject({ + status: 403, + code: 'permission_denied', + }); + expect(dataTableService.validateDataTableExists).not.toHaveBeenCalled(); + expect(dataTableService.insertRows).not.toHaveBeenCalled(); + }); + + it('answers 404 table_not_found when the table left the project, before reading the input', async () => { + dataTableService.validateDataTableExists.mockRejectedValue( + new DataTableNotFoundError('dt-1'), + ); + + expect(await failure(service.insertRows('board', 'tasks', 'not a body'))).toMatchObject({ + status: 404, + code: 'table_not_found', + }); + expect(dataTableService.validateDataTableExists).toHaveBeenCalledWith('dt-1', 'proj-1'); + }); + }); + + describe('listRows', () => { + it('passes the parsed query to the data table service as the app project', async () => { + dataTableService.getManyRowsAndCount.mockResolvedValue({ count: 1, data: [row] }); + + const result = await service.listRows('board', 'tasks', { + take: '5', + skip: '10', + sortBy: 'title:desc', + filter: JSON.stringify({ filters: [{ columnName: 'title', value: 'a' }] }), + search: 'a', + }); + + expect(dataTableService.getManyRowsAndCount).toHaveBeenCalledWith('dt-1', 'proj-1', { + take: 5, + skip: 10, + sortBy: ['title', 'DESC'], + filter: { type: 'and', filters: [{ columnName: 'title', condition: 'eq', value: 'a' }] }, + search: 'a', + }); + expect(result).toEqual({ count: 1, data: [row] }); + }); + + it('answers 400 invalid_input with the issues for a malformed filter', async () => { + const error = await failure(service.listRows('board', 'tasks', { filter: '{not json' })); + + expect(error).toMatchObject({ status: 400, code: 'invalid_input' }); + expect(error.issues).toEqual([{ path: ['filter', 'filter'], code: 'custom' }]); + expect(dataTableService.getManyRowsAndCount).not.toHaveBeenCalled(); + }); + }); + + describe('insertRows', () => { + it('inserts the rows and returns them in full', async () => { + dataTableService.insertRows.mockResolvedValue([row]); + + const result = await service.insertRows('board', 'tasks', { data: [{ title: 'a' }] }); + + expect(dataTableService.insertRows).toHaveBeenCalledWith( + 'dt-1', + 'proj-1', + [{ title: 'a' }], + 'all', + ); + expect(result).toEqual({ data: [row] }); + }); + + it('answers 400 invalid_input for more than 100 rows', async () => { + const data = Array.from({ length: 101 }, () => ({ title: 'a' })); + + const error = await failure(service.insertRows('board', 'tasks', { data })); + + expect(error).toMatchObject({ status: 400, code: 'invalid_input' }); + expect(error.issues).toEqual([{ path: ['data'], code: 'too_big' }]); + expect(dataTableService.insertRows).not.toHaveBeenCalled(); + }); + + it('answers 400 invalid_input when the columns reject a value', async () => { + dataTableService.insertRows.mockRejectedValue( + new DataTableValidationError("unknown column 'nope'"), + ); + + const error = await failure(service.insertRows('board', 'tasks', { data: [{ nope: 1 }] })); + + expect(error).toMatchObject({ + status: 400, + code: 'invalid_input', + message: expect.stringContaining("unknown column 'nope'"), + }); + }); + + it('rethrows a failure that is not the caller’s', async () => { + dataTableService.insertRows.mockRejectedValue(new Error('db down')); + + await expect(service.insertRows('board', 'tasks', { data: [{}] })).rejects.toThrow('db down'); + }); + }); + + describe('updateRows', () => { + it('updates by filter and returns the changed rows', async () => { + dataTableService.updateRows.mockResolvedValue([row]); + + const result = await service.updateRows('board', 'tasks', { + filter: { filters: [{ columnName: 'id', value: 1 }] }, + data: { title: 'b' }, + }); + + expect(dataTableService.updateRows).toHaveBeenCalledWith( + 'dt-1', + 'proj-1', + { + filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, + data: { title: 'b' }, + }, + true, + false, + ); + expect(result).toEqual({ data: [row] }); + }); + + it('answers 400 invalid_input for an empty filter', async () => { + const error = await failure( + service.updateRows('board', 'tasks', { filter: { filters: [] }, data: { title: 'b' } }), + ); + + expect(error).toMatchObject({ status: 400, code: 'invalid_input' }); + expect(dataTableService.updateRows).not.toHaveBeenCalled(); + }); + }); + + describe('deleteRows', () => { + it('deletes by the filter in the query string and returns the removed rows', async () => { + dataTableService.deleteRows.mockResolvedValue([row]); + + const result = await service.deleteRows('board', 'tasks', { + filter: JSON.stringify({ filters: [{ columnName: 'id', value: 1 }] }), + }); + + expect(dataTableService.deleteRows).toHaveBeenCalledWith( + 'dt-1', + 'proj-1', + { filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] } }, + true, + false, + ); + expect(result).toEqual({ data: [row] }); + }); + + it('answers 400 invalid_input without a filter', async () => { + const error = await failure(service.deleteRows('board', 'tasks', {})); + + expect(error).toMatchObject({ status: 400, code: 'invalid_input' }); + expect(dataTableService.deleteRows).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/modules/apps/runtime/app-runtime.controller.ts b/packages/cli/src/modules/apps/runtime/app-runtime.controller.ts index 3b53262b504..5e074997ac5 100644 --- a/packages/cli/src/modules/apps/runtime/app-runtime.controller.ts +++ b/packages/cli/src/modules/apps/runtime/app-runtime.controller.ts @@ -9,6 +9,7 @@ import { UrlService } from '@/services/url.service'; import { AppRuntimeError } from './app-runtime.error'; import { AppRuntimeService } from './app-runtime.service'; +import { applyCors } from './cors'; const MAX_BODY_BYTES = 1024 * 1024; @@ -29,32 +30,8 @@ export class AppRuntimeController { private readonly urlService: UrlService, ) {} - /** - * The served page runs on an opaque origin (`sandbox` CSP), so `null` is the only - * `Origin` the app itself can send. The instance origin is allowed too, and a request - * without `Origin` (same-origin, curl) has nothing to check. Every other origin is - * another site: it gets a 403 and no CORS headers, so a browser never reads the answer. - */ private applyCors(req: Request, res: Response): boolean { - // The dev-only global cors middleware sets this before the controller runs. This - // public route never allows credentials, so drop it. - res.removeHeader('Access-Control-Allow-Credentials'); - - const origin = req.headers.origin; - const instanceOrigin = new URL(this.urlService.getInstanceBaseUrl()).origin; - if (origin !== undefined && origin !== 'null' && origin !== instanceOrigin) { - res.status(403).json({ - code: 'forbidden_origin', - message: 'The app runtime API answers only its own page.', - }); - return false; - } - - if (origin !== undefined) res.setHeader('Access-Control-Allow-Origin', origin); - res.vary('Origin'); - res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - return true; + return applyCors(req, res, this.urlService.getInstanceBaseUrl()); } // No rate limit: the browser preflights every call, so a limit here would halve the diff --git a/packages/cli/src/modules/apps/runtime/app-runtime.error.ts b/packages/cli/src/modules/apps/runtime/app-runtime.error.ts index bea4864cb93..ba622fc3842 100644 --- a/packages/cli/src/modules/apps/runtime/app-runtime.error.ts +++ b/packages/cli/src/modules/apps/runtime/app-runtime.error.ts @@ -7,6 +7,8 @@ export type AppRuntimeErrorCode = | 'workflow_not_published' | 'workflow_incompatible' | 'workflow_not_callable' + | 'table_not_found' + | 'permission_denied' | 'invalid_input' | 'forbidden_origin' | 'payload_too_large' diff --git a/packages/cli/src/modules/apps/runtime/app-runtime.service.ts b/packages/cli/src/modules/apps/runtime/app-runtime.service.ts index bb40c5692e6..5af6db6c867 100644 --- a/packages/cli/src/modules/apps/runtime/app-runtime.service.ts +++ b/packages/cli/src/modules/apps/runtime/app-runtime.service.ts @@ -1,3 +1,4 @@ +import type { AppBinding } from '@n8n/api-types'; import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import type { WorkflowEntity } from '@n8n/db'; @@ -99,7 +100,9 @@ export class AppRuntimeService { throw new AppRuntimeError(404, 'app_not_found', `No app is served at /apps/${namespace}.`); } - const binding = app.bindings.find((b) => b.key === key && b.kind === 'workflow'); + const binding = app.bindings.find( + (b): b is Extract => b.key === key && b.kind === 'workflow', + ); if (!binding) { throw new AppRuntimeError( 404, diff --git a/packages/cli/src/modules/apps/runtime/app-table-runtime.controller.ts b/packages/cli/src/modules/apps/runtime/app-table-runtime.controller.ts new file mode 100644 index 00000000000..e32873dd9cd --- /dev/null +++ b/packages/cli/src/modules/apps/runtime/app-table-runtime.controller.ts @@ -0,0 +1,98 @@ +import { GlobalConfig } from '@n8n/config'; +import { Time } from '@n8n/constants'; +import { createIpRateLimit, Delete, Get, Patch, Post, RootLevelController } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import type { Request, Response } from 'express'; +import { ErrorReporter } from 'n8n-core'; + +import { UrlService } from '@/services/url.service'; + +import { AppRuntimeError } from './app-runtime.error'; +import { AppTableRuntimeService } from './app-table-runtime.service'; +import { applyCors } from './cors'; + +const MAX_BODY_BYTES = 1024 * 1024; + +const rateLimit = createIpRateLimit( + Container.get(GlobalConfig).apps.runtimeRateLimit, + Time.minutes.toMilliseconds, +); + +type TableRequest = Request<{ namespace: string; key: string }>; + +/** + * Rows of the data tables bound to a served app: `/apps//api/tables//rows`. + * Same envelope as the workflow route: `skipAuth` because the caller is the served page, + * which has no session; the CORS check; the IP rate limit; one stable code per failure. + */ +@RootLevelController('/apps') +export class AppTableRuntimeController { + constructor( + private readonly appTableRuntimeService: AppTableRuntimeService, + private readonly errorReporter: ErrorReporter, + private readonly urlService: UrlService, + ) {} + + @Get('/:namespace/api/tables/:key/rows', { skipAuth: true, ipRateLimit: rateLimit }) + async listRows(req: TableRequest, res: Response) { + await this.answer(req, res, 200, async () => { + const { namespace, key } = req.params; + return await this.appTableRuntimeService.listRows(namespace, key, req.query); + }); + } + + @Post('/:namespace/api/tables/:key/rows', { skipAuth: true, ipRateLimit: rateLimit }) + async insertRows(req: TableRequest, res: Response) { + await this.answer(req, res, 201, async () => { + const { namespace, key } = req.params; + return await this.appTableRuntimeService.insertRows(namespace, key, req.body); + }); + } + + @Patch('/:namespace/api/tables/:key/rows', { skipAuth: true, ipRateLimit: rateLimit }) + async updateRows(req: TableRequest, res: Response) { + await this.answer(req, res, 200, async () => { + const { namespace, key } = req.params; + return await this.appTableRuntimeService.updateRows(namespace, key, req.body); + }); + } + + @Delete('/:namespace/api/tables/:key/rows', { skipAuth: true, ipRateLimit: rateLimit }) + async deleteRows(req: TableRequest, res: Response) { + await this.answer(req, res, 200, async () => { + const { namespace, key } = req.params; + return await this.appTableRuntimeService.deleteRows(namespace, key, req.query); + }); + } + + private async answer( + req: Request, + res: Response, + successStatus: number, + call: () => Promise, + ) { + if (!applyCors(req, res, this.urlService.getInstanceBaseUrl())) return; + + // `rawBody` is unset when the body parser skipped the request (multipart, no body). + if ((req.rawBody?.length ?? 0) > MAX_BODY_BYTES) { + res.status(413).json({ + code: 'payload_too_large', + message: `The request body must be at most ${MAX_BODY_BYTES} bytes.`, + }); + return; + } + + try { + res.status(successStatus).json(await call()); + } catch (error) { + if (error instanceof AppRuntimeError) { + const { status, code, message, issues } = error; + res.status(status).json({ code, message, ...(issues !== undefined ? { issues } : {}) }); + return; + } + // Anything else is ours, not the caller's; the browser gets one stable code for it. + this.errorReporter.error(error); + res.status(500).json({ code: 'execution_failed', message: 'The data table call failed.' }); + } + } +} diff --git a/packages/cli/src/modules/apps/runtime/app-table-runtime.service.ts b/packages/cli/src/modules/apps/runtime/app-table-runtime.service.ts new file mode 100644 index 00000000000..e366379baba --- /dev/null +++ b/packages/cli/src/modules/apps/runtime/app-table-runtime.service.ts @@ -0,0 +1,159 @@ +import { + AddDataTableRowsDto, + DeleteDataTableRowsDto, + ListDataTableContentQueryDto, + UpdateDataTableRowDto, + type AppBinding, + type DataTablePermission, +} from '@n8n/api-types'; +import { Service } from '@n8n/di'; +import { z } from 'zod'; + +import { DataTableService } from '@/modules/data-table/data-table.service'; +import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; +import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error'; + +import { AppRepository } from '../app.repository'; +import { AppRuntimeError } from './app-runtime.error'; + +/** One request inserts at most this many rows; the body cap alone would allow far more tiny rows. */ +const MAX_INSERT_ROWS = 100; + +const insertRowsSchema = z.object({ + data: AddDataTableRowsDto.schema.shape.data.max(MAX_INSERT_ROWS), +}); + +type DataTableBinding = Extract; + +/** The table the runtime may touch on behalf of `namespace`: its id and the app's project. */ +interface BoundTable { + dataTableId: string; + projectId: string; +} + +/** + * Rows of the data tables bound to an app, read and written as the app's project. Every + * refusal has its own code, and the payloads are the REST DTOs, so the SDK and the REST + * API agree on filters, sorting and paging. + */ +@Service() +export class AppTableRuntimeService { + constructor( + private readonly appRepository: AppRepository, + private readonly dataTableService: DataTableService, + ) {} + + async listRows(namespace: string, key: string, query: unknown) { + const { dataTableId, projectId } = await this.resolve(namespace, key, 'read'); + const dto = parseInput(ListDataTableContentQueryDto.safeParse(query)); + return await mapDataTableErrors( + async () => await this.dataTableService.getManyRowsAndCount(dataTableId, projectId, dto), + ); + } + + async insertRows(namespace: string, key: string, body: unknown) { + const { dataTableId, projectId } = await this.resolve(namespace, key, 'write'); + const { data } = parseInput(insertRowsSchema.safeParse(body)); + const rows = await mapDataTableErrors( + async () => await this.dataTableService.insertRows(dataTableId, projectId, data, 'all'), + ); + return { data: rows }; + } + + async updateRows(namespace: string, key: string, body: unknown) { + const { dataTableId, projectId } = await this.resolve(namespace, key, 'write'); + const { filter, data } = parseInput(UpdateDataTableRowDto.safeParse(body)); + const rows = await mapDataTableErrors( + async () => + await this.dataTableService.updateRows( + dataTableId, + projectId, + { filter, data }, + true, + false, + ), + ); + return { data: rows }; + } + + async deleteRows(namespace: string, key: string, query: unknown) { + const { dataTableId, projectId } = await this.resolve(namespace, key, 'write'); + const { filter } = parseInput(DeleteDataTableRowsDto.safeParse(query)); + const rows = await mapDataTableErrors( + async () => + await this.dataTableService.deleteRows(dataTableId, projectId, { filter }, true, false), + ); + return { data: rows }; + } + + /** + * Only `namespace` and `key` come from the caller: the table id is the stored binding's, + * and the table must still belong to the app's project, which is the identity the + * call runs as. + */ + private async resolve( + namespace: string, + key: string, + needed: DataTablePermission, + ): Promise { + const app = await this.appRepository.findByNamespace(namespace); + if (!app) { + throw new AppRuntimeError(404, 'app_not_found', `No app is served at /apps/${namespace}.`); + } + + const binding = app.bindings.find( + (b): b is DataTableBinding => b.key === key && b.kind === 'dataTable', + ); + if (!binding) { + throw new AppRuntimeError( + 404, + 'binding_not_found', + `App "${app.namespace}" has no data table bound as "${key}".`, + ); + } + + if (!binding.permissions.includes(needed)) { + throw new AppRuntimeError( + 403, + 'permission_denied', + `The binding "${key}" does not allow ${needed} access to its data table.`, + ); + } + + await mapDataTableErrors( + async () => + await this.dataTableService.validateDataTableExists(binding.dataTableId, app.projectId), + ); + + return { dataTableId: binding.dataTableId, projectId: app.projectId }; + } +} + +function parseInput(parsed: z.SafeParseReturnType): T { + if (parsed.success) return parsed.data; + throw new AppRuntimeError( + 400, + 'invalid_input', + 'The request does not match the data table API.', + parsed.error.issues.map(({ path, code }) => ({ path: path.map(String), code })), + ); +} + +/** The table can vanish between `resolve` and the row call; a filter or value the columns reject is the caller's. */ +async function mapDataTableErrors(call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + if (error instanceof DataTableValidationError) { + throw new AppRuntimeError(400, 'invalid_input', error.message); + } + if (error instanceof DataTableNotFoundError) { + throw new AppRuntimeError( + 404, + 'table_not_found', + 'The bound data table no longer exists in the app’s project.', + ); + } + throw error; + } +} diff --git a/packages/cli/src/modules/apps/runtime/cors.ts b/packages/cli/src/modules/apps/runtime/cors.ts new file mode 100644 index 00000000000..45216efd738 --- /dev/null +++ b/packages/cli/src/modules/apps/runtime/cors.ts @@ -0,0 +1,29 @@ +import type { Request, Response } from 'express'; + +/** + * The served page runs on an opaque origin (`sandbox` CSP), so `null` is the only + * `Origin` the app itself can send. The instance origin is allowed too, and a request + * without `Origin` (same-origin, curl) has nothing to check. Every other origin is + * another site: it gets a 403 and no CORS headers, so a browser never reads the answer. + */ +export function applyCors(req: Request, res: Response, instanceBaseUrl: string): boolean { + // The dev-only global cors middleware sets this before the controller runs. This + // public route never allows credentials, so drop it. + res.removeHeader('Access-Control-Allow-Credentials'); + + const origin = req.headers.origin; + const instanceOrigin = new URL(instanceBaseUrl).origin; + if (origin !== undefined && origin !== 'null' && origin !== instanceOrigin) { + res.status(403).json({ + code: 'forbidden_origin', + message: 'The app runtime API answers only its own page.', + }); + return false; + } + + if (origin !== undefined) res.setHeader('Access-Control-Allow-Origin', origin); + res.vary('Origin'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + return true; +} diff --git a/packages/cli/test/integration/apps/app-runtime.controller.integration.test.ts b/packages/cli/test/integration/apps/app-runtime.controller.integration.test.ts index eb7ce18236b..3f3bf91b7ed 100644 --- a/packages/cli/test/integration/apps/app-runtime.controller.integration.test.ts +++ b/packages/cli/test/integration/apps/app-runtime.controller.integration.test.ts @@ -368,7 +368,9 @@ describe('OPTIONS /apps/:namespace/api/*', () => { expect(response.headers['access-control-allow-origin']).toBe('null'); expect(response.headers.vary).toContain('Origin'); - expect(response.headers['access-control-allow-methods']).toBe('POST, OPTIONS'); + expect(response.headers['access-control-allow-methods']).toBe( + 'GET, POST, PATCH, DELETE, OPTIONS', + ); expect(response.headers['access-control-allow-headers']).toBe('Content-Type'); expect(response.headers['access-control-allow-credentials']).toBeUndefined(); }); diff --git a/packages/cli/test/integration/apps/app-table-runtime.controller.integration.test.ts b/packages/cli/test/integration/apps/app-table-runtime.controller.integration.test.ts new file mode 100644 index 00000000000..e5729ccb5a1 --- /dev/null +++ b/packages/cli/test/integration/apps/app-table-runtime.controller.integration.test.ts @@ -0,0 +1,370 @@ +import { getPersonalProject, testDb } from '@n8n/backend-test-utils'; +import type { Project, User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { AppRepository } from '@/modules/apps/app.repository'; +import { DataTableRepository } from '@/modules/data-table/data-table.repository'; +import { createDataTable } from '@test-integration/db/data-tables'; +import { createOwner } from '@test-integration/db/users'; +import type { SuperAgentTest } from '@test-integration/types'; +import * as utils from '@test-integration/utils'; + +let owner: User; +let ownerProject: Project; +/** The served page calls the runtime API without a session and without the `/rest` prefix. */ +let visitor: SuperAgentTest; +let authOwnerAgent: SuperAgentTest; + +const testServer = utils.setupTestServer({ + endpointGroups: ['apps'], + modules: ['apps', 'data-table'], +}); + +let appRepository: AppRepository; + +const ROWS = '/apps/board/api/tables/tasks/rows'; + +const createTasksTable = async () => + await createDataTable(ownerProject, { + name: 'Tasks', + columns: [ + { name: 'title', type: 'string' }, + { name: 'points', type: 'number' }, + { name: 'done', type: 'boolean' }, + ], + data: [ + { title: 'Write spec', points: 3, done: true }, + { title: 'Build app', points: 5, done: false }, + { title: 'Ship it', points: 1, done: false }, + ], + }); + +const createBoundApp = async ( + dataTableId: string, + permissions: Array<'read' | 'write'> = ['read', 'write'], +) => { + const app = await appRepository.createApp(ownerProject.id, 'Board', 'board'); + return await appRepository.updateBindings(app, [ + { key: 'tasks', kind: 'dataTable', dataTableId, permissions }, + ]); +}; + +const filterOf = (columnName: string, value: string | number | boolean) => + JSON.stringify({ filters: [{ columnName, value }] }); + +beforeAll(async () => { + appRepository = Container.get(AppRepository); + owner = await createOwner(); + ownerProject = await getPersonalProject(owner); + visitor = testServer.restlessAgent; + authOwnerAgent = testServer.authAgentFor(owner); +}); + +beforeEach(async () => { + await testDb.truncate(['App']); + await Container.get(DataTableRepository).deleteDataTableAll(); +}); + +describe('GET /apps/:namespace/api/tables/:key/rows', () => { + test('lists the rows with filter, sort and take', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor + .get(ROWS) + .query({ filter: filterOf('done', false), sortBy: 'points:desc', take: '1' }) + .set('Origin', 'null') + .expect(200); + + expect(response.body.count).toBe(2); + expect(response.body.data).toEqual([ + expect.objectContaining({ + id: expect.any(Number), + title: 'Build app', + points: 5, + done: false, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + ]); + expect(response.headers['access-control-allow-origin']).toBe('null'); + expect(response.headers['access-control-allow-credentials']).toBeUndefined(); + }); + + test('answers 400 invalid_input for a filter on an unknown column', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor + .get(ROWS) + .query({ filter: filterOf('nope', 1) }) + .expect(400); + + expect(response.body).toMatchObject({ code: 'invalid_input' }); + }); + + test('answers 404 binding_not_found for a key the app has not bound', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor.get('/apps/board/api/tables/nope/rows').expect(404); + + expect(response.body).toMatchObject({ code: 'binding_not_found' }); + }); + + test('answers 404 app_not_found for a namespace no app owns', async () => { + const response = await visitor.get('/apps/nobody/api/tables/tasks/rows').expect(404); + + expect(response.body).toMatchObject({ code: 'app_not_found' }); + }); + + test('answers 404 table_not_found after the bound table is deleted', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + await Container.get(DataTableRepository).deleteDataTable(table.id); + + const response = await visitor.get(ROWS).expect(404); + + expect(response.body).toMatchObject({ code: 'table_not_found' }); + }); +}); + +describe('POST /apps/:namespace/api/tables/:key/rows', () => { + test('inserts the rows and answers 201 with the stored rows', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor + .post(ROWS) + .set('Origin', 'null') + .send({ + data: [{ title: 'A', points: 1 }, { title: 'B', points: 2, done: true }, { title: 'C' }], + }) + .expect(201); + + expect(response.body.data).toEqual([ + expect.objectContaining({ id: expect.any(Number), title: 'A', points: 1, done: null }), + expect.objectContaining({ id: expect.any(Number), title: 'B', points: 2, done: true }), + expect.objectContaining({ id: expect.any(Number), title: 'C', points: null, done: null }), + ]); + const listed = await visitor.get(ROWS).query({ take: '250' }).expect(200); + expect(listed.body.count).toBe(6); + }); + + test('answers 400 invalid_input for more than 100 rows', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor + .post(ROWS) + .send({ data: Array.from({ length: 101 }, (_, i) => ({ title: `Row ${i}` })) }) + .expect(400); + + expect(response.body).toMatchObject({ code: 'invalid_input' }); + expect(response.body.issues).toEqual([{ path: ['data'], code: 'too_big' }]); + }); + + test('answers 403 permission_denied for a read-only binding', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id, ['read']); + + const response = await visitor + .post(ROWS) + .send({ data: [{ title: 'A' }] }) + .expect(403); + + expect(response.body).toMatchObject({ code: 'permission_denied' }); + const listed = await visitor.get(ROWS).expect(200); + expect(listed.body.count).toBe(3); + }); + + test('answers 403 forbidden_origin to another site without inserting', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor + .post(ROWS) + .set('Origin', 'https://evil.example') + .send({ data: [{ title: 'A' }] }) + .expect(403); + + expect(response.body).toMatchObject({ code: 'forbidden_origin' }); + expect(response.headers['access-control-allow-origin']).toBeUndefined(); + const listed = await visitor.get(ROWS).expect(200); + expect(listed.body.count).toBe(3); + }); +}); + +describe('PATCH /apps/:namespace/api/tables/:key/rows', () => { + test('updates the rows matching the filter and answers with them', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + const listed = await visitor.get(ROWS).query({ filter: filterOf('title', 'Ship it') }); + const [row] = listed.body.data; + + const response = await visitor + .patch(ROWS) + .send({ filter: { filters: [{ columnName: 'id', value: row.id }] }, data: { done: true } }) + .expect(200); + + expect(response.body.data).toEqual([ + expect.objectContaining({ id: row.id, title: 'Ship it', done: true }), + ]); + }); + + test('answers 400 invalid_input for an empty filter', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor + .patch(ROWS) + .send({ filter: { filters: [] }, data: { done: true } }) + .expect(400); + + expect(response.body).toMatchObject({ code: 'invalid_input' }); + }); + + test('answers 403 permission_denied for a read-only binding', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id, ['read']); + + const response = await visitor + .patch(ROWS) + .send({ filter: { filters: [{ columnName: 'done', value: false }] }, data: { done: true } }) + .expect(403); + + expect(response.body).toMatchObject({ code: 'permission_denied' }); + }); + + test('answers 403 forbidden_origin to another site', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor + .patch(ROWS) + .set('Origin', 'https://evil.example') + .send({ filter: { filters: [{ columnName: 'done', value: false }] }, data: { done: true } }) + .expect(403); + + expect(response.body).toMatchObject({ code: 'forbidden_origin' }); + }); +}); + +describe('DELETE /apps/:namespace/api/tables/:key/rows', () => { + test('deletes the rows matching the filter and answers with them', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor + .delete(ROWS) + .query({ filter: filterOf('done', false) }) + .expect(200); + + expect(response.body.data).toEqual([ + expect.objectContaining({ title: 'Build app' }), + expect.objectContaining({ title: 'Ship it' }), + ]); + const listed = await visitor.get(ROWS).expect(200); + expect(listed.body.count).toBe(1); + }); + + test('answers 400 invalid_input without a filter', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor.delete(ROWS).expect(400); + + expect(response.body).toMatchObject({ code: 'invalid_input' }); + }); + + test('answers 403 permission_denied for a read-only binding', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id, ['read']); + + const response = await visitor + .delete(ROWS) + .query({ filter: filterOf('done', false) }) + .expect(403); + + expect(response.body).toMatchObject({ code: 'permission_denied' }); + }); + + test('answers 403 forbidden_origin to another site', async () => { + const table = await createTasksTable(); + await createBoundApp(table.id); + + const response = await visitor + .delete(ROWS) + .set('Origin', 'https://evil.example') + .query({ filter: filterOf('done', false) }) + .expect(403); + + expect(response.body).toMatchObject({ code: 'forbidden_origin' }); + }); +}); + +describe('OPTIONS /apps/:namespace/api/tables/:key/rows', () => { + test('lists the four methods for the opaque-origin page', async () => { + const response = await visitor + .options(ROWS) + .set('Origin', 'null') + .set('Access-Control-Request-Method', 'PATCH') + .expect(204); + + expect(response.headers['access-control-allow-origin']).toBe('null'); + expect(response.headers['access-control-allow-methods']).toBe( + 'GET, POST, PATCH, DELETE, OPTIONS', + ); + }); +}); + +describe('GET /projects/:projectId/apps/:appId/bindings', () => { + test('describes the data table binding, and marks it missing once the table is deleted', async () => { + const table = await createTasksTable(); + const app = await createBoundApp(table.id, ['read']); + const url = `/projects/${ownerProject.id}/apps/${app.id}/bindings`; + + const described = await authOwnerAgent.get(url).expect(200); + + expect(described.body.data).toEqual({ + bindings: [ + { + key: 'tasks', + kind: 'dataTable', + dataTableId: table.id, + name: 'Tasks', + permissions: ['read'], + columns: [ + { name: 'title', type: 'string' }, + { name: 'points', type: 'number' }, + { name: 'done', type: 'boolean' }, + ], + row: { + type: 'object', + properties: { + id: { type: 'number' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + title: { type: ['string', 'null'] }, + points: { type: ['number', 'null'] }, + done: { type: ['boolean', 'null'] }, + }, + required: ['id', 'createdAt', 'updatedAt', 'title', 'points', 'done'], + additionalProperties: false, + }, + }, + ], + warnings: [], + }); + + await Container.get(DataTableRepository).deleteDataTable(table.id); + const missing = await authOwnerAgent.get(url).expect(200); + + expect(missing.body.data).toEqual({ + bindings: [{ key: 'tasks', kind: 'dataTable', name: 'tasks', missing: true }], + warnings: [ + `Binding 'tasks': data table '${table.id}' no longer exists in the app's project.`, + ], + }); + }); +}); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index b1f036f894f..bde3b789d77 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5220,10 +5220,15 @@ "apps.builder.empty.title": "Nothing to preview yet", "apps.builder.empty.description": "Ask the AI Assistant to build this app. Each build shows here as soon as it's done.", "apps.connections": "Connect", - "apps.connections.empty": "No workflows connected yet.", + "apps.connections.empty": "No workflows or data tables connected yet.", + "apps.connections.access.read": "Read", + "apps.connections.access.write": "Write", + "apps.connections.access.readWrite": "Read & write", "apps.connections.delete.confirm.title": "Disconnect workflow", "apps.connections.delete.confirm.message": "Are you sure you want to disconnect the \"{name}\" workflow? The app will no longer be able to run this workflow.", - "apps.connections.delete.error": "Error disconnecting workflow", + "apps.connections.delete.confirm.dataTable.title": "Disconnect data table", + "apps.connections.delete.confirm.dataTable.message": "Are you sure you want to disconnect the \"{name}\" table? The app will no longer be able to read or change its rows.", + "apps.connections.delete.error": "Error disconnecting", "apps.builder.live.starting": "Starting live preview…", "apps.builder.live.noSource": "Live preview isn't available in this chat yet.", "apps.builder.live.unsupported": "Live preview isn't supported on this instance.", @@ -5607,6 +5612,7 @@ "settings.n8nAgent.permissions.restoreWorkflowVersion": "Restore workflow versions", "settings.n8nAgent.permissions.executeMcpTool": "Execute MCP tools", "settings.n8nAgent.permissions.bindAppWorkflow": "Connect workflows to apps", + "settings.n8nAgent.permissions.bindAppDataTable": "Connect data tables to apps", "dataTable.search.dateSearchInfo": "Date searches use UTC format, while the table displays dates in your local timezone", "dataTable.cell.oversized": "Value too large to display", "dataTable.cell.oversized.tooltip": "The value can be modified using the data table node", @@ -7153,6 +7159,10 @@ "instanceAi.webSearch.prompt": "AI Assistant wants to search the web", "instanceAi.webSearch.allowThread": "Always allow", "instanceAi.appBinding.prompt": "Allow AI Assistant to connect a workflow to the app?", + "instanceAi.appBinding.dataTable.prompt": "Allow AI Assistant to connect a data table to the app?", + "instanceAi.appBinding.dataTable.access.read": "Anyone who can open the app can read this table.", + "instanceAi.appBinding.dataTable.access.write": "Anyone who can open the app can add and change rows in this table, but not read them.", + "instanceAi.appBinding.dataTable.access.readWrite": "Anyone who can open the app can read and change rows in this table.", "instanceAi.credential.selected": "Credential selected", "instanceAi.credential.required": "This action requires a credential", "instanceAi.credential.allSelected": "All credentials configured", @@ -7449,9 +7459,9 @@ "instanceAi.tools.apps.build": "Building app", "instanceAi.tools.apps.restore": "Restoring app files", "instanceAi.tools.apps.add-component": "Adding component", - "instanceAi.tools.apps.bind": "Connecting workflow to app", - "instanceAi.tools.apps.bind.imperative": "connect workflow to app", - "instanceAi.tools.apps.unbind": "Disconnecting workflow", + "instanceAi.tools.apps.bind": "Connecting workflow or data table to app", + "instanceAi.tools.apps.bind.imperative": "connect a workflow or data table to the app", + "instanceAi.tools.apps.unbind": "Disconnecting from app", "instanceAi.tools.apps.bindings": "Listing app connections", "instanceAi.tools.n8n-docs": "Reading n8n docs", "instanceAi.tools.n8n-docs.lookup": "Reading n8n docs", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/AppBindingApproval.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/AppBindingApproval.test.ts index 6cd9653a5ec..2946dfafc0a 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/AppBindingApproval.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/AppBindingApproval.test.ts @@ -6,6 +6,7 @@ import AppBindingApproval from '@/features/ai/instanceAi/components/AppBindingAp import type { ApprovalOption } from '@/features/ai/instanceAi/components/ApprovalOptionList.vue'; const APP_BINDING: AppBindingMeta = { + kind: 'workflow', appId: 'app-1', appName: 'Runner', appNamespace: 'runner', @@ -14,6 +15,18 @@ const APP_BINDING: AppBindingMeta = { key: 'submit', }; +const DATA_TABLE_BINDING: AppBindingMeta = { + kind: 'dataTable', + appId: 'app-1', + appName: 'Board', + appNamespace: 'board', + dataTableId: 'dt-1', + dataTableName: 'Tasks', + key: 'tasks', + permissions: ['read', 'write'], + projectId: 'proj-1', +}; + const OPTIONS: ApprovalOption[] = [ { key: 'always-allow', icon: 'check-check', label: 'Always allow', testId: 'opt-always-allow' }, { key: 'allow-once', icon: 'check', label: 'Allow once', testId: 'opt-allow-once' }, @@ -47,4 +60,52 @@ describe('AppBindingApproval', () => { expect(emitted('select')).toEqual([['allow-once'], ['deny']]); }); + + it('shows the app, the data table linked in a new tab, and the read-write access line', () => { + const { getByTestId, getByText, queryByTestId, queryByText } = renderComponent({ + props: { appBinding: DATA_TABLE_BINDING }, + }); + + expect(getByText('Allow AI Assistant to connect a data table to the app?')).toBeInTheDocument(); + expect(getByTestId('instance-ai-app-binding-app')).toHaveTextContent('Board'); + const link = getByTestId('instance-ai-app-binding-data-table'); + expect(link).toHaveTextContent('Tasks'); + expect(link).toHaveAttribute('href', '/projects/proj-1/datatables/dt-1'); + expect(link).toHaveAttribute('target', '_blank'); + expect(getByTestId('instance-ai-app-binding-access')).toHaveTextContent( + 'Anyone who can open the app can read and change rows in this table.', + ); + expect(queryByTestId('instance-ai-app-binding-workflow')).toBeNull(); + expect(queryByText(/tasks/)).toBeNull(); + }); + + it('shows the read-only access line for a read-only data table binding', () => { + const { getByTestId } = renderComponent({ + props: { appBinding: { ...DATA_TABLE_BINDING, permissions: ['read'] } }, + }); + + expect(getByTestId('instance-ai-app-binding-access')).toHaveTextContent( + 'Anyone who can open the app can read this table.', + ); + }); + + it('shows the write-only access line for a data table binding without read', () => { + const { getByTestId } = renderComponent({ + props: { appBinding: { ...DATA_TABLE_BINDING, permissions: ['write'] } }, + }); + + expect(getByTestId('instance-ai-app-binding-access')).toHaveTextContent( + 'Anyone who can open the app can add and change rows in this table, but not read them.', + ); + }); + + it('emits the selected option key for a data table binding', async () => { + const { getByTestId, emitted } = renderComponent({ + props: { appBinding: DATA_TABLE_BINDING }, + }); + + await userEvent.click(getByTestId('opt-always-allow')); + + expect(emitted('select')).toEqual([['always-allow']]); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts index 6de3944b838..d53ebe49d4e 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts @@ -572,6 +572,7 @@ describe('InstanceAiConfirmationPanel telemetry', () => { severity: 'warning', message: 'Connect workflow "Echo" (wf-1) to app "Runner" as "submit"', appBinding: { + kind: 'workflow', appId: 'app-1', appName: 'Runner', appNamespace: 'runner', diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts index 50ee42ed038..daec222a4a6 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts @@ -656,6 +656,17 @@ describe('SettingsInstanceAiView', () => { } }); + it('lists the workflow and data table permissions in the apps group', async () => { + const { getByTestId, getByLabelText } = renderComponent(); + + await fireEvent.click(getByLabelText('Toggle settings.n8nAgent.permissions.group.apps')); + + await waitFor(() => + expect(getByTestId('n8n-agent-permission-bindAppWorkflow')).toBeVisible(), + ); + expect(getByTestId('n8n-agent-permission-bindAppDataTable')).toBeVisible(); + }); + it('summarises non-default permissions as exceptions', () => { store.$patch({ settings: { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AppBindingApproval.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AppBindingApproval.vue index 897eb8567f9..71989c865a5 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AppBindingApproval.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/AppBindingApproval.vue @@ -14,7 +14,13 @@ const i18n = useI18n();
- {{ i18n.baseText('instanceAi.appBinding.prompt') }} + {{ + i18n.baseText( + appBinding.kind === 'dataTable' + ? 'instanceAi.appBinding.dataTable.prompt' + : 'instanceAi.appBinding.prompt', + ) + }}
@@ -22,18 +28,50 @@ const i18n = useI18n(); {{ appBinding.appName }} - - - {{ appBinding.workflowName }} - + +
+ + {{ + i18n.baseText( + appBinding.permissions.includes('read') + ? appBinding.permissions.includes('write') + ? 'instanceAi.appBinding.dataTable.access.readWrite' + : 'instanceAi.appBinding.dataTable.access.read' + : 'instanceAi.appBinding.dataTable.access.write', + ) + }} +
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue index 3bcf3b6ffdd..7340becfd38 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue @@ -215,7 +215,7 @@ const PERMISSION_GROUPS: PermissionGroup[] = [ { id: 'apps', labelKey: 'settings.n8nAgent.permissions.group.apps', - keys: ['bindAppWorkflow'], + keys: ['bindAppWorkflow', 'bindAppDataTable'], }, { id: 'system', diff --git a/packages/frontend/editor-ui/src/features/apps/AppDetailsView.test.ts b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.test.ts index e32fd823689..60aab3f2632 100644 --- a/packages/frontend/editor-ui/src/features/apps/AppDetailsView.test.ts +++ b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.test.ts @@ -762,6 +762,114 @@ describe('AppDetailsView', () => { expect(getByTestId('app-connections-warning')).not.toHaveTextContent("Binding 'notify'"); }); + const tasksBinding: DescribedBinding = { + key: 'tasks', + kind: 'dataTable', + dataTableId: 'dt-1', + name: 'Tasks', + permissions: ['read', 'write'], + columns: [{ name: 'title', type: 'string' }], + row: { type: 'object' }, + }; + + it('lists a connected data table with a link and its access level', async () => { + appsStore.bindings = [tasksBinding, { ...tasksBinding, key: 'log', permissions: ['read'] }]; + const { getByTestId, getAllByTestId, queryByTestId } = await renderApp(makeApp()); + + await userEvent.click(getByTestId('app-builder-mode-build')); + + const rows = getAllByTestId('app-connection'); + expect(rows).toHaveLength(2); + const links = getAllByTestId('app-connection-data-table'); + expect(links[0]).toHaveAttribute('href', '/projects/proj-1/datatables/dt-1'); + expect(links[0]).toHaveAttribute('target', '_blank'); + expect(links[0]).toHaveTextContent('Tasks'); + expect(rows[0].querySelector('[data-icon="table"]')).not.toBeNull(); + const access = getAllByTestId('app-connection-access'); + expect(access[0]).toHaveTextContent('Read & write'); + expect(access[1]).toHaveTextContent('Read'); + expect(access[1]).not.toHaveTextContent('write'); + expect(queryByTestId('app-connection-workflow')).toBeNull(); + expect(queryByTestId('app-connection-warning')).toBeNull(); + }); + + it('shows write-only access for a data table the app cannot read', async () => { + appsStore.bindings = [{ ...tasksBinding, permissions: ['write'] }]; + const { getByTestId } = await renderApp(makeApp()); + + await userEvent.click(getByTestId('app-builder-mode-build')); + + expect(getByTestId('app-connection-access')).toHaveTextContent('Write'); + expect(getByTestId('app-connection-access')).not.toHaveTextContent('Read'); + }); + + it('lists a missing connection without a link and disconnects it with its kind copy', async () => { + appsStore.bindings = [ + { key: 'tasks', kind: 'dataTable', name: 'tasks', missing: true }, + { key: 'submit', kind: 'workflow', name: 'submit', missing: true }, + ]; + appsStore.bindingWarnings = [ + "Binding 'tasks': data table 'dt-1' no longer exists in the app's project.", + "Binding 'submit': workflow 'wf-1' no longer exists in the app's project.", + ]; + confirm.mockResolvedValue(MODAL_CONFIRM); + const { getByTestId, getAllByTestId, queryByTestId } = await renderApp(makeApp()); + + await userEvent.click(getByTestId('app-builder-mode-build')); + + const rows = getAllByTestId('app-connection'); + expect(rows).toHaveLength(2); + expect(rows[0].querySelector('a')).toBeNull(); + expect(rows[1].querySelector('a')).toBeNull(); + expect(getAllByTestId('app-connection-missing')[0]).toHaveTextContent('tasks'); + expect(getAllByTestId('app-connection-missing')[1]).toHaveTextContent('submit'); + expect(rows[0].querySelector('[data-icon="table"]')).not.toBeNull(); + expect(rows[1].querySelector('[data-icon="workflow"]')).not.toBeNull(); + expect(queryByTestId('app-connection-data-table')).toBeNull(); + expect(queryByTestId('app-connection-workflow')).toBeNull(); + expect(queryByTestId('app-connection-access')).toBeNull(); + expect(queryByTestId('app-connections-warning')).toBeNull(); + + const warned = rows[0].querySelector('[data-test-id="app-connection-warning"]'); + expect(warned).not.toBeNull(); + await userEvent.hover(warned!); + expect(await screen.findByText(/data table 'dt-1' no longer exists/)).toBeInTheDocument(); + + await userEvent.click(getAllByTestId('app-connection-delete')[0]); + expect(confirm).toHaveBeenLastCalledWith( + expect.stringContaining('disconnect the "tasks" table'), + 'Disconnect data table', + expect.objectContaining({ confirmButtonText: 'Disconnect' }), + ); + expect(appsStore.deleteBinding).toHaveBeenLastCalledWith('proj-1', 'app-1', 'tasks'); + + await userEvent.click(getAllByTestId('app-connection-delete')[1]); + expect(confirm).toHaveBeenLastCalledWith( + expect.stringContaining('disconnect the "submit" workflow'), + 'Disconnect workflow', + expect.objectContaining({ confirmButtonText: 'Disconnect' }), + ); + expect(appsStore.deleteBinding).toHaveBeenLastCalledWith('proj-1', 'app-1', 'submit'); + }); + + it('disconnects a data table after confirmation with the table copy', async () => { + appsStore.bindings = [tasksBinding]; + confirm.mockResolvedValue(MODAL_CONFIRM); + const { getByTestId } = await renderApp(makeApp()); + + await userEvent.click(getByTestId('app-builder-mode-build')); + await userEvent.click(getByTestId('app-connection-delete')); + + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining( + 'disconnect the "Tasks" table? The app will no longer be able to read or change its rows.', + ), + 'Disconnect data table', + expect.objectContaining({ confirmButtonText: 'Disconnect' }), + ); + expect(appsStore.deleteBinding).toHaveBeenCalledWith('proj-1', 'app-1', 'tasks'); + }); + it('deletes a connection after confirmation', async () => { appsStore.bindings = bindings; confirm.mockResolvedValue(MODAL_CONFIRM); @@ -789,12 +897,14 @@ describe('AppDetailsView', () => { expect(appsStore.deleteBinding).not.toHaveBeenCalled(); }); - it('shows the empty state when no workflow is connected', async () => { + it('shows the empty state when nothing is connected', async () => { const { getByTestId, queryByTestId } = await renderApp(makeApp()); await userEvent.click(getByTestId('app-builder-mode-build')); - expect(getByTestId('app-connections-empty')).toHaveTextContent('No workflows connected yet'); + expect(getByTestId('app-connections-empty')).toHaveTextContent( + 'No workflows or data tables connected yet.', + ); expect(queryByTestId('app-connection')).not.toBeInTheDocument(); }); }); diff --git a/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue index 3e67baba25e..7818bbd42a1 100644 --- a/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue +++ b/packages/frontend/editor-ui/src/features/apps/AppDetailsView.vue @@ -223,7 +223,7 @@ const bindingWarnings = (key: string) => { .map((warning) => warning.slice(prefix.length).trim()); }; -// Warnings about bindings the API left out (workflow gone or incompatible) have no row. +// Warnings about bindings the API left out have no row. const unlistedBindingWarnings = computed(() => appsStore.bindingWarnings.filter( (warning) => @@ -823,8 +823,29 @@ watch( :class="$style.connectionRow" data-test-id="app-connection" > - + + + {{ binding.name }} + + {{ binding.name }} + + {{ binding.name }} + + {{ + i18n.baseText( + binding.permissions.includes('read') + ? binding.permissions.includes('write') + ? 'apps.connections.access.readWrite' + : 'apps.connections.access.read' + : 'apps.connections.access.write', + ) + }} + => { + const copy = + binding.kind === 'dataTable' + ? 'apps.connections.delete.confirm.dataTable' + : 'apps.connections.delete.confirm'; const response = await message.confirm( - i18n.baseText('apps.connections.delete.confirm.message', { + i18n.baseText(`${copy}.message`, { interpolate: { name: escapeHtml(binding.name) }, }), - i18n.baseText('apps.connections.delete.confirm.title'), + i18n.baseText(`${copy}.title`), { confirmButtonText: i18n.baseText('generic.disconnect'), cancelButtonText: i18n.baseText('generic.cancel'),