feat(server): run server-exposed logic functions in the owner workspace (#22002)

## Summary

Implements the server-level logic-function tier in the simplest shape: a
logic function is "server-exposed" iff its manifest entry carries
`serverWebhookTriggerSettings`. Execution delegates to the
owner-workspace copy of that function — billing, throttling, env vars,
and the existing executor all apply uniformly against that workspace.

Supersedes #21971 with the simplified design from that discussion (no
`applicationRegistrationLogicFunction` registry, no dedicated manifest
type, no separate SDK helper, no special throttling).

## Design

- **Manifest**: `LogicFunctionManifest` gains
`serverWebhookTriggerSettings?`. The declarative `workspaceIdResolver`
shape is dropped.
- **Materialization**: those settings become two new jsonb columns on
`LogicFunctionEntity`. The manifest → flat converter and the
create-from-source DTO/util forward them; the property-config map and
editable-properties list are extended.
- **Lookup**: a single QB query joins `logicFunction → application →
applicationRegistration` and filters on `lf.workspaceId =
reg.workspaceId` to get only the owner workspace's copy.
- **Webhook**: `POST /webhooks/server/:logicFunctionUniversalIdentifier`
→ `ServerWebhookTriggerService.handle` → join lookup →
`LogicFunctionTriggerService.run`. No registry table, no
`:applicationRegistrationUniversalIdentifier` segment, no resolver.
- **Gate**: `IS_SERVER_LOGIC_FUNCTION_ENABLED` config var (disabled by
default).

## Test plan
- [x] `npx jest server-webhook-trigger` — 9 unit tests across the
webhook service.
- [x] `npx jest logic-function` — 88 existing tests stay green.
- [x] `npx nx typecheck twenty-server`.
- [x] `npx nx lint:diff-with-main twenty-server`.
- [x] Reset DB → init → run `database:migrate:prod` → run
`database:migrate:generate --name pending-migration-check` → no drift.
- [ ] Manual: hit `/webhooks/server/<uid>` end-to-end against a manifest
carrying `serverWebhookTriggerSettings`.

https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh

---
_Generated by [Claude
Code](https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22002?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-06-24 15:34:12 +02:00
committed by GitHub
parent 20ac0a52bf
commit b5a1aed24b
36 changed files with 1231 additions and 1061 deletions
@@ -198,4 +198,46 @@ describe('defineLogicFunction', () => {
'Database event trigger must have an eventName',
);
});
it('accepts a serverRouteTriggerSettings resolver returning { workspaceId, targetLogicFunctionUniversalIdentifier }', () => {
const result = defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Resolve workspace from request',
serverRouteTriggerSettings: { forwardedRequestHeaders: ['x-tenant'] },
handler: async () => ({
workspaceId: 'ws-1',
targetLogicFunctionUniversalIdentifier: 'target-uid',
}),
});
expect(result.success).toBe(true);
expect(result.config.serverRouteTriggerSettings).toBeDefined();
});
it('compile-time rejects a serverRouteTriggerSettings resolver returning the wrong shape', () => {
// @ts-expect-error — handler must return { workspaceId: string;
// targetLogicFunctionUniversalIdentifier: string } when
// `serverRouteTriggerSettings` is set.
const result = defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Bad resolver',
serverRouteTriggerSettings: { forwardedRequestHeaders: [] },
handler: async () => ({ notAWorkspaceId: 'oops' }),
});
expect(result.success).toBe(true);
});
it('compile-time rejects a serverRouteTriggerSettings resolver returning only workspaceId', () => {
// @ts-expect-error — handler must also return
// `targetLogicFunctionUniversalIdentifier`.
const result = defineLogicFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
name: 'Resolver missing target',
serverRouteTriggerSettings: { forwardedRequestHeaders: [] },
handler: async () => ({ workspaceId: 'ws-1' }),
});
expect(result.success).toBe(true);
});
});
@@ -1,13 +1,43 @@
import { type LogicFunctionManifest } from 'twenty-shared/application';
import {
type LogicFunctionManifest,
type ServerRouteTriggerSettings,
} from 'twenty-shared/application';
export type LogicFunctionHandler = (...args: any[]) => any | Promise<any>;
export type LogicFunctionConfig = Omit<
// A resolver function attached to `serverRouteTriggerSettings` runs in the
// owner workspace and must return BOTH the target workspace and the target
// logic function to dispatch to. The server contract is
// `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string;
// payload?: object }`. The resolver is the single point of authorization —
// the URL only carries the resolver's universalIdentifier.
export type ServerRouteResolverResult = {
workspaceId: string;
targetLogicFunctionUniversalIdentifier: string;
payload?: object;
};
export type ServerRouteResolverHandler = (
...args: any[]
) => ServerRouteResolverResult | Promise<ServerRouteResolverResult>;
type LogicFunctionConfigBase = Omit<
LogicFunctionManifest,
| 'sourceHandlerPath'
| 'builtHandlerPath'
| 'builtHandlerChecksum'
| 'handlerName'
> & {
handler: LogicFunctionHandler;
};
| 'serverRouteTriggerSettings'
>;
export type LogicFunctionConfig = LogicFunctionConfigBase &
(
| {
serverRouteTriggerSettings?: undefined;
handler: LogicFunctionHandler;
}
| {
serverRouteTriggerSettings: ServerRouteTriggerSettings;
handler: ServerRouteResolverHandler;
}
);