mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
feat(core): Let built apps read and write bound data tables (#38332)
This commit is contained in:
parent
6bfe813156
commit
2c8aec6fd2
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
129
packages/@n8n/app-sdk/src/__tests__/tables.test.ts
Normal file
129
packages/@n8n/app-sdk/src/__tests__/tables.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ import '@n8n/app-sdk';
|
|||
declare module '@n8n/app-sdk' {
|
||||
interface Bindings {
|
||||
workflows: {};
|
||||
tables: {};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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}'.`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
29
packages/cli/src/modules/apps/runtime/cors.ts
Normal file
29
packages/cli/src/modules/apps/runtime/cors.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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']]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)" />
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ const PERMISSION_GROUPS: PermissionGroup[] = [
|
|||
{
|
||||
id: 'apps',
|
||||
labelKey: 'settings.n8nAgent.permissions.group.apps',
|
||||
keys: ['bindAppWorkflow'],
|
||||
keys: ['bindAppWorkflow', 'bindAppDataTable'],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(' ')"
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user