feat(core): Let built apps read and write bound data tables (#38332)

This commit is contained in:
Elias Meire 2026-09-11 11:15:41 +02:00 committed by GitHub
parent 6bfe813156
commit 2c8aec6fd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 2687 additions and 293 deletions

View File

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

View File

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

View File

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

View File

@ -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/<ns>/api/workflows/<key> 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<typeof appBindingSchema>;
export type DataTablePermission = z.infer<typeof dataTablePermissionSchema>;
/** 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<typeof dataTableColumnTypeSchema> }>;
/** 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;

View File

@ -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<typeof webSearchMetaSchema>;
/** 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<typeof appBindingMetaSchema>;
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<typeof instanceAiPermissionsSchema>;
@ -1917,6 +1934,7 @@ export const DEFAULT_INSTANCE_AI_PERMISSIONS: InstanceAiPermissions = {
restoreWorkflowVersion: 'require_approval',
executeMcpTool: 'require_approval',
bindAppWorkflow: 'require_approval',
bindAppDataTable: 'require_approval',
};
/**

View File

@ -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/<namespace>/api/workflows/<key>`, 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/<namespace>/api/workflows/<key>` and
`/apps/<namespace>/api/tables/<key>/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.<key>` 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.

View File

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

View File

@ -1,8 +1,9 @@
/**
* Augmented by the generated `src/n8n-bindings.d.ts` of an app with
* `workflows: { <key>: { 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: { <key>: { input; output } }` and `tables: { <key>: { 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<string, { input: unknown; output: unknown }>;
type Workflows = Bindings extends { workflows: infer W extends AnyWorkflows } ? W : AnyWorkflows;
type WorkflowKey = Extract<keyof Workflows, string>;
type AnyTables = Record<string, { row: Record<string, unknown> }>;
type Tables = Bindings extends { tables: infer T extends AnyTables } ? T : AnyTables;
type TableKey = Extract<keyof Tables, string>;
type Row<K extends TableKey> = Tables[K]['row'];
/** What the app sends: the server fills `id`, `createdAt` and `updatedAt`. */
type NewRow<K extends TableKey> = Partial<Omit<Row<K>, 'id' | 'createdAt' | 'updatedAt'>>;
type ColumnName<K extends TableKey> = Extract<keyof Row<K>, 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<T> {
principal: Principal;
}
/** Same shape as the n8n REST data table filter; `type` defaults to `and`, `condition` to `eq`. */
export interface TableFilter<K extends TableKey = TableKey> {
type?: 'and' | 'or';
filters: Array<{
columnName: ColumnName<K>;
condition?: 'eq' | 'neq' | 'like' | 'ilike' | 'gt' | 'gte' | 'lt' | 'lte';
value: string | number | boolean | null;
}>;
}
export interface TableListOptions<K extends TableKey> {
filter?: TableFilter<K>;
/** Case-insensitive substring match over every column as text. */
search?: string;
sortBy?: `${ColumnName<K>}:${'asc' | 'desc'}`;
/** Default 10, at most 250. */
take?: number;
skip?: number;
}
export interface TableClient<K extends TableKey> {
list(
opts?: TableListOptions<K> & { signal?: AbortSignal },
): Promise<{ count: number; data: Array<Row<K>> }>;
insert(rows: Array<NewRow<K>>, opts?: { signal?: AbortSignal }): Promise<{ data: Array<Row<K>> }>;
update(
filter: TableFilter<K>,
data: NewRow<K>,
opts?: { signal?: AbortSignal },
): Promise<{ data: Array<Row<K>> }>;
delete(filter: TableFilter<K>, opts?: { signal?: AbortSignal }): Promise<{ data: Array<Row<K>> }>;
}
export class N8nAppError extends Error {
constructor(
readonly status: number,
@ -44,6 +86,7 @@ export interface N8nAppClient {
opts?: { signal?: AbortSignal },
): Promise<RunResult<Workflows[K]['output']>>;
};
tables: { [K in TableKey]: TableClient<K> };
}
function isRecord(value: unknown): value is Record<string, unknown> {
@ -56,6 +99,14 @@ function isRunResult<T>(value: unknown): value is RunResult<T> {
);
}
function isRowList<R>(value: unknown): value is { data: R[] } {
return isRecord(value) && Array.isArray(value.data);
}
function isRowPage<R>(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<unknown> {
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<Workflows[typeof key]['output']>(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<K extends TableKey>(key: K, baseUrl: string | undefined): TableClient<K> {
const rows = `/tables/${encodeURIComponent(key)}/rows`;
const query = (params: Record<string, string | undefined>) => {
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<Row<K>>(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<Row<K>>(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<Row<K>>(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<Row<K>>(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<Workflows[typeof key]['output']>(body)) throw invalidResponse(status);
return body;
},
},
// Keyed by binding key, so `n8n.tables.tasks` needs no registration step.
tables: new Proxy<N8nAppClient['tables']>(
{},
{
get: (_target, key) =>
typeof key === 'string' ? tableClient(key, opts.baseUrl) : undefined,
},
),
};
}
export const n8n: N8nAppClient = createClient();

View File

@ -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.<key>` 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.<key>` (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.<key>.row`: `{ id: number; createdAt: string; updatedAt: string;
<column>: <type> | 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/<namespace>/api` by hand; never put a workflow id in the app.
`/apps/<namespace>/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/<namespace>/
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
```

View File

@ -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/<namespace>/api/workflows/<key>`. 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/<namespace>/api/workflows/<key>` and
`/apps/<namespace>/api/tables/<key>/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.<key>` 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<string, any>; output: Array<Record<string, any>> };
};
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<T> {
- `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`
(`'<column>:asc' | '<column>: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.

View File

@ -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/<namespace>/`. 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('<key>', 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('<key>', 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.<key>.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: "<name>")` 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.

View File

@ -3,5 +3,6 @@ import '@n8n/app-sdk';
declare module '@n8n/app-sdk' {
interface Bindings {
workflows: {};
tables: {};
}
}

View File

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

View File

@ -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<string, any> | null; "raw"?: unknown }; output: Array<{ "reply": string }> };',
'\t\t\t"notify": { input: Record<string, any>; output: Array<Record<string, any>> };',
'\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<Record<string, any>>');
});
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 }),
);
});
});

View File

@ -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<typeof publishSchema>;
type RestoreInput = z.infer<typeof restoreSchema>;
type AddComponentInput = z.infer<typeof addComponentSchema>;
type BindInput = z.infer<typeof bindSchema>;
type FlatBinding = BindInput['bindings'][number];
type WorkflowBinding = Extract<AppBinding, { kind: 'workflow' }>;
type DataTableBinding = Extract<AppBinding, { kind: 'dataTable' }>;
type BindPermission = 'bindAppWorkflow' | 'bindAppDataTable';
type UnbindInput = z.infer<typeof unbindSchema>;
type BindingsInput = z.infer<typeof bindingsSchema>;
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.<key>`. 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<AppActionDenied | null> {
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<AppSummary, 'createdAt'>;
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<BindDescription> {
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<BindDescription> {
// 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/<namespace>/ 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.<key>.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();

View File

@ -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<WorkflowToolWorkflowLoader>(),
mock<ExecutionPersistence>(),
mock<DataTableService>(),
);
});
@ -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> = {}): 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<typeof mock<WorkflowFinderService>>;
let workflowLoader: ReturnType<typeof mock<WorkflowToolWorkflowLoader>>;
let executionPersistence: ReturnType<typeof mock<ExecutionPersistence>>;
let dataTableService: ReturnType<typeof mock<DataTableService>>;
let service: AppsService;
let app: App;
@ -164,6 +182,7 @@ describe('AppsService bindings', () => {
workflowFinderService = mock<WorkflowFinderService>();
workflowLoader = mock<WorkflowToolWorkflowLoader>();
executionPersistence = mock<ExecutionPersistence>();
dataTableService = mock<DataTableService>();
executionPersistence.findMultipleExecutions.mockResolvedValue([
successfulExecution([{ reply: 'hi' }]),
]);
@ -175,6 +194,7 @@ describe('AppsService bindings', () => {
mock<GlobalConfig>({ 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 () => {

View File

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

View File

@ -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<AppBinding, { kind: 'workflow' }>;
type DataTableBinding = Extract<AppBinding, { kind: 'dataTable' }>;
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<string, JSONSchema7> = {
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<App, 'projectId' | 'bindings'>,
): 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<Described> {
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<Described> {
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<Pick<DescribedBinding, 'output' | 'outputSource'>> {
): Promise<Pick<DescribedWorkflowBinding, 'output' | 'outputSource'>> {
const [execution] = await this.executionPersistence.findMultipleExecutions(
{
select: ['id', 'mode', 'startedAt', 'stoppedAt', 'workflowId', 'jsonSizeBytes'],

View File

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

View File

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

View File

@ -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<unknown>) => {
const error: unknown = await promise.catch((e: unknown) => e);
expect(error).toBeInstanceOf(AppRuntimeError);
return error as AppRuntimeError;
};
describe('AppTableRuntimeService', () => {
let appRepository: ReturnType<typeof mock<AppRepository>>;
let dataTableService: ReturnType<typeof mock<DataTableService>>;
let service: AppTableRuntimeService;
beforeEach(() => {
appRepository = mock<AppRepository>();
dataTableService = mock<DataTableService>();
appRepository.findByNamespace.mockResolvedValue(app());
dataTableService.validateDataTableExists.mockResolvedValue(mock<DataTable>({ 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();
});
});
});

View File

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

View File

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

View File

@ -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<AppBinding, { kind: 'workflow' }> => b.key === key && b.kind === 'workflow',
);
if (!binding) {
throw new AppRuntimeError(
404,

View File

@ -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/<namespace>/api/tables/<key>/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<unknown>,
) {
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.' });
}
}
}

View File

@ -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<AppBinding, { kind: 'dataTable' }>;
/** 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<BoundTable> {
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<T>(parsed: z.SafeParseReturnType<unknown, T>): 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<T>(call: () => Promise<T>): Promise<T> {
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;
}
}

View File

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

View File

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

View File

@ -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.`,
],
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,13 @@ const i18n = useI18n();
<div>
<div :class="$style.body" data-test-id="instance-ai-app-binding-approval">
<N8nText tag="div" size="medium" bold>
{{ i18n.baseText('instanceAi.appBinding.prompt') }}
{{
i18n.baseText(
appBinding.kind === 'dataTable'
? 'instanceAi.appBinding.dataTable.prompt'
: 'instanceAi.appBinding.prompt',
)
}}
</N8nText>
<div :class="$style.row">
<N8nIcon icon="app-window" size="large" />
@ -22,18 +28,50 @@ const i18n = useI18n();
{{ appBinding.appName }}
</N8nText>
<N8nIcon icon="arrow-right" size="small" color="text-light" />
<N8nIcon icon="workflow" size="large" />
<N8nLink
:to="`/workflow/${appBinding.workflowId}`"
new-window
theme="text"
size="small"
bold
data-test-id="instance-ai-app-binding-workflow"
>
{{ appBinding.workflowName }}
</N8nLink>
<template v-if="appBinding.kind === 'dataTable'">
<N8nIcon icon="table" size="large" />
<N8nLink
:to="`/projects/${appBinding.projectId}/datatables/${appBinding.dataTableId}`"
new-window
theme="text"
size="small"
bold
data-test-id="instance-ai-app-binding-data-table"
>
{{ appBinding.dataTableName }}
</N8nLink>
</template>
<template v-else>
<N8nIcon icon="workflow" size="large" />
<N8nLink
:to="`/workflow/${appBinding.workflowId}`"
new-window
theme="text"
size="small"
bold
data-test-id="instance-ai-app-binding-workflow"
>
{{ appBinding.workflowName }}
</N8nLink>
</template>
</div>
<N8nText
v-if="appBinding.kind === 'dataTable'"
tag="div"
size="small"
color="text-light"
data-test-id="instance-ai-app-binding-access"
>
{{
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',
)
}}
</N8nText>
</div>
<ApprovalOptionList :options="options" @select="emit('select', $event)" />

View File

@ -215,7 +215,7 @@ const PERMISSION_GROUPS: PermissionGroup[] = [
{
id: 'apps',
labelKey: 'settings.n8nAgent.permissions.group.apps',
keys: ['bindAppWorkflow'],
keys: ['bindAppWorkflow', 'bindAppDataTable'],
},
{
id: 'system',

View File

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

View File

@ -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"
>
<N8nIcon icon="workflow" size="large" />
<N8nIcon :icon="binding.kind === 'dataTable' ? 'table' : 'workflow'" size="large" />
<N8nText
v-if="binding.missing"
size="small"
color="text-light"
:class="$style.connectionName"
data-test-id="app-connection-missing"
>
{{ binding.name }}
</N8nText>
<N8nLink
v-else-if="binding.kind === 'dataTable'"
:to="`/projects/${projectId}/datatables/${binding.dataTableId}`"
new-window
theme="text"
size="small"
:class="$style.connectionName"
data-test-id="app-connection-data-table"
>
{{ binding.name }}
</N8nLink>
<N8nLink
v-else
:to="`/workflow/${binding.workflowId}`"
new-window
theme="text"
@ -834,6 +855,22 @@ watch(
>
{{ binding.name }}
</N8nLink>
<N8nText
v-if="binding.kind === 'dataTable' && !binding.missing"
size="small"
color="text-light"
data-test-id="app-connection-access"
>
{{
i18n.baseText(
binding.permissions.includes('read')
? binding.permissions.includes('write')
? 'apps.connections.access.readWrite'
: 'apps.connections.access.read'
: 'apps.connections.access.write',
)
}}
</N8nText>
<N8nTooltip
v-if="bindingWarnings(binding.key).length > 0"
:content="bindingWarnings(binding.key).join(' ')"

View File

@ -42,11 +42,15 @@ export function useAppDeletion() {
appId: string,
binding: DescribedBinding,
): Promise<boolean> => {
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'),