i18n - docs translations (#22102)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
committed by
GitHub
parent
53bfc6ab1c
commit
00b7d7c74a
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
تعمل مكونات الواجهة الأمامية في المتصفح داخل Web Worker معزول، بينما تعمل [الدوال المنطقية](/l/ar/developers/extend/apps/logic/logic-functions) على جانب الخادم. لا توجد استدعاءات مباشرة ضمن العملية بين الاثنين — بدلاً من ذلك، يصل مكون الواجهة الأمامية إلى الدالة المنطقية عبر HTTP.
|
||||
|
||||
يتم إتاحة الدالة المنطقية المُعلَنة باستخدام `httpRouteTriggerSettings` تحت نقطة النهاية `/s/` عند `${TWENTY_API_URL}/s\<path>`. يستدعي مكون الواجهة الأمامية ذلك المسار باستخدام `RestApiClient` من `twenty-client-sdk/rest`، والذي يقوم بالمصادقة باستخدام `TWENTY_APP_ACCESS_TOKEN` الذي تقوم Twenty بحقنه في الـ worker.
|
||||
يتم الوصول إلى الدالة المنطقية المُعلَنة باستخدام `httpRouteTriggerSettings` عبر HTTP عند مسار التوجيه الخاص بها. يقوم Twenty بحقن عنوان URL الأساسي الذي تُقدَّم منه الدوال الخاصة بك في عامل التشغيل على أنه `TWENTY_FUNCTIONS_URL`، إلى جانب `TWENTY_APP_ACCESS_TOKEN` الذي يُصادِّق الاستدعاء. لا يوجد عميل SDK مخصص لاستدعاء دوالك الخاصة بعد، لذا استدعِها باستخدام `fetch` عادي:
|
||||
|
||||
تم تصميم `RestApiClient` خصيصًا لهذا الغرض. يقوم بقراءة `TWENTY_API_URL` و`TWENTY_APP_ACCESS_TOKEN` من بيئة الـ worker، وإرفاق ترويسة `Authorization: Bearer`، وتسلسل وتحليل JSON، وإثارة `RestApiClientError` عندما يكون الرمز المميز أو عنوان URL مفقودًا أو عندما يكون الرد غير 2xx — حتى لا تعيد تنفيذ هذا الـ boilerplate في كل مكون.
|
||||
> **على Twenty Cloud، يتم تقديم الدوال المنطقية المُفعَّلة عبر HTTP على نطاق مخصص لكل مساحة عمل** عند `https://\<your-workspace-subdomain>.twenty.com\<path>` — وهذا بالضبط ما تُشير إليه قيمة `TWENTY_FUNCTIONS_URL`. للمتصلين الخارجيين، انسخ عنوان URL الدقيق من إعدادات **HTTP trigger** الخاصة بالدالة أو من علامة تبويب **Settings** في التطبيق.
|
||||
|
||||
<Warning>
|
||||
مسار الدالة القديم `/s/` **مهمَل (deprecated)** وسيتم **إيقاف تفعيله في 2026-07-24**. استخدم بدلًا من ذلك `TWENTY_FUNCTIONS_URL` (أعلاه)، ورحِّل أي عناوين URL ثابتة من نوع `/s/` قبل ذلك التاريخ. يبقى مسار `/s/` متاحًا للاستضافة الذاتية.
|
||||
</Warning>
|
||||
|
||||
يمكن لمكون واجهة أمامية عديم الرأس تنفيذ الاستدعاء عند التركيب عبر مكون `Command`، ثم إلغاء التركيب تلقائيًا:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
المسار المُمرَّر إلى العميل هو المسار العام للمسار (route) — قيمة `httpRouteTriggerSettings.path` الخاصة بدالة المنطق (logic function) مع إضافة البادئة `/s`. أبقِ `isAuthRequired: true`؛ يزوّد العميل مكوّنك برمز وصول التطبيق الذي تُصدِره Twenty:
|
||||
المسار المُضاف إلى `TWENTY_FUNCTIONS_URL` هو قيمة `httpRouteTriggerSettings.path` الخاصة بدالة المنطق (logic function). أبقِ `isAuthRequired: true`؛ فرمز `TWENTY_APP_ACCESS_TOKEN` الذي تُنشئه Twenty لمكوِّنك هو ما يصادق على الطلب:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
يتم حقن `TWENTY_API_URL` و`TWENTY_APP_ACCESS_TOKEN` تلقائيًا — انظر [متغيرات التطبيق](#application-variables). نظرًا لأن متغيرات التطبيق السرية لا تُعرَض أبدًا على مكونات الواجهة الأمامية، احتفِظ بمفاتيح واجهة برمجة التطبيقات والمنطق الحساس الآخر داخل الدالة المنطقية، وليس في مكون الواجهة الأمامية.
|
||||
يتم حقن `TWENTY_FUNCTIONS_URL` و`TWENTY_APP_ACCESS_TOKEN` تلقائيًا — انظر [متغيرات التطبيق](#application-variables). نظرًا لأن متغيرات التطبيق السرية لا تُعرَض أبدًا على مكونات الواجهة الأمامية، احتفِظ بمفاتيح واجهة برمجة التطبيقات والمنطق الحساس الآخر داخل الدالة المنطقية، وليس في مكون الواجهة الأمامية.
|
||||
</Note>
|
||||
|
||||
### مرجع RestApiClient
|
||||
### استدعاء واجهة REST API الخاصة بـ Twenty
|
||||
|
||||
استورد `RestApiClient` من `twenty-client-sdk/rest`. ينتمي إلى نفس عائلة العملاء مثل `CoreApiClient` و`MetadataApiClient`، لكنه يستهدف مسارات HTTP الخاصة بتطبيقك بدلاً من واجهة GraphQL API.
|
||||
لقراءة سجلات Twenty أو كتابتها من مكوِّن واجهة أمامية، استخدم `RestApiClient` من `twenty-client-sdk/rest`. ينتمي إلى نفس عائلة العملاء مثل `CoreApiClient` و`MetadataApiClient`، لكنه يستهدف واجهة REST API الخاصة بـ Twenty (`/rest/...`) بدلًا من واجهة GraphQL API، ويقرأ عنوان URL الأساسي الخاص بها من `TWENTY_API_URL`.
|
||||
|
||||
| طريقة | الوصف |
|
||||
| --------------------------------- | -------------------------- |
|
||||
@@ -280,7 +285,7 @@ export default defineLogicFunction({
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ export default defineFrontComponent({
|
||||
|
||||
متغيرات النظام التالية تكون متاحة دائمًا عبر `process.env`:
|
||||
|
||||
| المتغيّر | الوصف |
|
||||
| ------------------------- | --------------------------------------- |
|
||||
| `TWENTY_API_URL` | عنوان URL الأساسي لـ Twenty API |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | رمز مميز قصير العمر مُقيَّد بدور تطبيقك |
|
||||
| المتغيّر | الوصف |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `TWENTY_FUNCTIONS_URL` | عنوان URL الأساسي الذي تُقدَّم منه دوال منطق HTTP الخاصة بتطبيقك (يُستخدم بواسطة `RestApiClient`) |
|
||||
| `TWENTY_API_URL` | عنوان URL الأساسي لـ Twenty Core API |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | رمز مميز قصير العمر مُقيَّد بدور تطبيقك |
|
||||
|
||||
## واجهة الاتصال مع المضيف
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export default defineLogicFunction({
|
||||
* **cron**: يشغّل وظيفتك على جدول باستخدام تعبير CRON.
|
||||
* **databaseEvent**: يعمل على أحداث دورة حياة كائنات مساحة العمل. عندما تكون عملية الحدث هي `updated`، يمكن تحديد الحقول المحددة المراد الاستماع إليها في مصفوفة `updatedFields`. إذا تُركت غير معرّفة أو فارغة، فسيؤدي أي تحديث إلى تشغيل الدالة.
|
||||
> مثال: `person.updated`، `*.created`، `company.*`
|
||||
* **serverWebhook**: يستقبل خطافات الويب الواردة من خدمة خارجية (Stripe وGitHub وSvix و…) على نقطة نهاية واحدة ضمن نطاق التسجيل ويحدد مساحة العمل المستهدفة من الحمولة. راجع [مشغّل خطاف الويب على الخادم](#server-webhook-trigger).
|
||||
* **serverRoute**: يوفّر مسار HTTP واحدًا بنطاق التسجيل. تعمل دالة **resolver** (المُعلَنة باستخدام `serverRouteTriggerSettings`) في مساحة عمل المالك وتُرجِع مساحة العمل المستهدفة ودالة المنطق المستهدفة التي يجب التوجيه إليها؛ ثم يُشغِّل النظام الأساسي تلك الدالة **المستهدفة** ويُرجِع استجابتها. راجع [مشغّل مسار الخادم](#server-route-trigger).
|
||||
|
||||
<Note>
|
||||
يمكنك أيضًا تنفيذ دالة يدويًا باستخدام CLI:
|
||||
@@ -172,54 +172,108 @@ const handler = async (event: RoutePayload) => {
|
||||
يجب أن يكون رمز الحالة رمز حالة HTTP صالحًا (بين 100 و599). تتم مطابقة أسماء ترويسات الاستجابة دون حساسية لحالة الأحرف.
|
||||
</Note>
|
||||
|
||||
#### مشغّل ربط الويب على الخادم
|
||||
#### مشغّل مسار الخادم
|
||||
|
||||
`httpRouteTriggerSettings` يوفّر دالة تحت `/s/` ويحل مساحة العمل من مضيف الطلب — وهذا يعمل عندما تكون لكل مساحة عمل نطاقها الخاص. مع ذلك، يرسل المزوّدون الخارجيون أحداث كل مستأجر إلى عنوان URL واحد لربط الويب. في هذه الحالة، استخدم `serverWebhookTriggerSettings`: تكون الدالة متاحة عند نقطة نهاية ذات نطاق تسجيل ويتم حل مساحة العمل من الحمولة.
|
||||
`httpRouteTriggerSettings` يوفّر دالة تحت `/s/` ويحل مساحة العمل من مضيف الطلب — وهذا يعمل عندما تكون لكل مساحة عمل نطاقها الخاص. لكن المزوّدين من جهات خارجية يرسلون أحداث كل مستأجر إلى عنوان URL **واحد**. في هذه الحالة، استخدم `serverRouteTriggerSettings`.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
يتكوّن المشغّل من جزأين:
|
||||
|
||||
1. دالة منطق **resolver** — يتم التصريح عنها باستخدام `serverRouteTriggerSettings` — تعمل في **مساحة العمل المالكة** (مساحة العمل التي تمتلك تسجيل التطبيق). تتفحّص الطلب الوارد وتُرجِع `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`، لتحديد *كلٍ من* مساحة العمل المستهدفة والدالة المستهدفة. يُعَدّ الـ resolver نقطة التفويض الوحيدة — فعنوان URL يحمل فقط معرّف الـ resolver. **هذا هو المكان المفضّل للتحقق من تواقيع الطلبات**: يعمل الـ resolver قبل أي تأثير جانبي، ولديه إمكانية الوصول إلى `rawBody` الأصلي والرؤوس المُمرَّرة، ويمكنه رفض الطلب دون لمس الهدف مطلقًا.
|
||||
2. دالة منطق **target** — دالة منطق عادية لكل مساحة عمل — تعمل بعد ذلك في مساحة العمل التي تم حلّها باستخدام الحمولة التي أعادها الـ resolver (أو حمولة الطلب الأصلية إذا لم يقم الـ resolver بتحويلها). تصبح القيمة التي تعيدها هي استجابة HTTP.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
يمكن الوصول إلى الدالة عند:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
كلا المعرّفين هما `universalIdentifier`s من البيان التعريفي الخاص بك — تسجيل التطبيق وهذه الدالة المنطقية. سجّل عنوان URL هذا لدى المزوّد.
|
||||
يمكن الوصول إلى نقطة النهاية عند:
|
||||
|
||||
**حل مساحة العمل.** نظرًا لأن نقطة النهاية الواحدة تخدم كل مساحات العمل، يجب أن يضع تكاملك `workspaceId` المستهدف في مكان ما في التسليم، وتخبر `workspaceIdResolver.{ source, path }` المنصّة بمكان قراءته:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| الحقل | القيم | الملاحظات |
|
||||
| -------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `المصدر` | `body` \| `query` \| `header` | `body` يقرأ JSON المحلَّل. `query` هو الخيار الأكثر شمولاً — فعادةً ما تتحكم في عنوان URL لرد النداء الذي تسجّله، لذا أضِف `?twentyWorkspaceId=…`. |
|
||||
| `مسار` | مسار بنقطة، على سبيل المثال: `metadata.twentyWorkspaceId` | يقتصر على مقاطع أبجدية رقمية / `_` / `-`؛ يتم رفض مفاتيح النموذج الأولي. |
|
||||
المعرّف هو `universalIdentifier` الخاص بالـ resolver من ملف manifest لديك. سجّل عنوان URL هذا لدى المزوّد.
|
||||
|
||||
يجب أن تكون القيمة المحلولة UUID صالحًا لمساحة عمل **و** يجب أن يكون تطبيقك مثبتًا في تلك المساحة، وإلا فسيتم رفض الطلب قبل تشغيل الدالة.
|
||||
**عقد الـ Resolver.** يفرض نوع `LogicFunctionConfig` في حزمة SDK هذا في وقت الترجمة: بمجرد تعيينك لـ `serverRouteTriggerSettings`، يُقيَّد الـ handler الخاص بك بأن يُرجِع `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (أو `Promise` من هذا الكائن). يجب أن يكون `workspaceId` لمساحة عمل تكون الدالة المستهدفة مثبّتة فيها، وإلا فسيتم رفض الطلب مع `404`.
|
||||
|
||||
| الحقل | النوع | الملاحظات |
|
||||
| ---------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | معرّف UUID لمساحة العمل التي سيعمل فيها الهدف. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` لدالة المنطق التي سيتم استدعاؤها في تلك المساحة. |
|
||||
| `payload` | `object` (اختياري) | إذا تم تعيينه، فإنه يستبدل جسم الطلب المُرسَل إلى الهدف. |
|
||||
|
||||
<Warning>
|
||||
**التحقق من التوقيع من مسؤوليتك.** المنصّة لا تتحقّق من توقيعات ربط الويب لهذا المشغّل — فهي تكتفي بحل مساحة العمل وتشغيل الدالة الخاصة بك. يجب أن يتحقق معالِجك من التوقيع بنفسه باستخدام `event.rawBody` والرؤوس التي أدرجتها في `forwardedRequestHeaders`، مع المقارنة بسر محفوظ كمتغيّر خادم/تطبيق. تحقّق دائمًا **قبل** أي تأثير جانبي، واستخدم مقارنة بزمن ثابت.
|
||||
**مسؤولية التحقق من التوقيع تقع عليك — تحقّق في الـ resolver.** المنصّة لا تتحقق من تواقيع الطلبات. يُعَدّ الـ resolver المكان الموصى به للقيام بذلك: فهو يعمل أولًا، مع إمكانية الوصول إلى `event.rawBody` والرؤوس التي أدرجتها في `forwardedRequestHeaders`، وأي خطأ يتم رميه (أو أي `workspaceId` لا يطابق) يوقف عملية الإرسال قبل استدعاء الهدف. إذا دفعت التحقق بدلًا من ذلك إلى داخل الهدف، فيجب على الهدف أن يكون حذرًا حتى لا يفقد `rawBody` والرؤوس — أي يجب ألّا يعيد الـ resolver خاصية `payload`. تحقّق دائمًا **قبل** أي تأثير جانبي، واستخدم مقارنة بزمن ثابت.
|
||||
</Warning>
|
||||
|
||||
يستخدم معظم المزوّدين HMAC-SHA256 للتوقيع؛ الأجزاء التي تختلف هي اسم الرأس وترميز الملخّص وسلسلة الحمولة الموقّعة. بعض الأمثلة:
|
||||
بالنسبة لتواقيع الطلبات، يستخدم معظم المزوّدين HMAC-SHA256 للتوقيع؛ الأجزاء التي تختلف هي اسم الرأس وترميز الملخّص وسلسلة الحمولة الموقّعة. بعض الأمثلة:
|
||||
|
||||
| المزود | الرؤوس المطلوب تمريرها | السلسلة الموقَّعة | الملخّص |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | -------------------------------------------------- |
|
||||
@@ -229,31 +283,10 @@ POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniv
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (يبدأ بـ `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
يُظهِر مثال الـ resolver أعلاه بالفعل تدفّق GitHub HMAC-SHA256 — عدِّل اسم الرأس وترميز الملخّص وسلسلة الحمولة الموقّعة بحسب المزوّد الذي تدمجه.
|
||||
|
||||
<Note>
|
||||
تعمل الدالة **بشكل متزامن** وتصبح القيمة التي تعيدها هي استجابة HTTP، لذا يرى المزوّدون رمز الحالة الخاص بك ويمكنهم إعادة المحاولة عند رموز غير 2xx. اجعل المعالِجات سريعة — بعض المزوّدين (مثل Slack) تنتهي مهلة طلباتهم خلال بضع ثوانٍ. نظرًا لأن الدالة تعمل قبل التحقق من التوقيع، قم بحماية نقطة النهاية هذه عبر تحديد المعدل على الحافة الخاصة بك.
|
||||
يعمل الهدف **بشكل متزامن** وتصبح القيمة التي يعيدها هي استجابة HTTP، لذا يرى المتّصلون رمز الحالة الخاص بك ويمكنهم إعادة المحاولة عند رموز غير 2xx. اجعل كلا المعالِجَيْن سريعين — بعض المزوّدين (مثل Slack) تنتهي مهلة طلباتهم خلال بضع ثوانٍ. نظرًا لأن الـ resolver يمكن الوصول إليه كنقطة نهاية عامة، قم بحمايته من خلال تحديد المعدّل (rate limiting) على الحافة لديك.
|
||||
</Note>
|
||||
|
||||
#### حمولة مُحفِّز حدث قاعدة البيانات
|
||||
@@ -615,12 +648,12 @@ console.log(uploadedFile);
|
||||
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
|
||||
```
|
||||
|
||||
| المعلمة | النوع | الوصف |
|
||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------- |
|
||||
| `fileBuffer` | `Buffer` | المحتوى الخام للملف |
|
||||
| `filename` | `string` | اسم الملف (يُستخدم للتخزين والعرض) |
|
||||
| `contentType` | `string` | نوع MIME (القيمة الافتراضية `application/octet-stream` إذا لم يُحدَّد) |
|
||||
| `fieldMetadataUniversalIdentifier` | `string` | قيمة `universalIdentifier` لحقل نوع الملف في كائنك |
|
||||
| المعلمة | النوع | الوصف |
|
||||
| ---------------------------------- | ------------ | ---------------------------------------------------------------------- |
|
||||
| `fileBuffer` | `Buffer` | المحتوى الخام للملف |
|
||||
| `filename` | `string` | اسم الملف (يُستخدم للتخزين والعرض) |
|
||||
| `contentType` | `سلسلة نصية` | نوع MIME (القيمة الافتراضية `application/octet-stream` إذا لم يُحدَّد) |
|
||||
| `fieldMetadataUniversalIdentifier` | `سلسلة نصية` | قيمة `universalIdentifier` لحقل نوع الملف في كائنك |
|
||||
|
||||
النقاط الرئيسية:
|
||||
* يستخدم `universalIdentifier` الخاص بالحقل (وليس معرّفه الخاص بمساحة العمل)، بحيث يعمل كود الرفع لديك عبر أي مساحة عمل مُثبَّت فيها تطبيقك.
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
Front komponenty běží v prohlížeči v izolovaném web workeru, zatímco [logické funkce](/l/cs/developers/extend/apps/logic/logic-functions) běží na serveru. Neexistuje mezi nimi žádné přímé volání v rámci jednoho procesu — místo toho se front komponenta k logické funkci připojuje přes HTTP.
|
||||
|
||||
Logická funkce deklarovaná pomocí `httpRouteTriggerSettings` je vystavena pod endpointem `/s/` na `${TWENTY_API_URL}/s\<path>`. Vaše front komponenta volá tuto trasu pomocí `RestApiClient` z `twenty-client-sdk/rest`, který se autentizuje pomocí `TWENTY_APP_ACCESS_TOKEN`, který Twenty do workeru vkládá.
|
||||
Logická funkce deklarovaná pomocí `httpRouteTriggerSettings` je přes HTTP dostupná na své cestě (route path). Twenty vloží do workeru základní URL, ze které jsou vaše funkce poskytovány, jako `TWENTY_FUNCTIONS_URL` spolu s `TWENTY_APP_ACCESS_TOKEN`, který volání autentizuje. Zatím neexistuje žádný specializovaný klient SDK pro volání vlastních funkcí, takže je volejte pomocí prostého `fetch`:
|
||||
|
||||
`RestApiClient` je přesně pro tento účel. Z worker prostředí čte `TWENTY_API_URL` a `TWENTY_APP_ACCESS_TOKEN`, přidává hlavičku `Authorization: Bearer`, serializuje a parsuje JSON a vyhazuje `RestApiClientError`, pokud token nebo URL chybí nebo je odpověď mimo rozsah 2xx — takže nemusíte tento boilerplate znovu implementovat v každé komponentě.
|
||||
> **V Twenty Cloud jsou logické funkce spouštěné přes HTTP poskytovány na vyhrazené doméně pro každý workspace** na adrese `https://\<your-workspace-subdomain>.twenty.com\<path>` — právě na tuto adresu `TWENTY_FUNCTIONS_URL` směřuje. Pro externí volající zkopírujte přesnou URL z nastavení funkce **HTTP trigger** nebo z karty **Settings** aplikace.
|
||||
|
||||
<Warning>
|
||||
Původní funkční trasa `/s/` je **zastaralá** a bude **deaktivována dne 2026-07-24**. Místo toho použijte `TWENTY_FUNCTIONS_URL` (viz výše) a do tohoto data migrujte všechny pevně zakódované adresy URL `/s/`. Trasa `/s/` zůstává k dispozici pro self-hosting.
|
||||
</Warning>
|
||||
|
||||
Headless front komponenta může volání spustit při mountu přes komponentu `Command` a poté se automaticky odmountovat:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
Cesta předaná klientovi je veřejná cesta trasy — `httpRouteTriggerSettings.path` logické funkce s předponou `/s`. Ponechte `isAuthRequired: true`; klient poskytuje pro vaši komponentu přístupový token aplikace vydaný Twenty:
|
||||
Cesta připojená k `TWENTY_FUNCTIONS_URL` je `httpRouteTriggerSettings.path` logické funkce. Ponechte `isAuthRequired: true`; `TWENTY_APP_ACCESS_TOKEN`, který Twenty vygeneruje pro vaši komponentu, požadavek autentizuje:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` a `TWENTY_APP_ACCESS_TOKEN` jsou vloženy automaticky — viz [Proměnné aplikace](#application-variables). Protože tajné proměnné aplikace nejsou nikdy vystaveny front komponentám, ponechte API klíče a další citlivou logiku v logické funkci, ne ve front komponentě.
|
||||
`TWENTY_FUNCTIONS_URL` a `TWENTY_APP_ACCESS_TOKEN` jsou vloženy automaticky — viz [Proměnné aplikace](#application-variables). Protože tajné proměnné aplikace nejsou nikdy vystaveny front komponentám, ponechte API klíče a další citlivou logiku v logické funkci, ne ve front komponentě.
|
||||
</Note>
|
||||
|
||||
### Reference `RestApiClient`
|
||||
### Volání Twenty REST API
|
||||
|
||||
Importujte `RestApiClient` z `twenty-client-sdk/rest`. Patří do stejné rodiny klientů jako `CoreApiClient` a `MetadataApiClient`, ale cílí na HTTP trasy vaší aplikace místo na GraphQL API.
|
||||
Pro čtení nebo zápis záznamů Twenty z front komponenty použijte `RestApiClient` z `twenty-client-sdk/rest`. Patří do stejné rodiny klientů jako `CoreApiClient` a `MetadataApiClient`, ale cílí na Twenty REST API (`/rest/...`) místo na GraphQL API a svou základní URL čte z `TWENTY_API_URL`.
|
||||
|
||||
| Metoda | Popis |
|
||||
| --------------------------------- | ------------------------------------------ |
|
||||
@@ -280,7 +285,7 @@ Základní URL a token jsou ve výchozím nastavení odvozeny z prostředí. Pod
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ Tajné proměnné (`isSecret: true`) **nejsou** zpřístupněny front-endovým k
|
||||
|
||||
Následující systémové proměnné jsou vždy dostupné přes `process.env`:
|
||||
|
||||
| Proměnná | Popis |
|
||||
| ------------------------- | -------------------------------------------------------------- |
|
||||
| `TWENTY_API_URL` | Základní URL Twenty API |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Krátkodobý token s oprávněními omezenými na roli vaší aplikace |
|
||||
| Proměnná | Popis |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `TWENTY_FUNCTIONS_URL` | Základní URL, ze které jsou poskytovány HTTP logické funkce vaší aplikace (používá ji `RestApiClient`) |
|
||||
| `TWENTY_API_URL` | Základní URL Twenty core API |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Krátkodobý token s oprávněními omezenými na roli vaší aplikace |
|
||||
|
||||
## API komunikace s hostitelem
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Chcete-li vyvolat logickou funkci spuštěnou trasou z (bezhlavé) front-endové
|
||||
* **cron**: Spouští vaši funkci podle plánu pomocí výrazu CRON.
|
||||
* **databaseEvent**: Spouští se při událostech životního cyklu objektů v pracovním prostoru. Když je operace události `updated`, lze konkrétní sledovaná pole určit v poli `updatedFields`. Pokud zůstane nedefinované nebo prázdné, spustí funkci jakákoli aktualizace.
|
||||
> např. `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook**: Přijímá příchozí webhooky od služby třetí strany (Stripe, GitHub, Svix, …) na jediném koncovém bodu v rámci registrace a z payloadu určí cílový pracovní prostor. Viz [spouštěč serverového webhooku](#server-webhook-trigger).
|
||||
* **serverRoute**: Zpřístupňuje jednu registrací omezenou trasu HTTP. Funkce **resolver** (deklarovaná pomocí `serverRouteTriggerSettings`) běží ve vlastnickém workspace a vrací cílový workspace i cílovou logickou funkci, na kterou se má směrovat; platforma poté spustí tuto **cílovou** funkci a vrátí její odpověď. Viz [spouštěč serverové trasy](#server-route-trigger).
|
||||
|
||||
<Note>
|
||||
Funkci můžete také spustit ručně pomocí CLI:
|
||||
@@ -173,54 +173,108 @@ Z bezpečnostních důvodů jsou hlavičky odpovědi omezeny na seznam povolený
|
||||
Stavový kód musí být platný stavový kód HTTP (mezi 100 a 599). Názvy hlaviček odpovědi se porovnávají bez rozlišení velikosti písmen.
|
||||
</Note>
|
||||
|
||||
#### Serverový spouštěč webhooku
|
||||
#### Spouštěč serverové trasy
|
||||
|
||||
`httpRouteTriggerSettings` zpřístupňuje funkci pod `/s/` a workspace určuje z hostitele požadavku — což funguje, když má každý workspace svou vlastní doménu. Poskytovatelé třetích stran však doručují události každého tenanta na **jednu** adresu URL webhooku. Pro tento případ použijte `serverWebhookTriggerSettings`: funkce je dostupná na endpointu v rámci registrace a workspace se určuje z payloadu.
|
||||
`httpRouteTriggerSettings` zpřístupňuje funkci pod `/s/` a workspace určuje z hostitele požadavku — což funguje, když má každý workspace svou vlastní doménu. Poskytovatelé třetích stran však doručují události každého tenanta na **jednu** adresu URL. Pro tento případ použijte `serverRouteTriggerSettings`.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
Spouštěč má dvě části:
|
||||
|
||||
1. Logická funkce **resolveru** — deklarovaná pomocí `serverRouteTriggerSettings` — běží ve vašem **vlastnickém workspace** (workspace, který je vlastníkem registrace aplikace). Prohlédne si příchozí request a vrátí `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`, čímž zvolí *obě* — cílový workspace i cílovou funkci. Resolver je jediným místem autorizace — URL nese pouze identifikátor resolveru. **Toto je preferované místo pro ověřování podpisů requestů**: resolver běží před jakýmikoli vedlejšími efekty, má přístup k původnímu `rawBody` a předaným hlavičkám a může request odmítnout, aniž by se vůbec dotkl cíle.
|
||||
2. Cílová (**target**) logická funkce — běžná per-workspace logická funkce — pak běží v určeném workspace s payloadem vráceným resolverem (nebo s původním payloadem requestu, pokud jej resolver neupravil). Její návratová hodnota se stává HTTP odpovědí.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Funkce je dostupná na:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
Oba identifikátory jsou `universalIdentifier`s z vašeho manifestu — registrace aplikace a této logické funkce. Zaregistrujte tuto adresu URL u poskytovatele.
|
||||
Endpoint je dostupný na:
|
||||
|
||||
**Určení workspace.** Protože jeden endpoint obsluhuje každý workspace, vaše integrace musí vložit cílové `workspaceId` někam do doručovaných dat a `workspaceIdResolver.{ source, path }` říká platformě, odkud ho přečíst:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| Pole | Hodnoty | Poznámky |
|
||||
| ------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `zdroj` | `body` \| `query` \| `header` | `body` čte parsovaný JSON. `query` je nejuniverzálnější — obvykle máte pod kontrolou callback URL, kterou registrujete, takže připojte `?twentyWorkspaceId=…`. |
|
||||
| `cesta` | tečková cesta, např. `metadata.twentyWorkspaceId` | Omezeno na alfanumerické / `_` / `-` segmenty; prototypové klíče jsou odmítnuty. |
|
||||
Identifikátor je `universalIdentifier` resolveru z vašeho manifestu. Zaregistrujte tuto adresu URL u poskytovatele.
|
||||
|
||||
Určená hodnota musí být platné UUID workspace **a** vaše aplikace musí být v tomto workspace nainstalovaná, jinak je požadavek odmítnut ještě před spuštěním funkce.
|
||||
**Smlouva resolveru.** Typ `LogicFunctionConfig` v SDK toto vynucuje v době kompilace: jakmile nastavíte `serverRouteTriggerSettings`, váš handler je omezen tak, aby vracel `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (nebo `Promise` této hodnoty). `workspaceId` musí být workspace, ve kterém je cílová funkce nainstalována, jinak je request odmítnut s chybou `404`.
|
||||
|
||||
| Pole | Typ | Poznámky |
|
||||
| ---------------------------------------- | -------------------- | ------------------------------------------------------------------------------ |
|
||||
| `workspaceId` | `string` | Workspace UUID, ve kterém bude cíl spuštěn. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` logické funkce, která má být v daném workspace spuštěna. |
|
||||
| `payload` | `object` (nepovinné) | Pokud je nastaven, nahradí tělo requestu odeslané na cíl. |
|
||||
|
||||
<Warning>
|
||||
**Ověření podpisu je vaše zodpovědnost.** Platforma pro tento spouštěč neověřuje podpisy webhooků — pouze určí workspace a spustí vaši funkci. Váš handler musí podpis ověřit sám pomocí `event.rawBody` a hlaviček, které jste uvedli v `forwardedRequestHeaders`, porovnáním s tajemstvím uloženým jako serverová/aplikační proměnná. Vždy ověřujte **před** jakýmikoliv vedlejšími efekty a použijte porovnání v konstantním čase.
|
||||
**Ověření podpisu je vaší odpovědností — proveďte ho v resolveru.** Platforma nepřezkušuje (neověřuje) podpisy requestů. Resolver je k tomu doporučené místo: běží jako první, má přístup k `event.rawBody` a hlavičkám, které jste uvedli v `forwardedRequestHeaders`, a vyhozená chyba (nebo jakékoli neodpovídající `workspaceId`) zastaví předání dříve, než je cíl zavolán. Pokud místo toho posunete ověřování až do cíle, cíl musí dávat pozor, aby neztratil `rawBody` a hlavičky — tj. resolver nesmí vracet `payload`. Vždy ověřujte **před** jakýmikoliv vedlejšími efekty a použijte porovnání v konstantním čase.
|
||||
</Warning>
|
||||
|
||||
Většina poskytovatelů podepisuje pomocí HMAC-SHA256; části, které se liší, jsou název hlavičky, kódování digestu a podepsaný řetězec payloadu. Několik příkladů:
|
||||
U podpisů requestů většina poskytovatelů podepisuje pomocí HMAC-SHA256; části, které se liší, jsou název hlavičky, kódování digestu a podepsaný řetězec payloadu. Několik příkladů:
|
||||
|
||||
| Poskytovatel | Hlavičky k přeposlání | Podepsaný řetězec | Digest |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | ----------------------------------------------------- |
|
||||
@@ -230,31 +284,10 @@ Většina poskytovatelů podepisuje pomocí HMAC-SHA256; části, které se liš
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (s prefixem `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
Příklad resolveru výše už ukazuje GitHub HMAC-SHA256 flow — přizpůsobte název hlavičky, kódování digestu a podepsaný řetězec payloadu podle poskytovatele, se kterým se integrujete.
|
||||
|
||||
<Note>
|
||||
Funkce běží **synchronně** a vámi vrácená hodnota se stává HTTP odpovědí, takže poskytovatelé vidí váš stavový kód a mohou opakovat požadavek při jiném než 2xx kódu. Udržujte handlery rychlé — některým poskytovatelům (např. Slack) vyprší časový limit během několika sekund. Protože funkce běží před tím, než je podpis zkontrolován, chraňte tento endpoint rate limitingem na vaší edge vrstvě.
|
||||
Cíl běží **synchronně** a jeho návratová hodnota se stává HTTP odpovědí, takže volající vidí váš stavový kód a mohou opakovat požadavek při jiném než 2xx kódu. Udržujte oba handlery rychlé — některým poskytovatelům (např. Slack) vyprší časový limit během několika sekund. Protože je resolver dostupný jako veřejný endpoint, chraňte ho omezením rychlosti (rate limiting) na své edge vrstvě.
|
||||
</Note>
|
||||
|
||||
#### Payload spouštěče databázové události
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
Front-Komponenten laufen browserseitig in einem isolierten Web Worker, während [Logikfunktionen](/l/de/developers/extend/apps/logic/logic-functions) serverseitig ausgeführt werden. Es gibt keinen direkten In-Process-Aufruf zwischen beiden – stattdessen ruft eine Front-Komponente eine Logikfunktion über HTTP auf.
|
||||
|
||||
Eine mit `httpRouteTriggerSettings` deklarierte Logikfunktion wird unter dem `/s/`-Endpunkt unter `${TWENTY_API_URL}/s\<path>` bereitgestellt. Ihre Front-Komponente ruft diese Route mit dem `RestApiClient` aus `twenty-client-sdk/rest` auf, der sich mit dem `TWENTY_APP_ACCESS_TOKEN` authentifiziert, das Twenty in den Worker injiziert.
|
||||
Eine mit `httpRouteTriggerSettings` deklarierte Logikfunktion ist über HTTP unter ihrem Routenpfad erreichbar. Twenty injiziert die Basis-URL, unter der deine Funktionen bereitgestellt werden, als `TWENTY_FUNCTIONS_URL` in den Worker, zusammen mit dem `TWENTY_APP_ACCESS_TOKEN`, das den Aufruf authentifiziert. Es gibt noch keinen eigenen SDK-Client zum Aufrufen deiner eigenen Funktionen, daher rufe sie mit einem einfachen `fetch` auf:
|
||||
|
||||
Der `RestApiClient` ist genau dafür gemacht. Er liest `TWENTY_API_URL` und `TWENTY_APP_ACCESS_TOKEN` aus der Worker-Umgebung, hängt den Header `Authorization: Bearer` an, serialisiert und parst JSON und löst einen `RestApiClientError` aus, wenn das Token oder die URL fehlt oder die Antwort kein 2xx-Status ist – sodass Sie diesen Boilerplate-Code nicht in jeder Komponente neu implementieren müssen.
|
||||
> **In Twenty Cloud werden HTTP-ausgelöste Logikfunktionen auf einer eigenen, arbeitsbereichsspezifischen Domain bereitgestellt** unter `https://\<your-workspace-subdomain>.twenty.com\<path>` — genau darauf verweist `TWENTY_FUNCTIONS_URL`. Für externe Aufrufer kopiere die exakte URL aus den **HTTP trigger**-Einstellungen der Funktion oder aus dem **Settings**-Tab der Anwendung.
|
||||
|
||||
<Warning>
|
||||
Die `/s/`-Funktionsroute ist **veraltet** und wird **am 2026-07-24 deaktiviert**. Verwende stattdessen `TWENTY_FUNCTIONS_URL` (oben) und migriere alle hart codierten `/s/`-URLs vor diesem Datum. Die `/s/`-Route bleibt für Self-Hosting verfügbar.
|
||||
</Warning>
|
||||
|
||||
Eine headless Front-Komponente kann den Aufruf beim Mounten über die `Command`-Komponente ausführen und sich anschließend automatisch unmounten:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
Der an den Client übergebene Pfad ist der öffentliche Pfad der Route – der `httpRouteTriggerSettings.path` der Logikfunktion, der mit `/s` präfixiert ist. Belasse `isAuthRequired: true`; der Client stellt das App-Zugriffstoken bereit, das Twenty für deine Komponente ausstellt:
|
||||
Der an `TWENTY_FUNCTIONS_URL` angehängte Pfad ist das `httpRouteTriggerSettings.path` der Logikfunktion. Belasse `isAuthRequired: true`; das `TWENTY_APP_ACCESS_TOKEN`, das Twenty für deine Komponente ausstellt, authentifiziert die Anfrage:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` und `TWENTY_APP_ACCESS_TOKEN` werden automatisch injiziert – siehe [Anwendungsvariablen](#application-variables). Da geheime Anwendungsvariablen niemals in Front-Komponenten offengelegt werden, sollten API-Schlüssel und andere sensible Logik in der Logikfunktion verbleiben und nicht in der Front-Komponente.
|
||||
`TWENTY_FUNCTIONS_URL` und `TWENTY_APP_ACCESS_TOKEN` werden automatisch injiziert – siehe [Anwendungsvariablen](#application-variables). Da geheime Anwendungsvariablen niemals in Front-Komponenten offengelegt werden, sollten API-Schlüssel und andere sensible Logik in der Logikfunktion verbleiben und nicht in der Front-Komponente.
|
||||
</Note>
|
||||
|
||||
### RestApiClient-Referenz
|
||||
### Aufrufen der Twenty-REST-API
|
||||
|
||||
Importiere `RestApiClient` aus `twenty-client-sdk/rest`. Er gehört zur gleichen Client-Familie wie `CoreApiClient` und `MetadataApiClient`, zielt jedoch auf die HTTP-Routen deiner App statt auf die GraphQL-API.
|
||||
Um Twenty-Datensätze aus einer Front-Komponente zu lesen oder zu schreiben, verwende `RestApiClient` aus `twenty-client-sdk/rest`. Er gehört zur gleichen Client-Familie wie `CoreApiClient` und `MetadataApiClient`, zielt jedoch auf die Twenty-REST-API (`/rest/...`) statt auf die GraphQL-API und liest deren Basis-URL aus `TWENTY_API_URL`.
|
||||
|
||||
| Methode | Beschreibung |
|
||||
| --------------------------------- | ---------------------------------------------------- |
|
||||
@@ -280,7 +285,7 @@ Die Basis-URL und das Token werden standardmäßig aus der Umgebung ermittelt. G
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ Geheime Variablen (`isSecret: true`) werden **nicht** in Front-Komponenten offen
|
||||
|
||||
Die folgenden Systemvariablen sind immer über `process.env` verfügbar:
|
||||
|
||||
| Variable | Beschreibung |
|
||||
| ------------------------- | ------------------------------------------------------------- |
|
||||
| `TWENTY_API_URL` | Basis-URL der Twenty API |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Kurzlebiges Token mit dem Geltungsbereich der Rolle Ihrer App |
|
||||
| Variable | Beschreibung |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `TWENTY_FUNCTIONS_URL` | Basis-URL, von der die HTTP-Logikfunktionen deiner App bereitgestellt werden (verwendet von `RestApiClient`) |
|
||||
| `TWENTY_API_URL` | Basis-URL der Twenty-Core-API |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Kurzlebiges Token mit dem Geltungsbereich der Rolle Ihrer App |
|
||||
|
||||
## Host-Kommunikations-API
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Um eine routenausgelöste Logikfunktion von einer (headless) Front-Komponente au
|
||||
* **cron**: Führt Ihre Funktion nach Zeitplan mithilfe eines CRON-Ausdrucks aus.
|
||||
* **databaseEvent**: Wird bei Lebenszyklusereignissen von Workspace-Objekten ausgeführt. Wenn die Ereignisoperation `updated` ist, können bestimmte zu überwachende Felder im Array `updatedFields` angegeben werden. Wenn das Array undefiniert oder leer ist, löst jede Aktualisierung die Funktion aus.
|
||||
> z. B. `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook**: Empfängt eingehende Webhooks von einem Drittanbieterdienst (Stripe, GitHub, Svix, …) an einem einzelnen, registrierungsbezogenen Endpunkt und ermittelt den Ziel-Arbeitsbereich aus der Nutzlast. Siehe [Server-Webhook-Trigger](#server-webhook-trigger).
|
||||
* **serverRoute**: Stellt eine einzelne, registrierungsbezogene HTTP-Route bereit. Eine **Resolver**-Funktion (deklariert mit `serverRouteTriggerSettings`) läuft im Owner-Workspace und gibt den Ziel-Workspace UND die Ziel-Logikfunktion zurück, an die weitergeleitet werden soll; die Plattform führt dann diese **Ziel**-Funktion aus und gibt deren Antwort zurück. Siehe [Server-Route-Trigger](#server-route-trigger).
|
||||
|
||||
<Note>
|
||||
Sie können eine Funktion auch manuell über die CLI ausführen:
|
||||
@@ -172,54 +172,108 @@ Aus Sicherheitsgründen sind Antwort-Header auf eine Allowlist beschränkt. Jede
|
||||
Der Statuscode muss ein gültiger HTTP-Statuscode sein (zwischen 100 und 599). Antwort-Header-Namen werden ohne Beachtung der Groß-/Kleinschreibung verglichen.
|
||||
</Note>
|
||||
|
||||
#### Server-Webhook-Trigger
|
||||
#### Server-Route-Trigger
|
||||
|
||||
`httpRouteTriggerSettings` stellt eine Funktion unter `/s/` bereit und ermittelt den Workspace aus dem Host der Anfrage — was funktioniert, wenn jeder Workspace seine eigene Domain hat. Drittanbieter hingegen liefern die Ereignisse jedes Mandanten an **eine** Webhook-URL. Verwende für diesen Fall `serverWebhookTriggerSettings`: Die Funktion ist über einen registrierungsbezogenen Endpunkt erreichbar und der Workspace wird aus der Nutzlast ermittelt.
|
||||
`httpRouteTriggerSettings` stellt eine Funktion unter `/s/` bereit und ermittelt den Workspace aus dem Host der Anfrage — was funktioniert, wenn jeder Workspace seine eigene Domain hat. Drittanbieter hingegen liefern die Ereignisse jedes Mandanten an **eine** URL. Für diesen Fall verwende `serverRouteTriggerSettings`.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
Der Trigger besteht aus zwei Teilen:
|
||||
|
||||
1. Eine **Resolver**-Logikfunktion – deklariert mit `serverRouteTriggerSettings` – läuft in deinem **Owner-Workspace** (dem Workspace, dem die Anwendungsregistrierung gehört). Sie untersucht die eingehende Anfrage und gibt `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` zurück und wählt dabei *sowohl* den Ziel-Workspace als auch die Zielfunktion aus. Der Resolver ist die einzige Autorisierungsstelle – die URL enthält nur den Bezeichner des Resolvers. **Dies ist der bevorzugte Ort, um Anfragesignaturen zu verifizieren**: Der Resolver läuft vor jeglicher Nebenwirkung, hat Zugriff auf den ursprünglichen `rawBody` und weitergeleitete Header und kann ablehnen, ohne jemals das Ziel zu berühren.
|
||||
2. Eine **Target**-Logikfunktion – eine reguläre, Workspace-spezifische Logikfunktion – läuft dann im aufgelösten Workspace mit dem Payload, der vom Resolver zurückgegeben wurde (oder dem ursprünglichen Anfrage-Payload, falls der Resolver ihn nicht transformiert hat). Ihr Rückgabewert wird zur HTTP-Antwort.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Die Funktion ist erreichbar unter:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
Beide Bezeichner sind die `universalIdentifier` aus deinem Manifest — der der Anwendungsregistrierung und dieser Logikfunktion. Registriere diese URL beim Provider.
|
||||
Der Endpoint ist erreichbar unter:
|
||||
|
||||
**Workspace-Auflösung.** Da ein Endpunkt jeden Workspace bedient, muss deine Integration die Ziel-`workspaceId` irgendwo in der Zustellung platzieren, und `workspaceIdResolver.{ source, path }` teilt der Plattform mit, wo sie diese auslesen soll:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| Feld | Werte | Notizen |
|
||||
| -------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `quelle` | `body` \| `query` \| `header` | `body` liest das geparste JSON. `query` ist am universellsten — du kontrollierst in der Regel die Callback-URL, die du registrierst, also hänge `?twentyWorkspaceId=…` an. |
|
||||
| `pfad` | Dot-Pfad, z. B. `metadata.twentyWorkspaceId` | Beschränkt auf alphanumerische / `_`- / `-`-Segmente; Prototype-Schlüssel werden abgelehnt. |
|
||||
Der Bezeichner ist der `universalIdentifier` des Resolvers aus deinem Manifest. Registriere diese URL beim Provider.
|
||||
|
||||
Der aufgelöste Wert muss eine gültige Workspace-UUID sein **und** deine App muss in diesem Workspace installiert sein, andernfalls wird die Anfrage abgelehnt, bevor die Funktion ausgeführt wird.
|
||||
**Resolver-Vertrag.** Der `LogicFunctionConfig`-Typ des SDK erzwingt dies zur Compile-Zeit: Sobald du `serverRouteTriggerSettings` setzt, ist dein Handler darauf beschränkt, `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (oder ein `Promise` davon) zurückzugeben. Die `workspaceId` muss auf einen Workspace verweisen, in dem die Zielfunktion installiert ist, andernfalls wird die Anfrage mit `404` abgelehnt.
|
||||
|
||||
| Feld | Typ | Notizen |
|
||||
| ---------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | UUID des Workspaces, in dem das Ziel ausgeführt wird. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` der Logikfunktion, die in diesem Workspace aufgerufen werden soll. |
|
||||
| `payload` | `object` (optional) | Falls gesetzt, ersetzt er den Request-Body, der an das Target gesendet wird. |
|
||||
|
||||
<Warning>
|
||||
**Die Signaturüberprüfung liegt in deiner Verantwortung.** Die Plattform überprüft für diesen Trigger keine Webhook-Signaturen — sie ermittelt nur den Workspace und führt deine Funktion aus. Dein Handler muss die Signatur selbst mithilfe von `event.rawBody` und den in `forwardedRequestHeaders` aufgeführten Headern überprüfen und sie mit einem als Server-/Anwendungsvariable gespeicherten Geheimnis vergleichen. Überprüfe immer **vor** jeglicher Nebenwirkung und verwende einen Vergleich in konstanter Zeit.
|
||||
**Die Signaturverifizierung liegt in deiner Verantwortung – verifiziere im Resolver.** Die Plattform verifiziert Anfragesignaturen nicht. Der Resolver ist der empfohlene Ort dafür: Er läuft zuerst, mit Zugriff auf `event.rawBody` und die Header, die du in `forwardedRequestHeaders` aufgeführt hast, und ein geworfener Fehler (oder eine nicht passende `workspaceId`) stoppt das Dispatching, bevor das Target aufgerufen wird. Wenn du die Verifizierung stattdessen in das Target verschiebst, muss das Target darauf achten, `rawBody` und Header nicht zu verlieren – d. h. der Resolver darf keinen `payload` zurückgeben. Überprüfe immer **vor** jeglicher Nebenwirkung und verwende einen Vergleich in konstanter Zeit.
|
||||
</Warning>
|
||||
|
||||
Die meisten Provider signieren mit HMAC-SHA256; die Teile, die sich unterscheiden, sind der Header-Name, die Digest-Codierung und der signierte Payload-String. Einige Beispiele:
|
||||
Für Anfragesignaturen signieren die meisten Provider mit HMAC-SHA256; die Teile, die sich unterscheiden, sind der Header-Name, die Digest-Codierung und der signierte Payload-String. Einige Beispiele:
|
||||
|
||||
| Anbieter | Weiterzuleitende Header | Signierte Zeichenkette | Digest |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | --------------------------------------------------------- |
|
||||
@@ -229,31 +283,10 @@ Die meisten Provider signieren mit HMAC-SHA256; die Teile, die sich unterscheide
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (mit Präfix `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
Das obige Resolver-Beispiel zeigt bereits den GitHub-HMAC-SHA256-Flow – passe den Header-Namen, die Digest-Codierung und den signierten Payload-String entsprechend dem Provider an, den du integrierst.
|
||||
|
||||
<Note>
|
||||
Die Funktion läuft **synchron** und dein Rückgabewert wird zur HTTP-Antwort, sodass Provider deinen Statuscode sehen und bei Nicht-2xx erneut versuchen können. Halte Handler schnell — einige Provider (z. B. Slack) laufen nach wenigen Sekunden in ein Timeout. Da die Funktion ausgeführt wird, bevor die Signatur geprüft wird, solltest du diesen Endpunkt an deinem Edge mit Ratenbegrenzung schützen.
|
||||
Das Target läuft **synchron** und sein Rückgabewert wird zur HTTP-Antwort, sodass Aufrufer deinen Statuscode sehen und bei Nicht-2xx erneut versuchen können. Halte beide Handler schnell – einige Provider (z. B. Slack) laufen nach wenigen Sekunden in ein Timeout. Da der Resolver als öffentlicher Endpoint erreichbar ist, schütze ihn mit Rate-Limiting an deinem Edge.
|
||||
</Note>
|
||||
|
||||
#### Datenbank-Event-Trigger-Payload
|
||||
@@ -615,12 +648,12 @@ console.log(uploadedFile);
|
||||
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
|
||||
```
|
||||
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| ---------------------------------- | -------- | --------------------------------------------------------------------- |
|
||||
| `fileBuffer` | `Buffer` | Der Rohinhalt der Datei |
|
||||
| `filename` | `string` | Der Name der Datei (wird für Speicherung und Anzeige verwendet) |
|
||||
| `contentType` | `string` | MIME-Typ (standardmäßig `application/octet-stream`, wenn weggelassen) |
|
||||
| `fieldMetadataUniversalIdentifier` | `string` | Der `universalIdentifier` des Dateityp-Felds in Ihrem Objekt |
|
||||
| Parameter | Typ | Beschreibung |
|
||||
| ---------------------------------- | -------------- | --------------------------------------------------------------------- |
|
||||
| `fileBuffer` | `Buffer` | Der Rohinhalt der Datei |
|
||||
| `filename` | `string` | Der Name der Datei (wird für Speicherung und Anzeige verwendet) |
|
||||
| `contentType` | `Zeichenkette` | MIME-Typ (standardmäßig `application/octet-stream`, wenn weggelassen) |
|
||||
| `fieldMetadataUniversalIdentifier` | `Zeichenkette` | Der `universalIdentifier` des Dateityp-Felds in Ihrem Objekt |
|
||||
|
||||
Hauptpunkte:
|
||||
* Sie verwendet den `universalIdentifier` des Feldes (nicht dessen arbeitsbereichsspezifische ID), sodass Ihr Upload-Code in jedem Arbeitsbereich funktioniert, in dem Ihre App installiert ist.
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
Los componentes de front se ejecutan en el navegador dentro de un Web Worker aislado (sandboxed), mientras que las [funciones de lógica](/l/es/developers/extend/apps/logic/logic-functions) se ejecutan en el servidor. No hay una llamada directa en el mismo proceso entre ambos; en su lugar, un componente de front accede a una función de lógica a través de HTTP.
|
||||
|
||||
Una función de lógica declarada con `httpRouteTriggerSettings` se expone bajo el endpoint `/s/` en `${TWENTY_API_URL}/s\<path>`. Tu componente de front llama a esa ruta con el `RestApiClient` de `twenty-client-sdk/rest`, que se autentica con el `TWENTY_APP_ACCESS_TOKEN` que Twenty inyecta en el worker.
|
||||
Una función de lógica declarada con `httpRouteTriggerSettings` es accesible por HTTP en su ruta. Twenty inyecta en el worker la URL base desde la que se sirven tus funciones como `TWENTY_FUNCTIONS_URL`, junto con el `TWENTY_APP_ACCESS_TOKEN` que autentica la llamada. Todavía no hay un cliente SDK dedicado para invocar tus propias funciones, así que llámalas con un simple `fetch`:
|
||||
|
||||
El `RestApiClient` está diseñado precisamente para esto. Lee `TWENTY_API_URL` y `TWENTY_APP_ACCESS_TOKEN` del entorno del worker, añade la cabecera `Authorization: Bearer`, serializa y analiza JSON, y lanza un `RestApiClientError` cuando faltan el token o la URL o la respuesta no es 2xx, para que no tengas que volver a implementar ese código repetitivo en cada componente.
|
||||
> **En Twenty Cloud, las funciones de lógica activadas por HTTP se sirven en un dominio dedicado por espacio de trabajo** en `https://\<your-workspace-subdomain>.twenty.com\<path>` — esto es exactamente a lo que se resuelve `TWENTY_FUNCTIONS_URL`. Para clientes externos, copia la URL exacta desde la configuración de **HTTP trigger** de la función o desde la pestaña **Settings** de la aplicación.
|
||||
|
||||
<Warning>
|
||||
La ruta heredada de la función `/s/` está **obsoleta** y será **desactivada el 2026-07-24**. En su lugar, utiliza `TWENTY_FUNCTIONS_URL` (arriba) y migra cualquier URL de `/s/` codificada de forma fija antes de esa fecha. La ruta `/s/` sigue disponible para autoalojamiento.
|
||||
</Warning>
|
||||
|
||||
Un componente de front sin interfaz (headless) puede ejecutar la llamada al montar mediante el componente `Command` y luego desmontarse automáticamente:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
La ruta que se pasa al cliente es la ruta pública de la ruta: el `httpRouteTriggerSettings.path` de la función lógica con el prefijo `/s`. Mantén `isAuthRequired: true`; el cliente proporciona el token de acceso de la aplicación que Twenty emite para tu componente:
|
||||
La ruta añadida a `TWENTY_FUNCTIONS_URL` es el `httpRouteTriggerSettings.path` de la función lógica. Mantén `isAuthRequired: true`; el `TWENTY_APP_ACCESS_TOKEN` que Twenty crea para tu componente autentica la solicitud:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` y `TWENTY_APP_ACCESS_TOKEN` se inyectan automáticamente; consulta [Variables de la aplicación](#application-variables). Dado que las variables de aplicación secretas nunca se exponen a los componentes de front, mantén las claves de API y otra lógica confidencial en la función de lógica, no en el componente de front.
|
||||
`TWENTY_FUNCTIONS_URL` y `TWENTY_APP_ACCESS_TOKEN` se inyectan automáticamente; consulta [Variables de la aplicación](#application-variables). Dado que las variables de aplicación secretas nunca se exponen a los componentes de front, mantén las claves de API y otra lógica confidencial en la función de lógica, no en el componente de front.
|
||||
</Note>
|
||||
|
||||
### Referencia de `RestApiClient`
|
||||
### Llamar a la API REST de Twenty
|
||||
|
||||
Importa `RestApiClient` desde `twenty-client-sdk/rest`. Pertenece a la misma familia de clientes que `CoreApiClient` y `MetadataApiClient`, pero se dirige a las rutas HTTP de tu aplicación en lugar de a la API de GraphQL.
|
||||
Para leer o escribir registros de Twenty desde un componente de interfaz, utiliza `RestApiClient` de `twenty-client-sdk/rest`. Pertenece a la misma familia de clientes que `CoreApiClient` y `MetadataApiClient`, pero se dirige a la API REST de Twenty (`/rest/...`) en lugar de a la API de GraphQL, leyendo su URL base de `TWENTY_API_URL`.
|
||||
|
||||
| Método | Descripción |
|
||||
| --------------------------------- | -------------------------------------------- |
|
||||
@@ -280,7 +285,7 @@ La URL base y el token se resuelven desde el entorno de forma predeterminada. Pa
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ Las variables secretas (`isSecret: true`) **no** se exponen a los componentes de
|
||||
|
||||
Las siguientes variables de sistema siempre están disponibles a través de `process.env`:
|
||||
|
||||
| Variable | Descripción |
|
||||
| ------------------------- | -------------------------------------------------------- |
|
||||
| `TWENTY_API_URL` | URL base de la API de Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Token de corta duración limitado al rol de tu aplicación |
|
||||
| Variable | Descripción |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `TWENTY_FUNCTIONS_URL` | URL base desde la que se sirven las funciones de lógica HTTP de tu aplicación (utilizada por `RestApiClient`) |
|
||||
| `TWENTY_API_URL` | URL base de la API principal de Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Token de corta duración limitado al rol de tu aplicación |
|
||||
|
||||
## API de comunicación con el host
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Para invocar una función de lógica activada por una ruta desde un componente d
|
||||
* **cron**: Ejecuta tu función en un horario usando una expresión CRON.
|
||||
* **databaseEvent**: Se ejecuta en eventos del ciclo de vida de objetos del espacio de trabajo. Cuando la operación del evento es `updated`, se pueden especificar campos específicos que se deben escuchar en la matriz `updatedFields`. Si se deja sin definir o vacío, cualquier actualización activará la función.
|
||||
> p. ej. `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook**: Recibe webhooks entrantes de un servicio de terceros (Stripe, GitHub, Svix, …) en un único endpoint con alcance de registro y resuelve el espacio de trabajo de destino a partir del payload. Consulta [Disparador de webhook del servidor](#server-webhook-trigger).
|
||||
* **serverRoute**: expone una única ruta HTTP con ámbito de registro. Una función de **resolver** (declarada con `serverRouteTriggerSettings`) se ejecuta en el espacio de trabajo propietario y devuelve el espacio de trabajo de destino Y la función de lógica de destino a la que se debe enviar; la plataforma luego ejecuta esa función de **destino** y devuelve su respuesta. Consulta [Disparador de ruta de servidor](#server-route-trigger).
|
||||
|
||||
<Note>
|
||||
También puedes ejecutar manualmente una función usando la CLI:
|
||||
@@ -173,54 +173,108 @@ Por razones de seguridad, los encabezados de la respuesta están restringidos a
|
||||
El código de estado debe ser un código de estado HTTP válido (entre 100 y 599). Los nombres de los encabezados de respuesta se comparan sin distinguir mayúsculas de minúsculas.
|
||||
</Note>
|
||||
|
||||
#### Disparador de webhook del servidor
|
||||
#### Disparador de ruta de servidor
|
||||
|
||||
`httpRouteTriggerSettings` expone una función bajo `/s/` y resuelve el espacio de trabajo a partir del host de la solicitud — lo cual funciona cuando cada espacio de trabajo tiene su propio dominio. Los proveedores de terceros, sin embargo, entregan los eventos de cada inquilino a **una** URL de webhook. Para ese caso, usa `serverWebhookTriggerSettings`: la función es accesible en un endpoint con alcance de registro y el espacio de trabajo se resuelve a partir del payload.
|
||||
`httpRouteTriggerSettings` expone una función bajo `/s/` y resuelve el espacio de trabajo a partir del host de la solicitud — lo cual funciona cuando cada espacio de trabajo tiene su propio dominio. Los proveedores de terceros, sin embargo, entregan los eventos de cada inquilino a **una** URL. Para ese caso, usa `serverRouteTriggerSettings`.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
El disparador tiene dos partes:
|
||||
|
||||
1. Una función de lógica de **resolver** — declarada con `serverRouteTriggerSettings` — se ejecuta en tu **espacio de trabajo propietario** (el espacio de trabajo que es propietario del registro de la aplicación). Inspecciona la solicitud entrante y devuelve `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`, eligiendo *tanto* el espacio de trabajo de destino como la función de destino. El resolver es el único punto de autorización: la URL solo lleva el identificador del resolver. **Este es el lugar preferido para verificar las firmas de las solicitudes**: el resolver se ejecuta antes de cualquier efecto secundario, tiene acceso al `rawBody` original y a los encabezados reenviados, y puede rechazar sin tocar nunca el destino.
|
||||
2. Luego, una función de lógica de **destino** — una función de lógica normal por espacio de trabajo — se ejecuta en el espacio de trabajo resuelto con el payload devuelto por el resolver (o el payload original de la solicitud si el resolver no lo transformó). Su valor de retorno se convierte en la respuesta HTTP.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
La función es accesible en:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
Ambos identificadores son los `universalIdentifier`s de tu manifiesto: el del registro de la aplicación y el de esta función lógica. Registra esa URL con el proveedor.
|
||||
El endpoint es accesible en:
|
||||
|
||||
**Resolución de espacio de trabajo.** Como un endpoint atiende a cada espacio de trabajo, tu integración debe colocar el `workspaceId` de destino en algún lugar de la entrega, y `workspaceIdResolver.{ source, path }` le indica a la plataforma dónde leerlo:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| Campo | Valores | Notas |
|
||||
| -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `source` | `body` \| `query` \| `header` | `body` lee el JSON parseado. `query` es el más universal: normalmente controlas la URL de callback que registras, así que añade `?twentyWorkspaceId=…`. |
|
||||
| `path` | ruta con puntos, p. ej. `metadata.twentyWorkspaceId` | Restringido a segmentos alfanuméricos / `_` / `-`; las claves de prototipo se rechazan. |
|
||||
El identificador es el `universalIdentifier` del resolver de tu manifiesto. Registra esa URL con el proveedor.
|
||||
|
||||
El valor resuelto debe ser un UUID de espacio de trabajo válido **y** tu aplicación debe estar instalada en ese espacio de trabajo; de lo contrario, la solicitud se rechaza antes de que la función se ejecute.
|
||||
**Contrato del resolver.** El tipo `LogicFunctionConfig` del SDK aplica esto en tiempo de compilación: tan pronto como configuras `serverRouteTriggerSettings`, tu handler se ve obligado a devolver `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (o un `Promise` de esto). El `workspaceId` debe ser un espacio de trabajo donde la función de destino esté instalada; de lo contrario, la solicitud se rechaza con `404`.
|
||||
|
||||
| Campo | Tipo | Notas |
|
||||
| ---------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | UUID del espacio de trabajo donde se ejecutará el destino. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` de la función de lógica que se va a invocar en ese espacio de trabajo. |
|
||||
| `payload` | `object` (opcional) | Si se establece, reemplaza el cuerpo de la solicitud enviado al destino. |
|
||||
|
||||
<Warning>
|
||||
**La verificación de la firma es tu responsabilidad.** La plataforma no verifica las firmas de webhook para este disparador: solo resuelve el espacio de trabajo y ejecuta tu función. Tu manejador debe verificar la firma por sí mismo usando `event.rawBody` y los encabezados que incluiste en `forwardedRequestHeaders`, comparando contra un secreto almacenado como variable de servidor/aplicación. Verifica siempre **antes** de cualquier efecto secundario y usa una comparación en tiempo constante.
|
||||
**La verificación de la firma es tu responsabilidad: verifica en el resolver.** La plataforma no verifica las firmas de las solicitudes. El resolver es el lugar recomendado para hacerlo: se ejecuta primero, con acceso a `event.rawBody` y a los encabezados que incluiste en `forwardedRequestHeaders`, y un error lanzado (o cualquier `workspaceId` que no coincida) detiene el despacho antes de que se invoque el destino. Si en cambio trasladas la verificación al destino, el destino debe tener cuidado de no perder `rawBody` y los encabezados; es decir, el resolver no debe devolver un `payload`. Verifica siempre **antes** de cualquier efecto secundario y usa una comparación en tiempo constante.
|
||||
</Warning>
|
||||
|
||||
La mayoría de los proveedores firman con HMAC-SHA256; las partes que difieren son el nombre del encabezado, la codificación del digest y la cadena firmada del payload. Algunos ejemplos:
|
||||
Para las firmas de solicitudes, la mayoría de los proveedores firman con HMAC-SHA256; las partes que difieren son el nombre del encabezado, la codificación del digest y la cadena firmada del payload. Algunos ejemplos:
|
||||
|
||||
| Proveedor | Encabezados a reenviar | Cadena firmada | Digest |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | --------------------------------------------------------------- |
|
||||
@@ -230,31 +284,10 @@ La mayoría de los proveedores firman con HMAC-SHA256; las partes que difieren s
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (con el prefijo `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
El ejemplo de resolver anterior ya muestra el flujo HMAC-SHA256 de GitHub; adapta el nombre del encabezado, la codificación del digest y la cadena firmada del payload según el proveedor con el que te estés integrando.
|
||||
|
||||
<Note>
|
||||
La función se ejecuta **sincrónicamente** y el valor que devuelves se convierte en la respuesta HTTP, por lo que los proveedores ven tu código de estado y pueden reintentar en caso de que no sea 2xx. Mantén los manejadores rápidos: algunos proveedores (p. ej. Slack) agotan el tiempo de espera en pocos segundos. Como la función se ejecuta antes de que se compruebe la firma, protege este endpoint con limitación de tasa en tu edge.
|
||||
El destino se ejecuta de forma **sincrónica** y su valor devuelto se convierte en la respuesta HTTP, por lo que quienes llaman ven tu código de estado y pueden reintentar cuando no sea 2xx. Mantén ambos handlers rápidos: algunos proveedores (p. ej. Slack) agotan el tiempo de espera en pocos segundos. Como el resolver es accesible como un endpoint público, protégelo con limitación de tasa en el edge.
|
||||
</Note>
|
||||
|
||||
#### Payload del disparador de evento de base de datos
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
Les composants front s’exécutent côté navigateur dans un Web Worker isolé (sandboxed), tandis que les [fonctions logiques](/l/fr/developers/extend/apps/logic/logic-functions) s’exécutent côté serveur. Il n’y a aucun appel intra-processus direct entre les deux — à la place, un composant front appelle une fonction logique via HTTP.
|
||||
|
||||
Une fonction logique déclarée avec `httpRouteTriggerSettings` est exposée sous le point de terminaison `/s/` à `${TWENTY_API_URL}/s\<path>`. Votre composant front appelle cette route avec le `RestApiClient` de `twenty-client-sdk/rest`, qui s’authentifie avec le `TWENTY_APP_ACCESS_TOKEN` que Twenty injecte dans le worker.
|
||||
Une fonction logique déclarée avec `httpRouteTriggerSettings` est accessible via HTTP à son chemin de route. Twenty injecte dans le worker l’URL de base à partir de laquelle vos fonctions sont servies sous la forme de `TWENTY_FUNCTIONS_URL`, ainsi que le `TWENTY_APP_ACCESS_TOKEN` qui authentifie l’appel. Il n’existe pas encore de client SDK dédié pour invoquer vos propres fonctions, donc appelez-les avec un simple `fetch` :
|
||||
|
||||
Le `RestApiClient` est conçu exactement pour cela. Il lit `TWENTY_API_URL` et `TWENTY_APP_ACCESS_TOKEN` depuis l’environnement du worker, ajoute l’en-tête `Authorization: Bearer`, sérialise et analyse le JSON, et déclenche une `RestApiClientError` lorsque le jeton ou l’URL est manquant ou que la réponse n’est pas de type 2xx — afin que vous n’ayez pas à réimplémenter ce boilerplate dans chaque composant.
|
||||
> **Sur Twenty Cloud, les fonctions logiques déclenchées par HTTP sont servies sur un domaine dédié par espace de travail** à l’adresse `https://\<your-workspace-subdomain>.twenty.com\<path>` — c’est exactement ce à quoi `TWENTY_FUNCTIONS_URL` correspond. Pour les appelants externes, copiez l’URL exacte à partir des paramètres **HTTP trigger** de la fonction ou de l’onglet **Settings** de l’application.
|
||||
|
||||
<Warning>
|
||||
L’ancienne route de fonction `/s/` est **obsolète** et sera **désactivée le 2026-07-24**. Utilisez plutôt `TWENTY_FUNCTIONS_URL` (ci-dessus), et migrez toutes les URL `/s/` en dur avant cette date. La route `/s/` reste disponible pour l’auto-hébergement.
|
||||
</Warning>
|
||||
|
||||
Un composant front sans interface (headless) peut effectuer l’appel au montage via le composant `Command`, puis se démonter automatiquement :
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
Le chemin transmis au client est le chemin public de la route — la propriété `httpRouteTriggerSettings.path` de la fonction logique, préfixée par `/s`. Conservez `isAuthRequired: true` ; le client fournit le jeton d’accès de l’application que Twenty émet pour votre composant :
|
||||
Le chemin ajouté à `TWENTY_FUNCTIONS_URL` correspond au `httpRouteTriggerSettings.path` de la fonction logique. Conservez `isAuthRequired: true` ; le `TWENTY_APP_ACCESS_TOKEN` que Twenty génère pour votre composant authentifie la requête :
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` et `TWENTY_APP_ACCESS_TOKEN` sont injectés automatiquement — voir [Variables d’application](#application-variables). Comme les variables d’application secrètes ne sont jamais exposées aux composants front, conservez les clés d’API et les autres éléments sensibles dans la fonction logique, et non dans le composant front.
|
||||
`TWENTY_FUNCTIONS_URL` et `TWENTY_APP_ACCESS_TOKEN` sont injectés automatiquement — voir [Variables d’application](#application-variables). Comme les variables d’application secrètes ne sont jamais exposées aux composants front, conservez les clés d’API et les autres éléments sensibles dans la fonction logique, et non dans le composant front.
|
||||
</Note>
|
||||
|
||||
### Référence de `RestApiClient`
|
||||
### Appeler l’API REST de Twenty
|
||||
|
||||
Importez `RestApiClient` depuis `twenty-client-sdk/rest`. Il appartient à la même famille de clients que `CoreApiClient` et `MetadataApiClient`, mais cible les routes HTTP de votre application au lieu de l’API GraphQL.
|
||||
Pour lire ou écrire des enregistrements Twenty depuis un composant frontal, utilisez `RestApiClient` depuis `twenty-client-sdk/rest`. Il appartient à la même famille de clients que `CoreApiClient` et `MetadataApiClient`, mais cible l’API REST de Twenty (`/rest/...`) plutôt que l’API GraphQL, en lisant son URL de base depuis `TWENTY_API_URL`.
|
||||
|
||||
| Méthode | Description |
|
||||
| --------------------------------- | ---------------------------------------------------- |
|
||||
@@ -280,7 +285,7 @@ Par défaut, l’URL de base et le jeton sont résolus à partir de l’environn
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ Les variables secrètes (`isSecret: true`) ne sont **pas** exposées aux composa
|
||||
|
||||
Les variables système suivantes sont toujours disponibles via `process.env` :
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | --------------------------------------------------------- |
|
||||
| `TWENTY_API_URL` | URL de base de l’API Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Jeton de courte durée limité au rôle de votre application |
|
||||
| Variable | Description |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TWENTY_FUNCTIONS_URL` | URL de base à partir de laquelle les fonctions logiques HTTP de votre application sont servies (utilisée par `RestApiClient`) |
|
||||
| `TWENTY_API_URL` | URL de base de l’API principale de Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Jeton de courte durée limité au rôle de votre application |
|
||||
|
||||
## API de communication de l'hôte
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Pour appeler une fonction logique déclenchée par une route depuis un composant
|
||||
* **cron** : Exécute votre fonction selon une planification à l’aide d’une expression CRON.
|
||||
* **databaseEvent**: S'exécute lors des événements du cycle de vie des objets de l'espace de travail. Lorsque l'opération de l'événement est `updated`, des champs spécifiques à surveiller peuvent être spécifiés dans le tableau `updatedFields`. S'il est laissé indéfini ou vide, toute mise à jour déclenchera la fonction.
|
||||
> p. ex. `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook** : reçoit des webhooks entrants d’un service tiers (Stripe, GitHub, Svix, …) sur un endpoint unique propre à l’inscription et détermine l’espace de travail cible à partir de la charge utile. Voir [déclencheur de webhook serveur](#server-webhook-trigger).
|
||||
* **serverRoute** : expose une seule route HTTP à portée d’enregistrement. Une fonction de **résolution** (déclarée avec `serverRouteTriggerSettings`) s’exécute dans l’**espace de travail propriétaire** et renvoie l’espace de travail cible ET la fonction logique cible vers laquelle acheminer la requête ; la plateforme exécute ensuite cette fonction **cible** et renvoie sa réponse. Voir [déclencheur de route serveur](#server-route-trigger).
|
||||
|
||||
<Note>
|
||||
Vous pouvez également exécuter manuellement une fonction à l'aide de la CLI :
|
||||
@@ -173,54 +173,108 @@ Pour des raisons de sécurité, les en-têtes de réponse sont restreints à une
|
||||
Le code d’état doit être un code d’état HTTP valide (compris entre 100 et 599). Les noms des en-têtes de réponse sont comparés sans tenir compte de la casse.
|
||||
</Note>
|
||||
|
||||
#### Déclencheur de webhook côté serveur
|
||||
#### Déclencheur de route serveur
|
||||
|
||||
`httpRouteTriggerSettings` expose une fonction sous `/s/` et résout l’espace de travail à partir de l’hôte de la requête — ce qui fonctionne lorsque chaque espace de travail a son propre domaine. Les fournisseurs tiers, en revanche, envoient les événements de chaque locataire vers **une** URL de webhook. Dans ce cas, utilisez `serverWebhookTriggerSettings` : la fonction est accessible à un endpoint dont la portée est l’enregistrement, et l’espace de travail est résolu à partir de la charge utile.
|
||||
`httpRouteTriggerSettings` expose une fonction sous `/s/` et résout l’espace de travail à partir de l’hôte de la requête — ce qui fonctionne lorsque chaque espace de travail a son propre domaine. Les fournisseurs tiers, en revanche, envoient les événements de chaque locataire vers **une** URL. Dans ce cas, utilisez `serverRouteTriggerSettings`.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
Le déclencheur comporte deux parties :
|
||||
|
||||
1. Une fonction de logique de **résolution** — déclarée avec `serverRouteTriggerSettings` — s’exécute dans votre **espace de travail propriétaire** (l’espace de travail qui possède l’enregistrement de l’application). Elle inspecte la requête entrante et retourne `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`, en choisissant *à la fois* l’espace de travail cible et la fonction cible. Le résolveur est le point d’autorisation unique — l’URL transporte uniquement l’identifiant du résolveur. **C’est l’endroit privilégié pour vérifier les signatures des requêtes** : le résolveur s’exécute avant tout effet de bord, a accès au `rawBody` original et aux en-têtes transmis, et peut rejeter la requête sans jamais toucher la cible.
|
||||
2. Une fonction de logique **cible** — une fonction de logique classique par espace de travail — s’exécute ensuite dans l’espace de travail résolu avec la charge utile renvoyée par le résolveur (ou la charge utile originale de la requête si le résolveur ne l’a pas transformée). Sa valeur de retour devient la réponse HTTP.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
La fonction est accessible à l’adresse :
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
Les deux identifiants sont les `universalIdentifier`s de votre manifeste — celui de l’enregistrement de l’application et celui de cette fonction logique. Enregistrez cette URL auprès du fournisseur.
|
||||
Le point de terminaison est accessible à l’adresse :
|
||||
|
||||
**Résolution de l’espace de travail.** Étant donné qu’un seul endpoint dessert tous les espaces de travail, votre intégration doit placer l’identifiant cible `workspaceId` quelque part dans la requête, et `workspaceIdResolver.{ source, path }` indique à la plateforme où le lire :
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| Champ | Valeurs | Notes |
|
||||
| -------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `source` | `body` \| `query` \| `header` | `body` lit le JSON analysé. `query` est le plus universel — vous contrôlez généralement l’URL de rappel que vous enregistrez, donc ajoutez `?twentyWorkspaceId=…`. |
|
||||
| `chemin` | chemin en notation par points, par ex. `metadata.twentyWorkspaceId` | Limité à des segments alphanumériques / `_` / `-` ; les clés du prototype sont rejetées. |
|
||||
L’identifiant est le `universalIdentifier` du résolveur issu de votre manifeste. Enregistrez cette URL auprès du fournisseur.
|
||||
|
||||
La valeur résolue doit être un UUID d’espace de travail valide **et** votre application doit être installée dans cet espace de travail, sinon la requête est rejetée avant l’exécution de la fonction.
|
||||
**Contrat du résolveur.** Le type `LogicFunctionConfig` du SDK impose cela à la compilation : dès que vous définissez `serverRouteTriggerSettings`, votre gestionnaire est contraint de retourner `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (ou une `Promise` de cette valeur). Le `workspaceId` doit être celui d’un espace de travail où la fonction cible est installée, sinon la requête est rejetée avec un `404`.
|
||||
|
||||
| Champ | Type | Notes |
|
||||
| ---------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | UUID de l’espace de travail dans lequel la cible sera exécutée. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` de la fonction de logique à invoquer dans cet espace de travail. |
|
||||
| `payload` | `object` (facultatif) | S’il est défini, il remplace le corps de la requête envoyé à la cible. |
|
||||
|
||||
<Warning>
|
||||
**La vérification de la signature est de votre responsabilité.** La plateforme ne vérifie pas les signatures de webhook pour ce déclencheur — elle se contente de résoudre l’espace de travail et d’exécuter votre fonction. Votre gestionnaire doit vérifier lui-même la signature en utilisant `event.rawBody` et les en-têtes que vous avez listés dans `forwardedRequestHeaders`, en les comparant à un secret stocké en tant que variable serveur/application. Vérifiez toujours **avant** tout effet de bord et utilisez une comparaison en temps constant.
|
||||
**La vérification de la signature est de votre responsabilité — effectuez-la dans le résolveur.** La plateforme ne vérifie pas les signatures des requêtes. Le résolveur est l’endroit recommandé pour le faire : il s’exécute en premier, avec accès à `event.rawBody` et aux en-têtes que vous avez listés dans `forwardedRequestHeaders`, et une erreur levée (ou tout `workspaceId` ne correspondant pas) interrompt la distribution avant que la cible ne soit invoquée. Si, à la place, vous repoussez la vérification vers la cible, celle-ci doit faire attention à ne pas perdre `rawBody` et les en-têtes — c’est-à-dire que le résolveur ne doit pas retourner de `payload`. Vérifiez toujours **avant** tout effet de bord et utilisez une comparaison en temps constant.
|
||||
</Warning>
|
||||
|
||||
La plupart des fournisseurs signent avec HMAC-SHA256 ; les éléments qui diffèrent sont le nom de l’en-tête, l’encodage de l’empreinte et la chaîne de la charge utile signée. Quelques exemples :
|
||||
Pour les signatures de requêtes, la plupart des fournisseurs signent avec HMAC-SHA256 ; les éléments qui diffèrent sont le nom de l’en-tête, l’encodage de l’empreinte et la chaîne de la charge utile signée. Quelques exemples :
|
||||
|
||||
| Fournisseur | En-têtes à transférer | Chaîne signée | Empreinte |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | -------------------------------------------------------------- |
|
||||
@@ -230,31 +284,10 @@ La plupart des fournisseurs signent avec HMAC-SHA256 ; les éléments qui diffè
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hexadécimal (préfixé par `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
L’exemple de résolveur ci-dessus montre déjà le flux GitHub HMAC-SHA256 — adaptez le nom de l’en-tête, l’encodage de l’empreinte et la chaîne de la charge utile signée en fonction du fournisseur avec lequel vous vous intégrez.
|
||||
|
||||
<Note>
|
||||
La fonction s’exécute **de manière synchrone** et la valeur que vous retournez devient la réponse HTTP, de sorte que les fournisseurs voient votre code d’état et peuvent réessayer en cas de réponse non-2xx. Gardez les gestionnaires rapides — certains fournisseurs (par ex. Slack) expirent au bout de quelques secondes. Étant donné que la fonction s’exécute avant que la signature ne soit vérifiée, protégez cet endpoint avec une limitation de débit à votre périphérie.
|
||||
La cible s’exécute **de manière synchrone** et sa valeur de retour devient la réponse HTTP, de sorte que les appelants voient votre code d’état et peuvent réessayer en cas de réponse non-2xx. Gardez les deux gestionnaires rapides — certains fournisseurs (par ex. Slack) expirent au bout de quelques secondes. Comme le résolveur est accessible en tant que point de terminaison public, protégez-le avec une limitation de débit à votre périphérie.
|
||||
</Note>
|
||||
|
||||
#### Charge utile du déclencheur d'événement de base de données
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
I componenti front vengono eseguiti lato browser in un Web Worker in sandbox, mentre le [funzioni logiche](/l/it/developers/extend/apps/logic/logic-functions) vengono eseguite lato server. Non esiste una chiamata diretta in-process tra i due; invece, un front component chiama una funzione logica tramite HTTP.
|
||||
|
||||
Una funzione logica dichiarata con `httpRouteTriggerSettings` è esposta sotto l’endpoint `/s/` su `${TWENTY_API_URL}/s\<path>`. Il tuo front component chiama quella route con il `RestApiClient` da `twenty-client-sdk/rest`, che si autentica con il `TWENTY_APP_ACCESS_TOKEN` che Twenty inserisce nel worker.
|
||||
Una funzione logica dichiarata con `httpRouteTriggerSettings` è raggiungibile tramite HTTP al relativo percorso della route. Twenty inietta nel worker l'URL di base da cui vengono servite le tue funzioni come `TWENTY_FUNCTIONS_URL`, insieme al `TWENTY_APP_ACCESS_TOKEN` che autentica la chiamata. Non esiste ancora un client SDK dedicato per invocare le proprie funzioni, quindi chiamale con un semplice `fetch`:
|
||||
|
||||
Il `RestApiClient` è stato creato proprio per questo. Legge `TWENTY_API_URL` e `TWENTY_APP_ACCESS_TOKEN` dall'ambiente del worker, aggiunge l'header `Authorization: Bearer`, serializza e analizza il JSON e genera un `RestApiClientError` quando il token o l'URL mancano o la risposta non è 2xx — così non devi reimplementare quel boilerplate in ogni componente.
|
||||
> **Su Twenty Cloud, le funzioni logiche attivate tramite HTTP sono servite su un dominio dedicato per ogni workspace** in `https://\<your-workspace-subdomain>.twenty.com\<path>` — questo è esattamente ciò in cui viene risolto `TWENTY_FUNCTIONS_URL`. Per i chiamanti esterni, copia l’URL esatto dalle impostazioni del **trigger HTTP** della funzione o dalla scheda **Settings** dell’applicazione.
|
||||
|
||||
<Warning>
|
||||
La route legacy della funzione `/s/` è **deprecata** e sarà **disattivata il 2026-07-24**. Usa invece `TWENTY_FUNCTIONS_URL` (sopra) e migra tutti gli URL `/s/` hard-coded prima di quella data. La route `/s/` rimane disponibile per il self-hosting.
|
||||
</Warning>
|
||||
|
||||
Un front component headless può eseguire la chiamata al mount tramite il componente `Command`, quindi smontarsi automaticamente:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
Il percorso passato al client è il percorso pubblico della route — la proprietà `httpRouteTriggerSettings.path` della funzione di logica con prefisso `/s`. Mantieni `isAuthRequired: true`; il client fornisce il token di accesso dell'app emesso da Twenty per il tuo componente:
|
||||
Il percorso aggiunto a `TWENTY_FUNCTIONS_URL` è il `httpRouteTriggerSettings.path` della funzione logica. Mantieni `isAuthRequired: true`; il `TWENTY_APP_ACCESS_TOKEN` che Twenty genera per il tuo componente autentica la richiesta:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` e `TWENTY_APP_ACCESS_TOKEN` vengono inseriti automaticamente — vedi [Variabili dell’applicazione](#application-variables). Poiché le variabili di applicazione segrete non vengono mai esposte ai front component, mantieni le chiavi API e altra logica sensibile all’interno della funzione logica, non nel front component.
|
||||
`TWENTY_FUNCTIONS_URL` e `TWENTY_APP_ACCESS_TOKEN` vengono inseriti automaticamente — vedi [Variabili dell'applicazione](#application-variables). Poiché le variabili di applicazione segrete non vengono mai esposte ai front component, mantieni le chiavi API e altra logica sensibile all’interno della funzione logica, non nel front component.
|
||||
</Note>
|
||||
|
||||
### Riferimento a RestApiClient
|
||||
### Chiamare le Twenty REST API
|
||||
|
||||
Importa `RestApiClient` da `twenty-client-sdk/rest`. Appartiene alla stessa famiglia di client di `CoreApiClient` e `MetadataApiClient`, ma si rivolge alle route HTTP della tua app invece della GraphQL API.
|
||||
Per leggere o scrivere record di Twenty da un front component, usa `RestApiClient` da `twenty-client-sdk/rest`. Appartiene alla stessa famiglia di client di `CoreApiClient` e `MetadataApiClient`, ma si rivolge alle Twenty REST API (`/rest/...`) invece della GraphQL API, leggendo il suo URL di base da `TWENTY_API_URL`.
|
||||
|
||||
| Metodo | Descrizione |
|
||||
| --------------------------------- | -------------------------------------------- |
|
||||
@@ -280,7 +285,7 @@ L'URL di base e il token vengono risolti dall'ambiente per impostazione predefin
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ Le variabili segrete (`isSecret: true`) **non** sono esposte ai componenti front
|
||||
|
||||
Le seguenti variabili di sistema sono sempre disponibili tramite `process.env`:
|
||||
|
||||
| Variabile | Descrizione |
|
||||
| ------------------------- | ---------------------------------------------------------------- |
|
||||
| `TWENTY_API_URL` | URL di base delle API di Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Token di breve durata con ambito limitato al ruolo della tua app |
|
||||
| Variabile | Descrizione |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `TWENTY_FUNCTIONS_URL` | URL di base da cui vengono servite le funzioni logiche HTTP della tua app (usato da `RestApiClient`) |
|
||||
| `TWENTY_API_URL` | URL di base dell'API Core di Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Token di breve durata con ambito limitato al ruolo della tua app |
|
||||
|
||||
## API di comunicazione con l'host
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Per richiamare, da un componente front-end (headless), una funzione logica attiv
|
||||
* **cron**: Esegue la tua funzione secondo una pianificazione utilizzando un'espressione CRON.
|
||||
* **databaseEvent**: Viene eseguito sugli eventi del ciclo di vita degli oggetti dello spazio di lavoro. Quando l'operazione dell'evento è `updated`, è possibile specificare campi specifici da monitorare nell'array `updatedFields`. Se lasciato non definito o vuoto, qualsiasi aggiornamento attiverà la funzione.
|
||||
> ad es. `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook**: Riceve webhook in entrata da un servizio di terze parti (Stripe, GitHub, Svix, …) su un singolo endpoint con ambito di registrazione e risolve lo spazio di lavoro di destinazione a partire dal payload. Vedi [Trigger webhook del server](#server-webhook-trigger).
|
||||
* **serverRoute**: espone una singola route HTTP con ambito di registrazione. Una funzione di **resolver** (dichiarata con `serverRouteTriggerSettings`) viene eseguita nel workspace proprietario e restituisce sia il workspace di destinazione SIA la funzione logica di destinazione a cui indirizzare la richiesta; la piattaforma quindi esegue tale funzione di **destinazione** e ne restituisce la risposta. Vedi [Trigger route del server](#server-route-trigger).
|
||||
|
||||
<Note>
|
||||
Puoi anche eseguire manualmente una funzione utilizzando la CLI:
|
||||
@@ -172,54 +172,108 @@ Per motivi di sicurezza, le intestazioni di risposta sono limitate a un elenco c
|
||||
Il codice di stato deve essere un codice di stato HTTP valido (compreso tra 100 e 599). I nomi delle intestazioni di risposta vengono confrontati senza distinzione tra maiuscole e minuscole.
|
||||
</Note>
|
||||
|
||||
#### Trigger webhook del server
|
||||
#### Trigger route del server
|
||||
|
||||
`httpRouteTriggerSettings` espone una funzione sotto `/s/` e risolve lo spazio di lavoro dall'host della richiesta — il che funziona quando ogni spazio di lavoro ha il proprio dominio. I provider di terze parti, tuttavia, inviano gli eventi di ogni tenant a **un** URL di webhook. Per quel caso, usa `serverWebhookTriggerSettings`: la funzione è raggiungibile in un endpoint con ambito di registrazione e lo spazio di lavoro viene risolto dal payload.
|
||||
`httpRouteTriggerSettings` espone una funzione sotto `/s/` e risolve lo spazio di lavoro dall'host della richiesta — il che funziona quando ogni spazio di lavoro ha il proprio dominio. I provider di terze parti, tuttavia, inviano gli eventi di ogni tenant a **un** URL. Per questo caso, usa `serverRouteTriggerSettings`.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
Il trigger ha due parti:
|
||||
|
||||
1. Una funzione logica di **resolver** — dichiarata con `serverRouteTriggerSettings` — viene eseguita nel tuo **workspace proprietario** (il workspace che possiede la registrazione dell'applicazione). Ispeziona la richiesta in ingresso e restituisce `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`, scegliendo *sia* il workspace di destinazione che la funzione di destinazione. Il resolver è l'unico punto di autorizzazione: l'URL contiene solo l'identificatore del resolver. **Questo è il punto preferenziale per verificare le firme delle richieste**: il resolver viene eseguito prima di qualsiasi effetto collaterale, ha accesso al `rawBody` originale e agli header inoltrati, e può rifiutare senza toccare la destinazione.
|
||||
2. Una funzione logica di **destinazione** — una normale funzione logica per-workspace — viene quindi eseguita nel workspace risolto con il payload restituito dal resolver (o il payload originale della richiesta se il resolver non lo ha trasformato). Il suo valore di ritorno diventa la risposta HTTP.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
La funzione è raggiungibile all'indirizzo:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
Entrambi gli identificatori sono gli `universalIdentifier` del tuo manifest — quello della registrazione dell'applicazione e quello di questa funzione logica. Registra quell'URL presso il provider.
|
||||
L'endpoint è raggiungibile all'indirizzo:
|
||||
|
||||
**Risoluzione dello spazio di lavoro.** Poiché un endpoint serve ogni spazio di lavoro, la tua integrazione deve inserire il `workspaceId` di destinazione da qualche parte nella consegna, e `workspaceIdResolver.{ source, path }` indica alla piattaforma dove leggerlo:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| Campo | Valori | Note |
|
||||
| ---------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fonte` | `body` \| `query` \| `header` | `body` legge il JSON analizzato. `query` è il più universale — di solito controlli l'URL di callback che registri, quindi aggiungi `?twentyWorkspaceId=…`. |
|
||||
| `percorso` | percorso con punti, ad es. `metadata.twentyWorkspaceId` | Limitato a segmenti alfanumerici / `_` / `-`; le chiavi del prototipo vengono rifiutate. |
|
||||
L'identificatore è il `universalIdentifier` del resolver dal tuo manifest. Registra quell'URL presso il provider.
|
||||
|
||||
Il valore risolto deve essere un UUID di spazio di lavoro valido **e** la tua app deve essere installata in quello spazio di lavoro, altrimenti la richiesta viene rifiutata prima che la funzione venga eseguita.
|
||||
**Contratto del resolver.** Il tipo `LogicFunctionConfig` dell'SDK impone questo a tempo di compilazione: non appena imposti `serverRouteTriggerSettings`, il tuo handler è vincolato a restituire `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (o una `Promise` di questo valore). Il `workspaceId` deve essere un workspace in cui la funzione di destinazione è installata, altrimenti la richiesta viene rifiutata con `404`.
|
||||
|
||||
| Campo | Tipo | Note |
|
||||
| ---------------------------------------- | -------------------- | ---------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | UUID del workspace in cui verrà eseguita la destinazione. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` della funzione logica da invocare in quel workspace. |
|
||||
| `payload` | `object` (opzionale) | Se impostato, sostituisce il body della richiesta inviato alla destinazione. |
|
||||
|
||||
<Warning>
|
||||
**La verifica della firma è tua responsabilità.** La piattaforma non verifica le firme dei webhook per questo trigger — si limita a risolvere lo spazio di lavoro ed eseguire la tua funzione. Il tuo handler deve verificare la firma autonomamente usando `event.rawBody` e gli header che hai elencato in `forwardedRequestHeaders`, confrontando con un segreto memorizzato come variabile server/applicazione. Verifica sempre **prima** di qualsiasi effetto collaterale e usa un confronto a tempo costante.
|
||||
**La verifica della firma è una tua responsabilità — eseguila nel resolver.** La piattaforma non verifica le firme delle richieste. Il resolver è il punto consigliato in cui farlo: viene eseguito per primo, con accesso a `event.rawBody` e agli header che hai elencato in `forwardedRequestHeaders`, e un errore lanciato (o qualsiasi `workspaceId` non corrispondente) interrompe l’inoltro prima che la destinazione venga invocata. Se invece spingi la verifica verso la destinazione, la destinazione deve fare attenzione a non perdere `rawBody` e gli header — cioè il resolver non deve restituire un `payload`. Verifica sempre **prima** di qualsiasi effetto collaterale e usa un confronto a tempo costante.
|
||||
</Warning>
|
||||
|
||||
La maggior parte dei provider firma con HMAC-SHA256; le parti che differiscono sono il nome dell'header, la codifica del digest e la stringa del payload firmato. Alcuni esempi:
|
||||
Per le firme delle richieste, la maggior parte dei provider firma con HMAC-SHA256; le parti che differiscono sono il nome dell'header, la codifica del digest e la stringa del payload firmato. Alcuni esempi:
|
||||
|
||||
| Provider | Header da inoltrare | Stringa firmata | Digest |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | ---------------------------------------------------------- |
|
||||
@@ -229,31 +283,10 @@ La maggior parte dei provider firma con HMAC-SHA256; le parti che differiscono s
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | esadecimale (prefissato con `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
L'esempio di resolver sopra mostra già il flusso HMAC-SHA256 di GitHub — adatta il nome dell'header, la codifica del digest e la stringa del payload firmato in base al provider con cui ti stai integrando.
|
||||
|
||||
<Note>
|
||||
La funzione viene eseguita **in modo sincrono** e il valore restituito diventa la risposta HTTP, quindi i provider vedono il tuo codice di stato e possono ritentare in caso di codice non 2xx. Mantieni gli handler veloci — alcuni provider (ad es. Slack) vanno in timeout in pochi secondi. Poiché la funzione viene eseguita prima che la firma sia verificata, proteggi questo endpoint con limitazione della frequenza al tuo edge.
|
||||
La destinazione viene eseguita **in modo sincrono** e il valore restituito diventa la risposta HTTP, quindi i chiamanti vedono il tuo codice di stato e possono ritentare in caso di codice non 2xx. Mantieni entrambi gli handler veloci — alcuni provider (ad es. Slack) vanno in timeout in pochi secondi. Poiché il resolver è raggiungibile come endpoint pubblico, proteggilo con rate limiting al tuo edge.
|
||||
</Note>
|
||||
|
||||
#### Payload del trigger di evento del database
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
Front 컴포넌트는 샌드박스된 Web Worker 안에서 브라우저 측에서 실행되고, [logic functions](/l/ko/developers/extend/apps/logic/logic-functions)는 서버 측에서 실행됩니다. 두 요소 사이에는 프로세스 내에서의 직접 호출이 없습니다. 대신, Front 컴포넌트는 HTTP를 통해 로직 함수에 접근합니다.
|
||||
|
||||
`httpRouteTriggerSettings`로 선언된 로직 함수는 `${TWENTY_API_URL}/s\<path>`의 `/s/` 엔드포인트 아래에 노출됩니다. 여러분의 Front 컴포넌트는 `twenty-client-sdk/rest`의 `RestApiClient`를 사용해 해당 라우트를 호출하며, Twenty가 워커에 주입하는 `TWENTY_APP_ACCESS_TOKEN`으로 인증합니다.
|
||||
`httpRouteTriggerSettings`로 선언된 로직 함수는 HTTP를 통해 해당 라우트 경로에서 액세스할 수 있습니다. Twenty는 워커에 함수들이 제공되는 기본 URL을 `TWENTY_FUNCTIONS_URL`로 주입하고, 호출을 인증하는 `TWENTY_APP_ACCESS_TOKEN`도 함께 주입합니다. 아직 자체 함수를 호출하기 위한 전용 SDK 클라이언트는 없으므로, 일반 `fetch`로 호출하세요:
|
||||
|
||||
`RestApiClient`는 바로 이런 용도로 설계되었습니다. 이는 워커 환경에서 `TWENTY_API_URL`과 `TWENTY_APP_ACCESS_TOKEN`을 읽어 `Authorization: Bearer` 헤더를 추가하고, JSON을 직렬화 및 파싱하며, 토큰이나 URL이 없거나 응답이 2xx가 아닐 경우 `RestApiClientError`를 발생시켜, 여러분이 각 컴포넌트마다 이러한 보일러플레이트를 다시 구현하지 않아도 되도록 해 줍니다.
|
||||
> **Twenty Cloud에서 HTTP로 트리거되는 로직 함수는 작업공간별 전용 도메인에서 제공됩니다**: `https://\<your-workspace-subdomain>.twenty.com\<path>` — 이는 `TWENTY_FUNCTIONS_URL`이 정확히 가리키는 주소입니다. 외부 호출자의 경우, 함수의 **HTTP trigger** 설정 또는 애플리케이션의 **Settings** 탭에서 정확한 URL을 복사하세요.
|
||||
|
||||
<Warning>
|
||||
레거시 `/s/` 함수 라우트는 **사용 중단(deprecated)** 되었으며 **2026-07-24에 비활성화됩니다**. 대신 위의 `TWENTY_FUNCTIONS_URL`을 사용하고, 해당 날짜 이전에 하드 코딩된 모든 `/s/` URL을 마이그레이션하세요. `/s/` 라우트는 셀프 호스팅의 경우 계속 사용 가능합니다.
|
||||
</Warning>
|
||||
|
||||
헤드리스 Front 컴포넌트는 `Command` 컴포넌트를 통해 마운트 시점에 호출을 실행한 뒤, 자동으로 언마운트될 수 있습니다:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
클라이언트에 전달되는 경로는 라우트의 public 경로이며, 로직 함수의 `httpRouteTriggerSettings.path` 앞에 `/s`가 접두사로 붙습니다. `isAuthRequired: true`를 유지하세요. 클라이언트가 컴포넌트용으로 Twenty가 발행한 앱 액세스 토큰을 제공합니다:
|
||||
`TWENTY_FUNCTIONS_URL`에 추가되는 경로는 로직 함수의 `httpRouteTriggerSettings.path`입니다. `isAuthRequired: true`로 유지하세요. 컴포넌트를 위해 Twenty가 발급하는 `TWENTY_APP_ACCESS_TOKEN`이 요청을 인증합니다:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL`과 `TWENTY_APP_ACCESS_TOKEN`은 자동으로 주입됩니다. 자세한 내용은 [Application variables](#application-variables)을 참고하세요. 비밀 애플리케이션 변수는 Front 컴포넌트에 절대 노출되지 않으므로, API 키 및 기타 민감한 로직은 Front 컴포넌트가 아니라 로직 함수 안에 유지해야 합니다.
|
||||
`TWENTY_FUNCTIONS_URL`과 `TWENTY_APP_ACCESS_TOKEN`은 자동으로 주입됩니다. 자세한 내용은 [Application variables](#application-variables)을 참고하세요. 비밀 애플리케이션 변수는 Front 컴포넌트에 절대 노출되지 않으므로, API 키 및 기타 민감한 로직은 Front 컴포넌트가 아니라 로직 함수 안에 유지해야 합니다.
|
||||
</Note>
|
||||
|
||||
### RestApiClient 레퍼런스
|
||||
### Twenty REST API 호출하기
|
||||
|
||||
`RestApiClient`를 `twenty-client-sdk/rest`에서 import하세요. 이는 `CoreApiClient` 및 `MetadataApiClient`와 동일한 클라이언트 패밀리에 속하지만, GraphQL API 대신 앱의 HTTP 라우트를 대상으로 합니다.
|
||||
프론트 컴포넌트에서 Twenty 레코드를 읽거나 쓰려면 `twenty-client-sdk/rest`의 `RestApiClient`를 사용하세요. 이는 `CoreApiClient` 및 `MetadataApiClient`와 동일한 클라이언트 패밀리에 속하지만, GraphQL API 대신 Twenty REST API(`/rest/...`)를 대상으로 하며, 기본 URL은 `TWENTY_API_URL`에서 읽어 옵니다.
|
||||
|
||||
| 방법 | 설명 |
|
||||
| --------------------------------- | ------------------------------ |
|
||||
@@ -280,7 +285,7 @@ export default defineLogicFunction({
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ export default defineFrontComponent({
|
||||
|
||||
다음 시스템 변수는 항상 `process.env`를 통해 사용할 수 있습니다:
|
||||
|
||||
| 변수 | 설명 |
|
||||
| ------------------------- | ------------------------ |
|
||||
| `TWENTY_API_URL` | Twenty API의 기본 URL |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | 앱의 역할 범위로 제한된 단기간 유효한 토큰 |
|
||||
| 변수 | 설명 |
|
||||
| ------------------------- | ------------------------------------------------- |
|
||||
| `TWENTY_FUNCTIONS_URL` | 앱의 HTTP 로직 함수가 제공되는 기본 URL(`RestApiClient`에서 사용됨) |
|
||||
| `TWENTY_API_URL` | Twenty 코어 API의 기본 URL |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | 앱의 역할 범위로 제한된 단기간 유효한 토큰 |
|
||||
|
||||
## 호스트 통신 API
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export default defineLogicFunction({
|
||||
* **cron**: CRON 식을 사용하여 예약된 일정으로 함수를 실행합니다.
|
||||
* **databaseEvent**: 워크스페이스 객체 라이프사이클 이벤트에서 실행됩니다. 이벤트 작업이 `updated`인 경우, 수신할 특정 필드를 `updatedFields` 배열에 지정할 수 있습니다. 정의하지 않거나 비워두면, 어떤 업데이트든 함수가 트리거됩니다.
|
||||
> 예: `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook**: 타사 서비스(Stripe, GitHub, Svix, …)로부터 인바운드 웹훅을 수신합니다. 단일 등록 범위 엔드포인트에서 수신하고, 페이로드에서 대상 워크스페이스를 해석합니다. [서버 웹훅 트리거](#server-webhook-trigger)를 참고하세요.
|
||||
* **serverRoute**: 단일 등록 범위 HTTP 라우트를 노출합니다. `serverRouteTriggerSettings`로 선언된 **resolver** 함수는 소유자 워크스페이스에서 실행되며, 디스패치할 대상 워크스페이스와 대상 로직 함수를 반환합니다. 그런 다음 플랫폼은 해당 **target** 함수를 실행하고 그 응답을 반환합니다. [서버 라우트 트리거](#server-route-trigger)를 참고하세요.
|
||||
|
||||
<Note>
|
||||
CLI를 사용해 함수를 수동으로 실행할 수도 있습니다:
|
||||
@@ -172,54 +172,108 @@ const handler = async (event: RoutePayload) => {
|
||||
상태 코드는 유효한 HTTP 상태 코드(100에서 599 사이)여야 합니다. 응답 헤더 이름은 대소문자를 구분하지 않습니다.
|
||||
</Note>
|
||||
|
||||
#### 서버 웹훅 트리거
|
||||
#### 서버 라우트 트리거
|
||||
|
||||
`httpRouteTriggerSettings`는 `/s/` 아래에 함수를 노출하고 요청 호스트에서 워크스페이스를 해석합니다. 이는 각 워크스페이스가 자체 도메인을 가질 때 동작합니다. 그러나 타사 공급자는 모든 테넌트의 이벤트를 **하나의** 웹훅 URL로 전달합니다. 그런 경우에는 `serverWebhookTriggerSettings`를 사용하세요. 이 함수는 등록 범위의 엔드포인트에서 접근 가능하며, 워크스페이스는 페이로드에서 해석됩니다.
|
||||
`httpRouteTriggerSettings`는 `/s/` 아래에 함수를 노출하고 요청 호스트에서 워크스페이스를 해석합니다. 이는 각 워크스페이스가 자체 도메인을 가질 때 동작합니다. 그러나 타사 공급자는 모든 테넌트의 이벤트를 **하나의** URL로 전달합니다. 그 경우에는 `serverRouteTriggerSettings`를 사용하세요.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
트리거는 두 부분으로 구성됩니다:
|
||||
|
||||
1. **resolver** 로직 함수 — `serverRouteTriggerSettings`로 선언되는 — 는 **소유자 워크스페이스**(애플리케이션 등록을 소유한 워크스페이스)에서 실행됩니다. 이 함수는 들어오는 요청을 검사하고 `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`를 반환하여, 대상 워크스페이스와 대상 함수를 *둘 다* 선택합니다. resolver는 단일 인가 지점입니다 — URL에는 resolver의 식별자만 포함됩니다. **요청 서명을 검증하기에 가장 적합한 위치입니다**. resolver는 어떤 부수 효과가 발생하기 전에 실행되며, 원본 `rawBody`와 전달된 헤더에 접근할 수 있고, 대상에 전혀 접근하지 않고도 요청을 거부할 수 있습니다.
|
||||
2. 그 다음 **target** 로직 함수 — 일반적인 워크스페이스별 로직 함수 — 가 resolver가 반환한 payload(또는 resolver가 변환하지 않았다면 원본 요청 payload)를 가지고 결정된 워크스페이스에서 실행됩니다. 해당 함수의 반환 값이 HTTP 응답이 됩니다.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
함수는 다음 위치에서 접근할 수 있습니다:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
두 식별자는 모두 매니페스트에 있는 `universalIdentifier`입니다. 각각 애플리케이션 등록과 이 로직 함수의 `universalIdentifier`입니다. 해당 URL을 공급자에 등록하세요.
|
||||
엔드포인트는 다음 위치에서 접근할 수 있습니다:
|
||||
|
||||
**워크스페이스 해석.** 하나의 엔드포인트가 모든 워크스페이스를 처리하므로, 통합에서는 대상 `workspaceId`를 전달 데이터 어딘가에 넣어야 하고, `workspaceIdResolver.{ source, path }`는 플랫폼에 그 값을 어디에서 읽어야 하는지 알려 줍니다:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| 필드 | 값 | 노트 |
|
||||
| -------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `source` | `body` \| `query` \| `header` | `body`는 파싱된 JSON을 읽습니다. `query`가 가장 범용적입니다. 일반적으로 등록하는 콜백 URL을 제어할 수 있으므로, `?twentyWorkspaceId=…`를 추가하면 됩니다. |
|
||||
| `path` | dot-path, 예: `metadata.twentyWorkspaceId` | 영숫자 / `_` / `-` 세그먼트로만 제한되며, prototype 키는 거부됩니다. |
|
||||
이 식별자는 manifest에 있는 resolver의 `universalIdentifier`입니다. 해당 URL을 공급자에 등록하세요.
|
||||
|
||||
해석된 값은 유효한 워크스페이스 UUID여야 **하고**, 앱이 해당 워크스페이스에 설치되어 있어야 합니다. 그렇지 않으면 함수가 실행되기 전에 요청이 거부됩니다.
|
||||
**Resolver 계약.** SDK의 `LogicFunctionConfig` 타입은 컴파일 타임에 이를 강제합니다. `serverRouteTriggerSettings`를 설정하는 즉시, 핸들러는 `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }`(또는 이 값을 반환하는 `Promise`)를 반환하도록 제한됩니다. `workspaceId`는 대상 함수가 설치된 워크스페이스여야 하며, 그렇지 않으면 요청은 `404`로 거부됩니다.
|
||||
|
||||
| 필드 | 유형 | 노트 |
|
||||
| ---------------------------------------- | ---------------- | ------------------------------------------------ |
|
||||
| `workspaceId` | `string` | 대상 함수가 실행될 워크스페이스의 UUID입니다. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | 해당 워크스페이스에서 호출할 로직 함수의 `universalIdentifier`입니다. |
|
||||
| `payload` | `object` (선택 사항) | 설정된 경우, 대상 함수에 전송되는 요청 본문을 대체합니다. |
|
||||
|
||||
<Warning>
|
||||
**서명 검증은 사용자 책임입니다.** 이 트리거에 대해 플랫폼은 웹훅 서명을 검증하지 않습니다. 워크스페이스를 해석하고 함수를 실행할 뿐입니다. 핸들러는 `event.rawBody`와 `forwardedRequestHeaders`에 나열한 헤더를 사용해 직접 서명을 검증해야 하며, 서버/애플리케이션 변수로 저장된 시크릿과 비교해야 합니다. 항상 부수 효과가 발생하기 **이전**에 서명을 검증하고, 상수 시간 비교를 사용하세요.
|
||||
**서명 검증은 사용자 책임입니다 — resolver에서 검증하세요.** 플랫폼은 요청 서명을 검증하지 않습니다. resolver는 이를 수행하기에 권장되는 위치입니다. 가장 먼저 실행되며, `event.rawBody`와 `forwardedRequestHeaders`에 나열한 헤더에 접근할 수 있고, 오류를 발생시키거나 일치하지 않는 `workspaceId`를 반환하면 대상이 호출되기 전에 디스패치를 중단합니다. 검증을 대신 대상 쪽으로 미루는 경우, 대상은 `rawBody`와 헤더를 잃지 않도록 주의해야 합니다. 즉, resolver가 `payload`를 반환해서는 안 됩니다. 항상 어떤 부수 효과가 발생하기 **이전**에 검증을 수행하고, 상수 시간 비교를 사용하세요.
|
||||
</Warning>
|
||||
|
||||
대부분의 공급자는 HMAC-SHA256으로 서명합니다. 서로 다른 부분은 헤더 이름, 다이제스트 인코딩, 그리고 서명 대상 페이로드 문자열입니다. 몇 가지 예시는 다음과 같습니다:
|
||||
요청 서명의 경우 대부분의 공급자는 HMAC-SHA256으로 서명합니다. 서로 다른 부분은 헤더 이름, 다이제스트 인코딩, 그리고 서명 대상 페이로드 문자열입니다. 몇 가지 예시는 다음과 같습니다:
|
||||
|
||||
| 공급자 | 포워딩할 헤더 | 서명 문자열 | 다이제스트 |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | ---------------------------------- |
|
||||
@@ -229,31 +283,10 @@ POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniv
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (`v0=` prefix 포함) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
위의 resolver 예제는 이미 GitHub HMAC-SHA256 플로우를 보여 줍니다. 통합하려는 공급자에 따라 헤더 이름, 다이제스트 인코딩, 그리고 서명 대상 페이로드 문자열을 조정하세요.
|
||||
|
||||
<Note>
|
||||
함수는 **동기적으로** 실행되며, 반환한 값이 HTTP 응답이 됩니다. 따라서 프로바이더는 상태 코드를 확인하고 2xx가 아닐 경우 재시도할 수 있습니다. 핸들러는 빠르게 유지하세요. 일부 공급자(예: Slack)는 몇 초 안에 타임아웃됩니다. 함수가 서명을 확인하기 전에 실행되므로, 이 엔드포인트는 엣지에서 레이트 리밋으로 보호하세요.
|
||||
대상 함수는 **동기적으로** 실행되며, 반환한 값이 HTTP 응답이 되므로, 호출자는 상태 코드를 확인하고 2xx가 아닐 경우 재시도할 수 있습니다. 두 핸들러 모두 빠르게 유지하세요. 일부 공급자(예: Slack)는 몇 초 안에 타임아웃됩니다. resolver는 public endpoint로 접근 가능하므로, 엣지에서 rate limiting으로 보호하세요.
|
||||
</Note>
|
||||
|
||||
#### 데이터베이스 이벤트 트리거 페이로드
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
Os componentes de front são executados no navegador em um Web Worker isolado, enquanto as [funções lógicas](/l/pt/developers/extend/apps/logic/logic-functions) são executadas no servidor. Não há chamada direta no mesmo processo entre os dois — em vez disso, um componente de front acessa uma função lógica via HTTP.
|
||||
|
||||
Uma função lógica declarada com `httpRouteTriggerSettings` é exposta sob o endpoint `/s/` em `${TWENTY_API_URL}/s\<path>`. Seu componente de front chama essa rota com o `RestApiClient` de `twenty-client-sdk/rest`, que autentica com o `TWENTY_APP_ACCESS_TOKEN` que a Twenty injeta no worker.
|
||||
Uma função lógica declarada com `httpRouteTriggerSettings` é acessível por HTTP em seu caminho de rota. Twenty injeta no worker a URL base a partir da qual suas funções são servidas como `TWENTY_FUNCTIONS_URL`, juntamente com o `TWENTY_APP_ACCESS_TOKEN` que autentica a chamada. Ainda não há um cliente SDK dedicado para invocar suas próprias funções, portanto chame-as com um simples `fetch`:
|
||||
|
||||
O `RestApiClient` foi criado exatamente para isso. Ele lê `TWENTY_API_URL` e `TWENTY_APP_ACCESS_TOKEN` do ambiente do worker, adiciona o cabeçalho `Authorization: Bearer`, serializa e analisa JSON e lança um `RestApiClientError` quando o token ou a URL estão ausentes ou a resposta não é 2xx — para que você não precise reimplementar esse boilerplate em todos os componentes.
|
||||
> **No Twenty Cloud, funções lógicas acionadas por HTTP são servidas em um domínio dedicado por workspace** em `https://\<your-workspace-subdomain>.twenty.com\<path>` — que é exatamente para onde `TWENTY_FUNCTIONS_URL` aponta. Para chamadores externos, copie a URL exata das configurações de **HTTP trigger** da função ou da guia **Settings** do aplicativo.
|
||||
|
||||
<Warning>
|
||||
A rota legada da função `/s/` está **obsoleta** e será **desativada em 2026-07-24**. Use `TWENTY_FUNCTIONS_URL` (acima) em vez disso e migre quaisquer URLs de `/s/` fixas no código antes dessa data. A rota `/s/` continua disponível para auto-hospedagem.
|
||||
</Warning>
|
||||
|
||||
Um componente de front headless pode executar a chamada ao montar via o componente `Command` e, em seguida, desmontar automaticamente:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
O caminho passado para o cliente é o caminho público da rota — o `httpRouteTriggerSettings.path` da função de lógica, prefixado com `/s`. Mantenha `isAuthRequired: true`; o cliente fornece o token de acesso do app que o Twenty emite para o seu componente:
|
||||
O caminho anexado a `TWENTY_FUNCTIONS_URL` é o `httpRouteTriggerSettings.path` da função lógica. Mantenha `isAuthRequired: true`; o `TWENTY_APP_ACCESS_TOKEN` que a Twenty gera para o seu componente autentica a solicitação:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` e `TWENTY_APP_ACCESS_TOKEN` são injetados automaticamente — consulte [Variáveis de aplicação](#application-variables). Como as variáveis de aplicação secretas nunca são expostas aos componentes de front, mantenha as chaves de API e outra lógica sensível na função lógica, não no componente de front.
|
||||
`TWENTY_FUNCTIONS_URL` e `TWENTY_APP_ACCESS_TOKEN` são injetados automaticamente — consulte [Variáveis de aplicação](#application-variables). Como as variáveis de aplicação secretas nunca são expostas aos componentes de front, mantenha as chaves de API e outra lógica sensível na função lógica, não no componente de front.
|
||||
</Note>
|
||||
|
||||
### Referência do `RestApiClient`
|
||||
### Chamando a API REST da Twenty
|
||||
|
||||
Importe `RestApiClient` de `twenty-client-sdk/rest`. Ele pertence à mesma família de clientes que `CoreApiClient` e `MetadataApiClient`, mas tem como alvo as rotas HTTP do seu app em vez da API GraphQL.
|
||||
Para ler ou gravar registros da Twenty a partir de um front component, use `RestApiClient` de `twenty-client-sdk/rest`. Ele pertence à mesma família de clientes que `CoreApiClient` e `MetadataApiClient`, mas tem como alvo a API REST da Twenty (`/rest/...`) em vez da API GraphQL, lendo sua URL base de `TWENTY_API_URL`.
|
||||
|
||||
| Método | Descrição |
|
||||
| --------------------------------- | -------------------------------------------- |
|
||||
@@ -280,7 +285,7 @@ A URL base e o token são resolvidos do ambiente por padrão. Passe substituiç
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ Variáveis secretas (`isSecret: true`) **não** são expostas aos componentes de
|
||||
|
||||
As seguintes variáveis de sistema estão sempre disponíveis via `process.env`:
|
||||
|
||||
| Variável | Descrição |
|
||||
| ------------------------- | ------------------------------------------------------------- |
|
||||
| `TWENTY_API_URL` | URL base da API da Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Token de curta duração limitado ao escopo do papel do seu app |
|
||||
| Variável | Descrição |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `TWENTY_FUNCTIONS_URL` | URL base a partir da qual as funções lógicas HTTP do seu app são servidas (usada por `RestApiClient`) |
|
||||
| `TWENTY_API_URL` | URL base da API principal da Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Token de curta duração limitado ao escopo do papel do seu app |
|
||||
|
||||
## API de comunicação do host
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Para invocar uma função de lógica acionada por rota a partir de um componente
|
||||
* **cron**: Executa sua função em um agendamento usando uma expressão CRON.
|
||||
* **databaseEvent**: Executa em eventos do ciclo de vida de objetos do espaço de trabalho. Quando a operação do evento é `updated`, campos específicos a serem observados podem ser especificados no array `updatedFields`. Se deixar indefinido ou vazio, qualquer atualização acionará a função.
|
||||
> por exemplo, `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook**: Recebe webhooks de entrada de um serviço de terceiros (Stripe, GitHub, Svix, …) em um único endpoint com escopo de registro e resolve o workspace de destino a partir do payload. Veja [gatilho de webhook de servidor](#server-webhook-trigger).
|
||||
* **serverRoute**: expõe uma única rota HTTP com escopo de registro. Uma função **resolver** (declarada com `serverRouteTriggerSettings`) é executada no workspace proprietário e retorna o workspace de destino E a função de lógica de destino para a qual despachar; a plataforma então executa essa função de **destino** e retorna sua resposta. Veja [gatilho de rota de servidor](#server-route-trigger).
|
||||
|
||||
<Note>
|
||||
Você também pode executar manualmente uma função usando a CLI:
|
||||
@@ -172,54 +172,108 @@ Por motivos de segurança, os cabeçalhos de resposta são restringidos a uma li
|
||||
O código de status deve ser um código de status HTTP válido (entre 100 e 599). Os nomes dos cabeçalhos de resposta são comparados sem distinção entre maiúsculas e minúsculas.
|
||||
</Note>
|
||||
|
||||
#### Gatilho de webhook do servidor
|
||||
#### Gatilho de rota de servidor
|
||||
|
||||
`httpRouteTriggerSettings` expõe uma função em `/s/` e resolve o workspace a partir do host da solicitação — o que funciona quando cada workspace tem seu próprio domínio. Provedores de terceiros, entretanto, entregam os eventos de todos os workspaces para **uma** URL de webhook. Para esse caso, use `serverWebhookTriggerSettings`: a função fica acessível em um endpoint com escopo de registro e o workspace é resolvido a partir do payload.
|
||||
`httpRouteTriggerSettings` expõe uma função em `/s/` e resolve o workspace a partir do host da solicitação — o que funciona quando cada workspace tem seu próprio domínio. Provedores de terceiros, entretanto, entregam os eventos de todos os workspaces para **uma** URL. Para esse caso, use `serverRouteTriggerSettings`.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
Nesse caso, o gatilho tem duas partes:
|
||||
|
||||
1. Uma função de lógica de **resolver** — declarada com `serverRouteTriggerSettings` — é executada no seu **workspace proprietário** (o workspace que é proprietário do registro da aplicação). Ela inspeciona a requisição recebida e retorna `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`, escolhendo *tanto* o workspace de destino quanto a função de destino. O resolver é o ponto único de autorização — a URL carrega apenas o identificador do resolver. **Este é o local preferencial para verificar assinaturas de requisição**: o resolver é executado antes de qualquer efeito colateral, tem acesso ao `rawBody` original e aos headers encaminhados e pode rejeitar sem nunca tocar no alvo.
|
||||
2. Uma função de lógica de **target** — uma função de lógica regular por workspace — então é executada no workspace resolvido com o payload retornado pelo resolver (ou o payload original da requisição, se o resolver não o tiver transformado). Seu valor de retorno se torna a resposta HTTP.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
A função fica acessível em:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
Ambos os identificadores são os `universalIdentifier`s do seu manifesto — o da aplicação registrada e o desta função de lógica. Registre essa URL junto ao provedor.
|
||||
O endpoint fica acessível em:
|
||||
|
||||
**Resolução de workspace.** Como um endpoint atende a todos os workspaces, sua integração deve colocar o `workspaceId` de destino em algum lugar na entrega, e `workspaceIdResolver.{ source, path }` informa à plataforma onde lê-lo:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| Campo | Valores | Notas |
|
||||
| -------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `source` | `body` \| `query` \| `header` | `body` lê o JSON já analisado. `query` é o mais universal — você normalmente controla a URL de callback que registra, então acrescente `?twentyWorkspaceId=…`. |
|
||||
| `path` | caminho com pontos, por exemplo, `metadata.twentyWorkspaceId` | Restrito a segmentos alfanuméricos / `_` / `-`; chaves de protótipo são rejeitadas. |
|
||||
O identificador é o `universalIdentifier` do resolver, vindo do seu manifest. Registre essa URL junto ao provedor.
|
||||
|
||||
O valor resolvido deve ser um UUID de workspace válido **e** seu aplicativo deve estar instalado nesse workspace; caso contrário, a solicitação é rejeitada antes que a função seja executada.
|
||||
**Contrato do resolver.** O tipo `LogicFunctionConfig` do SDK aplica isso em tempo de compilação: assim que você define `serverRouteTriggerSettings`, o seu handler fica limitado a retornar `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (ou uma `Promise` disso). O `workspaceId` deve ser um workspace onde a função de destino esteja instalada, caso contrário a requisição é rejeitada com `404`.
|
||||
|
||||
| Campo | Tipo | Notas |
|
||||
| ---------------------------------------- | ------------------- | --------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | UUID do workspace onde o alvo será executado. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` da função de lógica a ser invocada naquele workspace. |
|
||||
| `payload` | `object` (opcional) | Se definido, substitui o corpo da requisição enviado ao alvo. |
|
||||
|
||||
<Warning>
|
||||
**A verificação da assinatura é de sua responsabilidade.** A plataforma não verifica assinaturas de webhook para esse gatilho — ela apenas resolve o workspace e executa sua função. Seu handler deve verificar a assinatura usando `event.rawBody` e os headers que você listou em `forwardedRequestHeaders`, comparando com um segredo armazenado como variável de servidor/aplicação. Sempre verifique **antes** de qualquer efeito colateral e use uma comparação em tempo constante.
|
||||
**A verificação de assinatura é sua responsabilidade — verifique no resolver.** A plataforma não verifica assinaturas de requisição. O resolver é o local recomendado para fazer isso: ele é executado primeiro, com acesso a `event.rawBody` e aos headers que você listou em `forwardedRequestHeaders`, e um erro lançado (ou qualquer `workspaceId` que não corresponda) interrompe o despacho antes que o alvo seja invocado. Se, em vez disso, você empurrar a verificação para o target, o target deve ter cuidado para não perder o `rawBody` e os headers — isto é, o resolver não deve retornar um `payload`. Sempre verifique **antes** de qualquer efeito colateral e use uma comparação em tempo constante.
|
||||
</Warning>
|
||||
|
||||
A maioria dos provedores assina com HMAC-SHA256; as partes que diferem são o nome do header, a codificação do digest e a string de payload assinada. Alguns exemplos:
|
||||
Para assinaturas de requisição, a maioria dos provedores assina com HMAC-SHA256; as partes que diferem são o nome do header, a codificação do digest e a string de payload assinada. Alguns exemplos:
|
||||
|
||||
| Provedor | Headers a encaminhar | String assinada | Digest |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | ------------------------------------------------------- |
|
||||
@@ -229,31 +283,10 @@ A maioria dos provedores assina com HMAC-SHA256; as partes que diferem são o no
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (prefixado com `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
O exemplo de resolver acima já mostra o fluxo de HMAC-SHA256 do GitHub — adapte o nome do header, a codificação do digest e a string de payload assinada de acordo com o provedor com o qual você está integrando.
|
||||
|
||||
<Note>
|
||||
A função é executada **sincronamente** e o valor que você retorna se torna a resposta HTTP, portanto os provedores veem seu código de status e podem tentar novamente em caso de não 2xx. Mantenha os handlers rápidos — alguns provedores (por exemplo, Slack) atingem timeout em poucos segundos. Como a função é executada antes de a assinatura ser verificada, proteja esse endpoint com rate limiting na sua borda.
|
||||
O target é executado **sincronamente** e o valor retornado se torna a resposta HTTP, então quem faz a chamada vê seu código de status e pode tentar novamente em caso de não 2xx. Mantenha ambos os handlers rápidos — alguns provedores (por exemplo, Slack) atingem timeout em poucos segundos. Como o resolver fica acessível como um endpoint público, proteja-o com rate limiting na sua borda.
|
||||
</Note>
|
||||
|
||||
#### Payload do gatilho de evento do banco de dados
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
Componentele de front rulează în browser într-un Web Worker izolat, în timp ce [funcțiile logice](/l/ro/developers/extend/apps/logic/logic-functions) rulează pe server. Nu există un apel direct în același proces între cele două — în schimb, o componentă de front apelează o funcție logică prin HTTP.
|
||||
|
||||
O funcție logică declarată cu `httpRouteTriggerSettings` este expusă sub endpoint-ul `/s/` la `${TWENTY_API_URL}/s\<path>`. Componenta ta de front apelează acea rută cu `RestApiClient` din `twenty-client-sdk/rest`, care se autentifică folosind `TWENTY_APP_ACCESS_TOKEN` pe care Twenty îl injectează în worker.
|
||||
O funcție logică declarată cu `httpRouteTriggerSettings` este accesibilă prin HTTP la ruta sa. Twenty injectează în worker URL-ul de bază de la care sunt deservite funcțiile tale ca `TWENTY_FUNCTIONS_URL`, împreună cu `TWENTY_APP_ACCESS_TOKEN` care autentifică apelul. Nu există încă un client SDK dedicat pentru apelarea propriilor funcții, așa că apelează-le cu un simplu `fetch`:
|
||||
|
||||
`RestApiClient` este creat exact pentru acest scop. Acesta citește `TWENTY_API_URL` și `TWENTY_APP_ACCESS_TOKEN` din mediul worker-ului, atașează antetul `Authorization: Bearer`, serializează și parsează JSON și aruncă un `RestApiClientError` atunci când token-ul sau URL-ul lipsesc sau când răspunsul nu este 2xx — astfel încât să nu trebuiască să reimplementezi acel boilerplate în fiecare componentă.
|
||||
> **În Twenty Cloud, funcțiile logice declanșate prin HTTP sunt deservite pe un domeniu dedicat pentru fiecare spațiu de lucru** la `https://\<your-workspace-subdomain>.twenty.com\<path>` — acesta este exact URL-ul la care indică `TWENTY_FUNCTIONS_URL`. Pentru apelanții externi, copiază URL-ul exact din setările **HTTP trigger** ale funcției sau din fila **Settings** a aplicației.
|
||||
|
||||
<Warning>
|
||||
Ruta veche a funcției `/s/` este **depășită** și va fi **dezactivată la 2026-07-24**. Folosește în schimb `TWENTY_FUNCTIONS_URL` (mai sus) și migrează orice URL-uri `/s/` hard-codate înainte de acea dată. Ruta `/s/` rămâne disponibilă pentru self-hosting.
|
||||
</Warning>
|
||||
|
||||
O componentă de front headless poate efectua apelul la montare prin componenta `Command`, apoi se demontează automat:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
Calea transmisă către client este calea publică a rutei — proprietatea `httpRouteTriggerSettings.path` a funcției logice, cu prefixul `/s`. Păstrează `isAuthRequired: true`; clientul furnizează tokenul de acces al aplicației pe care Twenty îl emite pentru componenta ta:
|
||||
Calea adăugată la `TWENTY_FUNCTIONS_URL` este `httpRouteTriggerSettings.path` al funcției logice. Păstrează `isAuthRequired: true`; `TWENTY_APP_ACCESS_TOKEN` pe care Twenty îl generează pentru componenta ta autentifică cererea:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` și `TWENTY_APP_ACCESS_TOKEN` sunt injectate automat — vezi [Application variables](#application-variables). Deoarece variabilele de aplicație secrete nu sunt niciodată expuse componentelor de front, păstrează cheile API și altă logică sensibilă în funcția logică, nu în componenta de front.
|
||||
`TWENTY_FUNCTIONS_URL` și `TWENTY_APP_ACCESS_TOKEN` sunt injectate automat — vezi [Application variables](#application-variables). Deoarece variabilele de aplicație secrete nu sunt niciodată expuse componentelor de front, păstrează cheile API și altă logică sensibilă în funcția logică, nu în componenta de front.
|
||||
</Note>
|
||||
|
||||
### Referință pentru `RestApiClient`
|
||||
### Apelarea API-ului REST Twenty
|
||||
|
||||
Importă `RestApiClient` din `twenty-client-sdk/rest`. Face parte din aceeași familie de clienți ca `CoreApiClient` și `MetadataApiClient`, dar vizează rutele HTTP ale aplicației tale în locul API-ului GraphQL.
|
||||
Pentru a citi sau scrie înregistrări Twenty dintr-un front component, folosește `RestApiClient` din `twenty-client-sdk/rest`. Face parte din aceeași familie de clienți ca `CoreApiClient` și `MetadataApiClient`, dar vizează Twenty REST API (`/rest/...`) în locul API-ului GraphQL, citindu-și URL-ul de bază din `TWENTY_API_URL`.
|
||||
|
||||
| Metodă | Descriere |
|
||||
| --------------------------------- | ------------------------------------ |
|
||||
@@ -280,7 +285,7 @@ URL-ul de bază și tokenul sunt rezolvate din mediu în mod implicit. Transmite
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ Variabilele secrete (`isSecret: true`) **nu** sunt expuse componentelor de inter
|
||||
|
||||
Următoarele variabile de sistem sunt întotdeauna disponibile prin `process.env`:
|
||||
|
||||
| Variabilă | Descriere |
|
||||
| ------------------------- | -------------------------------------------------------- |
|
||||
| `TWENTY_API_URL` | URL de bază al API-ului Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Token cu durată scurtă, limitat la rolul aplicației dvs. |
|
||||
| Variabilă | Descriere |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
||||
| `TWENTY_FUNCTIONS_URL` | URL-ul de bază de la care sunt deservite funcțiile de logică HTTP ale aplicației tale (folosit de `RestApiClient`) |
|
||||
| `TWENTY_API_URL` | URL-ul de bază al API-ului de bază Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Token cu durată scurtă, limitat la rolul aplicației dvs. |
|
||||
|
||||
## API-ul de comunicare cu gazda
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Pentru a apela o funcție logică declanșată de o rută dintr-o componentă fr
|
||||
* **cron**: Rulează funcția pe un program folosind o expresie CRON.
|
||||
* **databaseEvent**: Rulează la evenimentele ciclului de viață ale obiectelor din spațiul de lucru. Când operațiunea evenimentului este `updated`, câmpurile specifice de urmărit pot fi specificate în array-ul `updatedFields`. Dacă este lăsat nedefinit sau gol, orice actualizare va declanșa funcția.
|
||||
> de ex. `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook**: Primește webhook-uri de intrare de la un serviciu terț (Stripe, GitHub, Svix, …) la un singur endpoint la nivelul înregistrării și determină spațiul de lucru țintă din payload. Consultați [declanșatorul de webhook de server](#server-webhook-trigger).
|
||||
* **serverRoute**: Expune o singură rută HTTP la nivelul înregistrării. O funcție de tip **resolver** (declarată cu `serverRouteTriggerSettings`) rulează în workspace-ul deținător și returnează atât workspace-ul țintă, cât și funcția logică țintă către care se face trimiterea; platforma rulează apoi acea funcție **țintă** și returnează răspunsul acesteia. Consultați [declanșatorul de rută de server](#server-route-trigger).
|
||||
|
||||
<Note>
|
||||
Puteți, de asemenea, să executați manual o funcție folosind CLI:
|
||||
@@ -173,54 +173,108 @@ Din motive de securitate, anteturile de răspuns sunt limitate la o listă de an
|
||||
Codul de stare trebuie să fie un cod de stare HTTP valid (între 100 și 599). Numele anteturilor de răspuns sunt comparate fără a ține cont de majuscule și minuscule.
|
||||
</Note>
|
||||
|
||||
#### Declanșator webhook de server
|
||||
#### Declanșator de rută de server
|
||||
|
||||
`httpRouteTriggerSettings` expune o funcție sub `/s/` și rezolvă spațiul de lucru din gazda cererii — ceea ce funcționează atunci când fiecare spațiu de lucru are propriul domeniu. Furnizorii terți, însă, livrează evenimentele fiecărui tenant către **un** singur URL de webhook. Pentru acest caz, folosiți `serverWebhookTriggerSettings`: funcția este accesibilă la un endpoint la nivelul înregistrării, iar spațiul de lucru este determinat din payload.
|
||||
`httpRouteTriggerSettings` expune o funcție sub `/s/` și rezolvă spațiul de lucru din gazda cererii — ceea ce funcționează atunci când fiecare spațiu de lucru are propriul domeniu. Furnizorii terți, însă, livrează evenimentele fiecărui tenant către **un** singur URL. Pentru acest caz, folosiți `serverRouteTriggerSettings`.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
Declanșatorul are două părți:
|
||||
|
||||
1. O funcție logică de **resolver** — declarată cu `serverRouteTriggerSettings` — rulează în **workspace-ul deținător** (workspace-ul care deține înregistrarea aplicației). Aceasta inspectează cererea de intrare și returnează `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`, alegând *atât* workspace-ul țintă, cât și funcția țintă. Resolver-ul este singurul punct de autorizare — URL-ul conține doar identificatorul resolver-ului. **Acesta este locul preferat pentru a verifica semnăturile cererilor**: resolver-ul rulează înaintea oricărui efect secundar, are acces la `rawBody` original și la headerele redirecționate și poate respinge fără a atinge vreodată ținta.
|
||||
2. O funcție logică **țintă** — o funcție logică obișnuită per-workspace — rulează apoi în workspace-ul rezolvat cu payload-ul returnat de resolver (sau payload-ul original al cererii dacă resolver-ul nu l-a transformat). Valoarea returnată devine răspunsul HTTP.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Funcția este accesibilă la:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
Ambii identificatori sunt `universalIdentifier` din manifestul dvs. — cel al înregistrării aplicației și al acestei funcții logice. Înregistrați acel URL la furnizor.
|
||||
Endpoint-ul este accesibil la:
|
||||
|
||||
**Rezolvarea spațiului de lucru.** Deoarece un singur endpoint deservește fiecare spațiu de lucru, integrarea dvs. trebuie să plaseze `workspaceId` țintă undeva în livrare, iar `workspaceIdResolver.{ source, path }` îi indică platformei de unde să îl citească:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| Câmp | Valori | Notițe |
|
||||
| -------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `source` | `body` \| `query` \| `header` | `body` citește JSON-ul deja parsificat. `query` este cel mai universal — de obicei controlați URL-ul de callback pe care îl înregistrați, așa că adăugați `?twentyWorkspaceId=…`. |
|
||||
| `path` | dot-path, de ex. `metadata.twentyWorkspaceId` | Restricționat la segmente alfanumerice / `_` / `-`; cheile prototype sunt respinse. |
|
||||
Identificatorul este `universalIdentifier` al resolver-ului din manifestul dvs. Înregistrați acel URL la furnizor.
|
||||
|
||||
Valoarea rezolvată trebuie să fie un UUID de spațiu de lucru valid **și** aplicația dvs. trebuie să fie instalată în acel spațiu de lucru, altfel cererea este respinsă înainte ca funcția să ruleze.
|
||||
**Contractul resolver-ului.** Tipul `LogicFunctionConfig` din SDK impune acest lucru la compilare: de îndată ce setați `serverRouteTriggerSettings`, handler-ul este constrâns să returneze `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (sau un `Promise` al acestuia). `workspaceId` trebuie să fie un workspace în care funcția țintă este instalată, altfel cererea este respinsă cu `404`.
|
||||
|
||||
| Câmp | Tip | Notițe |
|
||||
| ---------------------------------------- | ------------------- | --------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | UUID-ul workspace-ului în care va rula ținta. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` al funcției logice care trebuie invocată în acel workspace. |
|
||||
| `payload` | `object` (opțional) | Dacă este setat, înlocuiește corpul cererii trimis către țintă. |
|
||||
|
||||
<Warning>
|
||||
**Verificarea semnăturii este responsabilitatea dvs.** Platforma nu verifică semnăturile webhook pentru acest declanșator — doar rezolvă spațiul de lucru și rulează funcția. Handlerul dvs. trebuie să verifice singur semnătura folosind `event.rawBody` și headerele pe care le-ați enumerat în `forwardedRequestHeaders`, comparând cu un secret stocat ca variabilă de server/aplicație. Verificați întotdeauna **înainte** de orice efect secundar și folosiți o comparație în timp constant.
|
||||
**Verificarea semnăturii este responsabilitatea dvs. — verificați în resolver.** Platforma nu verifică semnăturile cererilor. Resolver-ul este locul recomandat pentru a face acest lucru: rulează primul, cu acces la `event.rawBody` și la headerele pe care le-ați enumerat în `forwardedRequestHeaders`, iar o eroare aruncată (sau orice `workspaceId` care nu se potrivește) oprește livrarea înainte ca ținta să fie invocată. Dacă, în schimb, mutați verificarea în funcția țintă, funcția țintă trebuie să aibă grijă să nu piardă `rawBody` și headerele — adică resolver-ul nu trebuie să returneze un `payload`. Verificați întotdeauna **înainte** de orice efect secundar și folosiți o comparație în timp constant.
|
||||
</Warning>
|
||||
|
||||
Majoritatea furnizorilor semnează cu HMAC-SHA256; părțile care diferă sunt numele headerului, codificarea digestului și șirul de payload semnat. Câteva exemple:
|
||||
Pentru semnăturile cererilor, majoritatea furnizorilor semnează cu HMAC-SHA256; părțile care diferă sunt numele headerului, codificarea digestului și șirul de payload semnat. Câteva exemple:
|
||||
|
||||
| Furnizor | Headere de redirecționat | Șir semnat | Digest |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | -------------------------------------------------------------------- |
|
||||
@@ -230,31 +284,10 @@ Majoritatea furnizorilor semnează cu HMAC-SHA256; părțile care diferă sunt n
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (cu prefixul `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
Exemplul de resolver de mai sus arată deja fluxul GitHub HMAC-SHA256 — adaptați numele headerului, codificarea digestului și șirul de payload semnat în funcție de furnizorul cu care vă integrați.
|
||||
|
||||
<Note>
|
||||
Funcția rulează **sincron**, iar valoarea returnată devine răspunsul HTTP, astfel încât furnizorii văd codul de stare și pot reîncerca pentru coduri non-2xx. Mențineți handler-ele rapide — unii furnizori (de ex. Slack) expiră după câteva secunde. Deoarece funcția rulează înainte ca semnătura să fie verificată, protejați acest endpoint cu limitare de rată la edge.
|
||||
Ținta rulează **sincron**, iar valoarea returnată devine răspunsul HTTP, astfel încât apelanții văd codul de stare și pot reîncerca pentru coduri non-2xx. Mențineți ambii handler-i rapizi — unii furnizori (de ex. Slack) expiră după câteva secunde. Deoarece resolver-ul este accesibil ca endpoint public, protejați-l cu limitare de rată la marginea infrastructurii dvs.
|
||||
</Note>
|
||||
|
||||
#### Payload-ul declanșatorului de eveniment al bazei de date
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
Front-компоненты выполняются в браузере в изолированном Web Worker, в то время как [логические функции](/l/ru/developers/extend/apps/logic/logic-functions) выполняются на стороне сервера. Между ними нет прямого внутрипроцессного вызова — вместо этого front-компонент обращается к логической функции по HTTP.
|
||||
|
||||
Логическая функция, объявленная с `httpRouteTriggerSettings`, доступна по эндпоинту `/s/` по адресу `${TWENTY_API_URL}/s\<path>`. Ваш front-компонент вызывает этот маршрут с помощью `RestApiClient` из `twenty-client-sdk/rest`, который аутентифицируется с использованием `TWENTY_APP_ACCESS_TOKEN`, который Twenty внедряет в worker.
|
||||
Логическая функция, объявленная с `httpRouteTriggerSettings`, доступна по HTTP по своему пути маршрута. Twenty внедряет в воркер базовый URL, с которого обслуживаются ваши функции, в виде `TWENTY_FUNCTIONS_URL` вместе с `TWENTY_APP_ACCESS_TOKEN`, который аутентифицирует вызов. Пока что нет отдельного клиентского SDK для вызова ваших собственных функций, поэтому вызывайте их с помощью обычного `fetch`:
|
||||
|
||||
`RestApiClient` создан именно для этого. Он считывает `TWENTY_API_URL` и `TWENTY_APP_ACCESS_TOKEN` из окружения worker, добавляет заголовок `Authorization: Bearer`, сериализует и парсит JSON и выбрасывает `RestApiClientError`, когда токен или URL отсутствуют или ответ не является 2xx — чтобы вам не приходилось реализовывать этот шаблонный код в каждом компоненте.
|
||||
> **В Twenty Cloud логические функции с HTTP-триггером обслуживаются на выделенном домене для каждого рабочего пространства** по адресу `https://\<your-workspace-subdomain>.twenty.com\<path>` — именно к этому и разрешается `TWENTY_FUNCTIONS_URL`. Для внешних вызовов скопируйте точный URL из настроек **HTTP trigger** функции или на вкладке **Settings** приложения.
|
||||
|
||||
<Warning>
|
||||
Устаревший маршрут функции `/s/` **не рекомендуется к использованию** и будет **деактивирован 2026-07-24**. Вместо этого используйте `TWENTY_FUNCTIONS_URL` (выше) и перенесите все жестко заданные URL вида `/s/` до этой даты. Маршрут `/s/` по-прежнему доступен при самостоятельном размещении (self-hosting).
|
||||
</Warning>
|
||||
|
||||
Безголовый front-компонент может выполнить вызов при монтировании через компонент `Command`, а затем автоматически размонтироваться:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
Путь, передаваемый клиенту, — это общедоступный путь маршрута: значение `httpRouteTriggerSettings.path` для логической функции с префиксом `/s`. Сохраните `isAuthRequired: true`; клиент передает токен доступа к приложению, который Twenty выпускает для вашего компонента:
|
||||
Путь, добавляемый к `TWENTY_FUNCTIONS_URL`, — это значение `httpRouteTriggerSettings.path` логической функции. Сохраните `isAuthRequired: true`; `TWENTY_APP_ACCESS_TOKEN`, который Twenty выпускает для вашего компонента, аутентифицирует запрос:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` и `TWENTY_APP_ACCESS_TOKEN` внедряются автоматически — см. [переменные приложения](#application-variables). Поскольку секретные переменные приложения никогда не раскрываются front-компонентам, храните ключи API и другую конфиденциальную логику в логической функции, а не во front-компоненте.
|
||||
`TWENTY_FUNCTIONS_URL` и `TWENTY_APP_ACCESS_TOKEN` внедряются автоматически — см. [переменные приложения](#application-variables). Поскольку секретные переменные приложения никогда не раскрываются front-компонентам, храните ключи API и другую конфиденциальную логику в логической функции, а не во front-компоненте.
|
||||
</Note>
|
||||
|
||||
### Справочник по RestApiClient
|
||||
### Вызов REST API Twenty
|
||||
|
||||
Импортируйте `RestApiClient` из `twenty-client-sdk/rest`. Он принадлежит к тому же семейству клиентов, что и `CoreApiClient` и `MetadataApiClient`, но предназначен для HTTP-маршрутов вашего приложения вместо GraphQL API.
|
||||
Чтобы читать или изменять записи Twenty из фронт-компонента, используйте `RestApiClient` из `twenty-client-sdk/rest`. Он принадлежит к тому же семейству клиентов, что и `CoreApiClient` и `MetadataApiClient`, но нацелен на REST API Twenty (`/rest/...`) вместо GraphQL API, считывая базовый URL из `TWENTY_API_URL`.
|
||||
|
||||
| Метод | Описание |
|
||||
| --------------------------------- | ----------------------------------------- |
|
||||
@@ -280,7 +285,7 @@ export default defineLogicFunction({
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ export default defineFrontComponent({
|
||||
|
||||
Следующие системные переменные всегда доступны через `process.env`:
|
||||
|
||||
| Переменная | Описание |
|
||||
| ------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `TWENTY_API_URL` | Базовый URL API Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Краткоживущий токен с областью действия, ограниченной ролью вашего приложения |
|
||||
| Переменная | Описание |
|
||||
| ------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `TWENTY_FUNCTIONS_URL` | Базовый URL, с которого обслуживаются HTTP-логические функции вашего приложения (используется `RestApiClient`) |
|
||||
| `TWENTY_API_URL` | Базовый URL основного API Twenty |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Краткоживущий токен с областью действия, ограниченной ролью вашего приложения |
|
||||
|
||||
## API взаимодействия с хостом
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export default defineLogicFunction({
|
||||
* **cron**: Запускает вашу функцию по расписанию с использованием выражения CRON.
|
||||
* **databaseEvent**: Запускается при событиях жизненного цикла объектов рабочего пространства. Когда операция события — `updated`, можно указать конкретные поля для отслеживания в массиве `updatedFields`. Если оставить не заданным или пустым, любое обновление будет вызывать функцию.
|
||||
> например, `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook**: получает входящие вебхуки от стороннего сервиса (Stripe, GitHub, Svix, …) на единственной конечной точке в области регистрации и определяет целевое рабочее пространство из полезной нагрузки. См. [триггер серверного вебхука](#server-webhook-trigger).
|
||||
* **serverRoute**: открывает один HTTP-маршрут в области регистрации. Функция-резолвер (объявленная с помощью `serverRouteTriggerSettings`) выполняется в рабочем пространстве-владельце и возвращает целевое рабочее пространство И целевую логическую функцию для маршрутизации; платформа затем запускает эту **целевую** функцию и возвращает ее ответ. См. [триггер серверного маршрута](#server-route-trigger).
|
||||
|
||||
<Note>
|
||||
Вы также можете вручную выполнить функцию с помощью CLI:
|
||||
@@ -172,54 +172,108 @@ const handler = async (event: RoutePayload) => {
|
||||
Код состояния должен быть допустимым кодом состояния HTTP (в диапазоне от 100 до 599). Имена заголовков ответа сравниваются без учета регистра.
|
||||
</Note>
|
||||
|
||||
#### Серверный триггер вебхука
|
||||
#### Триггер серверного маршрута
|
||||
|
||||
`httpRouteTriggerSettings` предоставляет функцию по пути `/s/` и определяет рабочее пространство из хоста запроса — это работает, когда у каждого рабочего пространства свой домен. Поставщики сторонних сервисов, однако, отправляют события всех арендаторов на **один** URL вебхука. В этом случае используйте `serverWebhookTriggerSettings`: функция доступна на конечной точке в области регистрации, а рабочее пространство определяется из полезной нагрузки.
|
||||
`httpRouteTriggerSettings` предоставляет функцию по пути `/s/` и определяет рабочее пространство из хоста запроса — это работает, когда у каждого рабочего пространства свой домен. Поставщики сторонних сервисов, однако, отправляют события всех арендаторов на **один** URL. В этом случае используйте `serverRouteTriggerSettings`.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
Триггер состоит из двух частей:
|
||||
|
||||
1. Логическая функция-**резолвер** — объявляется с помощью `serverRouteTriggerSettings` — выполняется в вашем **рабочем пространстве-владельце** (рабочем пространстве, которому принадлежит регистрация приложения). Она анализирует входящий запрос и возвращает `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`, выбирая *и* целевое рабочее пространство, и целевую функцию. Резолвер является единой точкой авторизации — URL содержит только идентификатор резолвера. **Это предпочтительное место для проверки подписей запросов**: резолвер выполняется до любых побочных эффектов, имеет доступ к исходным `rawBody` и переадресованным заголовкам и может отклонить запрос, не обращаясь к целевой функции.
|
||||
2. **Целевая** логическая функция — обычная логическая функция на рабочее пространство — затем выполняется в определенном рабочем пространстве с полезной нагрузкой, возвращенной резолвером (или с исходной полезной нагрузкой запроса, если резолвер ее не преобразовал). Ее возвращаемое значение становится HTTP-ответом.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Функция доступна по адресу:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
Оба идентификатора — это `universalIdentifier` из вашего манифеста: регистрации приложения и этой логической функции. Зарегистрируйте этот URL у поставщика.
|
||||
Конечная точка доступна по адресу:
|
||||
|
||||
**Определение рабочего пространства.** Поскольку одна конечная точка обслуживает все рабочие пространства, ваша интеграция должна поместить целевой `workspaceId` в передаваемые данные, а `workspaceIdResolver.{ source, path }` указывает платформе, откуда его прочитать:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| Поле | Значения | Заметки |
|
||||
| -------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `source` | `body` \| `query` \| `header` | `body` читает разобранный JSON. `query` — самый универсальный вариант: вы обычно контролируете URL обратного вызова, который регистрируете, поэтому добавьте `?twentyWorkspaceId=…`. |
|
||||
| `path` | точечный путь, например `metadata.twentyWorkspaceId` | Ограничено сегментами из буквенно-цифровых символов / `_` / `-`; ключи прототипа отклоняются. |
|
||||
Идентификатор — это `universalIdentifier` резолвера из вашего манифеста. Зарегистрируйте этот URL у поставщика.
|
||||
|
||||
Определённое значение должно быть действительным UUID рабочего пространства, и ваше приложение должно быть установлено в этом рабочем пространстве, иначе запрос будет отклонён до запуска функции.
|
||||
**Контракт резолвера.** Тип `LogicFunctionConfig` в SDK обеспечивает это на этапе компиляции: как только вы задаете `serverRouteTriggerSettings`, ваш обработчик обязан возвращать `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (или `Promise` этого объекта). `workspaceId` должен указывать на рабочее пространство, в котором установлена целевая функция, иначе запрос будет отклонен с кодом `404`.
|
||||
|
||||
| Поле | Тип | Заметки |
|
||||
| ---------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | UUID рабочего пространства, в котором будет выполняться целевая функция. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` логической функции, которую нужно вызвать в этом рабочем пространстве. |
|
||||
| `payload` | `object` (необязательный) | Если задан, заменяет тело запроса, отправляемое целевой функции. |
|
||||
|
||||
<Warning>
|
||||
**Проверка подписи — ваша ответственность.** Платформа не проверяет подписи вебхуков для этого триггера — она только определяет рабочее пространство и запускает вашу функцию. Ваш обработчик должен самостоятельно проверить подпись, используя `event.rawBody` и заголовки, перечисленные в `forwardedRequestHeaders`, сравнивая с секретом, хранящимся как серверная/приложенческая переменная. Всегда выполняйте проверку **до** любых побочных эффектов и используйте сравнение с постоянным временем выполнения.
|
||||
**Ответственность за проверку подписи лежит на вас — выполняйте проверку в резолвере.** Платформа не проверяет подписи запросов. Резолвер — рекомендуемое место для этого: он выполняется первым, имеет доступ к `event.rawBody` и заголовкам, которые вы указали в `forwardedRequestHeaders`, и выброшенная ошибка (или любой `workspaceId`, не соответствующий ожидаемому) останавливает диспетчеризацию до вызова целевой функции. Если вместо этого вы перенесете проверку в целевую функцию, целевая функция должна позаботиться о том, чтобы не потерять `rawBody` и заголовки — то есть резолвер не должен возвращать `payload`. Всегда выполняйте проверку **до** любых побочных эффектов и используйте сравнение с постоянным временем выполнения.
|
||||
</Warning>
|
||||
|
||||
Большинство провайдеров подписывают с помощью HMAC-SHA256; различаются имя заголовка, кодировка дайджеста и строка подписываемой полезной нагрузки. Несколько примеров:
|
||||
Для подписей запросов большинство провайдеров используют HMAC-SHA256; различаются имя заголовка, кодировка дайджеста и строка подписываемой полезной нагрузки. Несколько примеров:
|
||||
|
||||
| Провайдер | Заголовки для пересылки | Подписываемая строка | Дайджест |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | ----------------------------------------------------------------- |
|
||||
@@ -229,31 +283,10 @@ POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniv
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Слэк | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (с префиксом `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
Приведенный выше пример резолвера уже показывает поток GitHub HMAC-SHA256 — адаптируйте имя заголовка, кодировку дайджеста и строку подписываемой полезной нагрузки в соответствии с провайдером, с которым вы интегрируетесь.
|
||||
|
||||
<Note>
|
||||
Функция выполняется **синхронно**, и возвращаемое вами значение становится HTTP-ответом, поэтому провайдеры видят ваш статус-код и могут повторить запрос при не-2xx коде. Делайте обработчики быстрыми — некоторые провайдеры (например, Slack) прерывают запрос через несколько секунд. Поскольку функция выполняется до проверки подписи, защитите эту конечную точку ограничением частоты на периметре (edge).
|
||||
Целевая функция выполняется **синхронно**, и ее возвращаемое значение становится HTTP-ответом, поэтому вызывающая сторона видит ваш статус-код и может повторить запрос при не-2xx коде. Делайте оба обработчика быстрыми — некоторые провайдеры (например, Slack) прерывают запрос через несколько секунд. Поскольку резолвер доступен как публичная конечная точка, защитите его с помощью ограничения частоты запросов (rate limiting) на вашем периметре (edge).
|
||||
</Note>
|
||||
|
||||
#### Полезная нагрузка триггера события базы данных
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
Ön bileşenler, tarayıcı tarafında izole bir Web Worker içinde çalışırken, [mantık işlevleri](/l/tr/developers/extend/apps/logic/logic-functions) sunucu tarafında çalışır. İkisi arasında doğrudan, işlem içi bir çağrı yoktur — bunun yerine, bir ön bileşen bir mantık işlevine HTTP üzerinden erişir.
|
||||
|
||||
`httpRouteTriggerSettings` ile bildirilen bir mantık işlevi, `${TWENTY_API_URL}/s\<path>` altındaki `/s/` uç noktasında sunulur. Ön bileşeniniz bu rotayı, Twenty tarafından Worker'a enjekte edilen `TWENTY_APP_ACCESS_TOKEN` ile kimlik doğrulayan `twenty-client-sdk/rest` içindeki `RestApiClient` ile çağırır.
|
||||
`httpRouteTriggerSettings` ile bildirilen bir mantık işlevi, rota yolunda HTTP üzerinden erişilebilir durumdadır. Twenty, işlevlerinizin sunulduğu temel URL’yi, çağrıyı kimlik doğrulayan `TWENTY_APP_ACCESS_TOKEN` ile birlikte, worker’a `TWENTY_FUNCTIONS_URL` olarak enjekte eder. Kendi işlevlerinizi çağırmak için henüz özel bir SDK istemcisi yoktur, bu yüzden onları basit bir `fetch` ile çağırın:
|
||||
|
||||
`RestApiClient` tam da bunun için oluşturulmuştur. Worker ortamından `TWENTY_API_URL` ve `TWENTY_APP_ACCESS_TOKEN` değerlerini okur, `Authorization: Bearer` başlığını ekler, JSON'u serileştirip ayrıştırır ve belirteç veya URL eksik olduğunda ya da yanıt 2xx dışı olduğunda bir `RestApiClientError` fırlatır — böylece bu şablon kodunu her bileşende yeniden uygulamak zorunda kalmazsınız.
|
||||
> **Twenty Cloud üzerinde, HTTP ile tetiklenen mantık işlevleri, çalışma alanı başına ayrılmış özel bir etki alanında** `https://\<your-workspace-subdomain>.twenty.com\<path>` adresinde sunulur — bu, `TWENTY_FUNCTIONS_URL`'ün tam olarak çözümlendiği değerdir. Harici çağrıcılar için, tam URL’yi işlevin **HTTP trigger** ayarlarından veya uygulamanın **Settings** sekmesinden kopyalayın.
|
||||
|
||||
<Warning>
|
||||
Eski `/s/` fonksiyon rotası **kullanımdan kaldırılmıştır (deprecated)** ve **2026-07-24 tarihinde devre dışı bırakılacaktır**. Bunun yerine yukarıdaki `TWENTY_FUNCTIONS_URL` değerini kullanın ve o tarihten önce sabit (hard-coded) tüm `/s/` URL’lerini taşıyın. `/s/` rotası, self-hosting için kullanılabilir olmaya devam eder.
|
||||
</Warning>
|
||||
|
||||
Başsız bir ön bileşen, çağrıyı `Command` bileşeni aracılığıyla mount sırasında çalıştırabilir ve ardından otomatik olarak unmount olabilir:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
İstemciye iletilen yol, rotanın herkese açık yoludur — mantık fonksiyonunun `httpRouteTriggerSettings.path` değeri, başına `/s` eklenmiş hâlidir. `isAuthRequired: true` değerini koruyun; istemci, Twenty'nin bileşeniniz için oluşturduğu uygulama erişim jetonunu sağlar:
|
||||
`TWENTY_FUNCTIONS_URL` değerine eklenen yol (path), mantık fonksiyonunun `httpRouteTriggerSettings.path` değeridir. `isAuthRequired: true` ayarını koruyun; bileşeniniz için Twenty'nin oluşturduğu `TWENTY_APP_ACCESS_TOKEN` isteği kimlik doğrular:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` ve `TWENTY_APP_ACCESS_TOKEN` otomatik olarak enjekte edilir — bkz. [Uygulama değişkenleri](#application-variables). Gizli uygulama değişkenleri asla ön bileşenlere açığa çıkarılmadığından, API anahtarlarını ve diğer hassas mantığı ön bileşende değil, mantık işlevinin içinde tutun.
|
||||
`TWENTY_FUNCTIONS_URL` ve `TWENTY_APP_ACCESS_TOKEN` otomatik olarak enjekte edilir — bkz. [Uygulama değişkenleri](#application-variables). Gizli uygulama değişkenleri asla ön bileşenlere açığa çıkarılmadığından, API anahtarlarını ve diğer hassas mantığı ön bileşende değil, mantık işlevinin içinde tutun.
|
||||
</Note>
|
||||
|
||||
### RestApiClient referansı
|
||||
### Twenty REST API'sini çağırma
|
||||
|
||||
`RestApiClient` öğesini `twenty-client-sdk/rest` içinden içe aktarın. `CoreApiClient` ve `MetadataApiClient` ile aynı istemci ailesine aittir, ancak GraphQL API yerine uygulamanızın HTTP rotalarını hedefler.
|
||||
Bir front bileşeninden Twenty kayıtlarını okumak veya yazmak için, `twenty-client-sdk/rest` paketinden `RestApiClient` kullanın. `CoreApiClient` ve `MetadataApiClient` ile aynı istemci ailesine aittir, ancak GraphQL API yerine Twenty REST API'sini (`/rest/...`) hedefler ve temel URL'sini `TWENTY_API_URL` değerinden okur.
|
||||
|
||||
| Yöntem | Açıklama |
|
||||
| --------------------------------- | ---------------------------------------- |
|
||||
@@ -280,7 +285,7 @@ Temel URL ve jeton varsayılan olarak ortamdan çözümlenir. Gerektiğinde —
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ Gizli değişkenler (`isSecret: true`) ön uç bileşenlere açıklanmaz. Bunlar
|
||||
|
||||
Aşağıdaki sistem değişkenleri her zaman `process.env` aracılığıyla kullanılabilir:
|
||||
|
||||
| Değişken | Açıklama |
|
||||
| ------------------------- | --------------------------------------------------------- |
|
||||
| `TWENTY_API_URL` | Twenty API'nin temel URL'si |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Uygulamanızın rolüyle sınırlanan kısa ömürlü bir belirteç |
|
||||
| Değişken | Açıklama |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `TWENTY_FUNCTIONS_URL` | Uygulamanızın HTTP mantık fonksiyonlarının sunulduğu temel URL (`RestApiClient` tarafından kullanılır) |
|
||||
| `TWENTY_API_URL` | Twenty çekirdek API'sinin temel URL'si |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | Uygulamanızın rolüyle sınırlanan kısa ömürlü bir belirteç |
|
||||
|
||||
## Host iletişim API'si
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Arayüzsüz bir ön uç bileşeninden rota tarafından tetiklenen mantık fonksi
|
||||
* **cron**: Bir CRON ifadesi kullanarak işlevinizi bir zamanlamayla çalıştırır.
|
||||
* **databaseEvent**: Çalışma alanı nesnesi yaşam döngüsü olaylarında çalışır. Olay işlemi `updated` olduğunda, dinlenecek belirli alanlar `updatedFields` dizisinde belirtilebilir. Tanımsız veya boş bırakılırsa, herhangi bir güncelleme işlevi tetikler.
|
||||
> örn. `person.updated`, `*.created`, `company.*`
|
||||
* **serverWebhook**: Üçüncü taraf bir hizmetten (Stripe, GitHub, Svix, …) gelen webhook'ları alır tek bir kayıt kapsamlı uç noktadan ve hedef çalışma alanını payload'dan çözümler. [Sunucu webhook tetikleyicisine](#server-webhook-trigger) bakın.
|
||||
* **serverRoute**: Kayıt kapsamına sahip tek bir HTTP rotasını erişime açar. Bir **resolver** fonksiyonu (`serverRouteTriggerSettings` ile tanımlanır) sahip çalışma alanında çalışır ve hedef çalışma alanını VE yönlendirilecek hedef mantık fonksiyonunu döndürür; platform daha sonra bu **hedef** fonksiyonu çalıştırır ve yanıtını döndürür. [Sunucu rota tetikleyicisine](#server-route-trigger) bakın.
|
||||
|
||||
<Note>
|
||||
Bir işlevi CLI kullanarak manuel olarak da çalıştırabilirsiniz:
|
||||
@@ -173,54 +173,108 @@ Güvenlik nedenleriyle, yanıt üstbilgileri bir izin listesiyle sınırlandır
|
||||
Durum kodu geçerli bir HTTP durum kodu olmalıdır (100 ile 599 arasında). Yanıt üstbilgisi adları büyük/küçük harfe duyarsız olarak eşleştirilir.
|
||||
</Note>
|
||||
|
||||
#### Sunucu webhook tetikleyicisi
|
||||
#### Sunucu rota tetikleyicisi
|
||||
|
||||
`httpRouteTriggerSettings`, `/s/` altında bir fonksiyon sunar ve çalışma alanını istek ana bilgisayarından çözümler — bu da her çalışma alanının kendi alan adına sahip olduğu durumda işe yarar. Üçüncü taraf sağlayıcılar ise, her kiracının olaylarını **tek** webhook URL’sine iletir. Bu durum için `serverWebhookTriggerSettings` kullanın: fonksiyona kayıt kapsamlı bir uç noktadan erişilebilir ve çalışma alanı, payload’dan çözümlenir.
|
||||
`httpRouteTriggerSettings`, `/s/` altında bir fonksiyon sunar ve çalışma alanını istek ana bilgisayarından çözümler — bu da her çalışma alanının kendi alan adına sahip olduğu durumda işe yarar. Üçüncü taraf sağlayıcılar ise, her kiracının olaylarını **tek** URL’ye iletir. Bu durum için `serverRouteTriggerSettings` kullanın.
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
Tetikleyicinin iki parçası vardır:
|
||||
|
||||
1. Bir **resolver** mantık fonksiyonu — `serverRouteTriggerSettings` ile tanımlanır — **sahip çalışma alanınızda** (uygulama kaydına sahip olan çalışma alanı) çalışır. Gelen isteği inceler ve `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }` döndürerek hem hedef çalışma alanını hem de hedef fonksiyonu seçer. Resolver, yetkilendirmenin tek noktasıdır — URL yalnızca resolver'ın tanımlayıcısını taşır. **İstek imzalarını doğrulamak için tercih edilen yer burasıdır**: resolver, herhangi bir yan etkiden önce çalışır, orijinal `rawBody` ve iletilen başlıklara erişebilir ve hedefe hiç dokunmadan isteği reddedebilir.
|
||||
2. Ardından bir **hedef** mantık fonksiyonu — her çalışma alanı için normal bir mantık fonksiyonu — resolver tarafından döndürülen payload ile (veya resolver onu dönüştürmediyse orijinal istek payload'ı ile) çözümlenen çalışma alanında çalışır. Döndürdüğü değer HTTP yanıtı olur.
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Fonksiyona şu adresten erişilebilir:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
Her iki tanımlayıcı da manifest’inizdeki `universalIdentifier` değerleridir — uygulama kaydınınki ve bu mantık fonksiyonununki. Bu URL’yi sağlayıcıya kaydedin.
|
||||
Uç noktaya şu adresten erişilebilir:
|
||||
|
||||
**Çalışma alanı çözümleme.** Tek bir uç nokta her çalışma alanına hizmet verdiğinden, entegrasyonunuz hedef `workspaceId` değerini teslimatta bir yere koymalı ve `workspaceIdResolver.{ source, path }` platforma bunun nereden okunacağını söyler:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| Alan | Değerler | Notlar |
|
||||
| -------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `source` | `body` \| `query` \| `header` | `body`, ayrıştırılmış JSON’u okur. `query` en evrensel seçenektir — genellikle kaydettiğiniz callback URL’sini kontrol edersiniz, bu yüzden `?twentyWorkspaceId=…` ekleyin. |
|
||||
| `path` | nokta-yolu, örn. `metadata.twentyWorkspaceId` | Alfasayısal / `_` / `-` segmentleriyle sınırlandırılmıştır; prototype anahtarları reddedilir. |
|
||||
Tanımlayıcı, manifest'inizdeki resolver'ın `universalIdentifier` değeridir. Bu URL’yi sağlayıcıya kaydedin.
|
||||
|
||||
Çözümlenen değer geçerli bir çalışma alanı UUID’si **olmalı** ve uygulamanızın o çalışma alanına yüklü olması gerekir, aksi halde istek, fonksiyon çalışmadan önce reddedilir.
|
||||
**Resolver sözleşmesi.** SDK'nin `LogicFunctionConfig` türü bunu derleme zamanında zorunlu kılar: `serverRouteTriggerSettings`'i ayarladığınız anda, işleyicinizin `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (veya bunun bir `Promise`'i) döndürmesi gerekir. `workspaceId`, hedef fonksiyonun kurulu olduğu bir çalışma alanı olmalıdır, aksi takdirde istek `404` ile reddedilir.
|
||||
|
||||
| Alan | Tür | Notlar |
|
||||
| ---------------------------------------- | ----------------------- | -------------------------------------------------------------------------------- |
|
||||
| `workspaceId` | `string` | Hedefin çalışacağı çalışma alanının UUID'si. |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | O çalışma alanında çağrılacak mantık fonksiyonunun `universalIdentifier` değeri. |
|
||||
| `payload` | `object` (isteğe bağlı) | Ayarlanırsa, hedefe gönderilen istek gövdesinin yerini alır. |
|
||||
|
||||
<Warning>
|
||||
**İmza doğrulama sizin sorumluluğunuzdadır.** Platform bu tetikleyici için webhook imzalarını doğrulamaz — yalnızca çalışma alanını çözümler ve fonksiyonunuzu çalıştırır. İşleyiciniz imzayı, `event.rawBody` ve `forwardedRequestHeaders` içinde listelediğiniz başlıkları kullanarak, sunucu/uygulama değişkeni olarak saklanan bir gizli anahtarla karşılaştırıp kendisi doğrulamalıdır. Her zaman herhangi bir yan etkiden **önce** doğrulayın ve sabit süreli bir karşılaştırma kullanın.
|
||||
**İmza doğrulama sizin sorumluluğunuzdadır — doğrulamayı resolver içinde yapın.** Platform istek imzalarını doğrulamaz. Bunu yapmak için önerilen yer resolver'dır: ilk olarak çalışır, `event.rawBody` ve `forwardedRequestHeaders` içinde listelediğiniz başlıklara erişir ve fırlatılan bir hata (veya eşleşmeyen herhangi bir `workspaceId`) hedef çağrılmadan önce yönlendirmeyi durdurur. Doğrulamayı bunun yerine hedefin içine iterseniz, hedef `rawBody` ve başlıkları kaybetmemeye dikkat etmelidir — yani resolver bir `payload` döndürmemelidir. Her zaman herhangi bir yan etkiden **önce** doğrulayın ve sabit süreli bir karşılaştırma kullanın.
|
||||
</Warning>
|
||||
|
||||
Çoğu sağlayıcı HMAC-SHA256 ile imzalar; farklı olan kısımlar başlık adı, özet kodlaması ve imzalanan payload dizesidir. Birkaç örnek:
|
||||
İstek imzaları için, sağlayıcıların çoğu HMAC-SHA256 ile imzalar; farklı olan kısımlar başlık adı, özet kodlaması ve imzalanan payload dizesidir. Birkaç örnek:
|
||||
|
||||
| Sağlayıcı | İletilecek başlıklar | İmzalanmış dize | Özet |
|
||||
| ---------------------------- | ------------------------------------------------------ | ---------------------------- | ---------------------------------------------------------------- |
|
||||
@@ -230,31 +284,10 @@ Her iki tanımlayıcı da manifest’inizdeki `universalIdentifier` değerleridi
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (`v0=` önekiyle) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
Yukarıdaki resolver örneği GitHub HMAC-SHA256 akışını zaten göstermektedir — tümleştirdiğiniz sağlayıcıya göre başlık adını, özet kodlamasını ve imzalanan payload dizesini uyarlayın.
|
||||
|
||||
<Note>
|
||||
Fonksiyon **senkron** olarak çalışır ve döndürdüğünüz değer HTTP yanıtı olur, bu nedenle sağlayıcılar durum kodunuzu görür ve 2xx olmayanlarda yeniden deneyebilir. İşleyicileri hızlı tutun — bazı sağlayıcılar (örn. Slack) birkaç saniye içinde zaman aşımına uğrar. Fonksiyon imza denetlenmeden önce çalıştığı için, bu uç noktayı edge’inizde hız sınırlamasıyla koruyun.
|
||||
Hedef **senkron** olarak çalışır ve döndürdüğü değer HTTP yanıtı olur, böylece çağıranlar durum kodunuzu görür ve 2xx olmayanlarda yeniden deneyebilir. Her iki işleyiciyi de hızlı tutun — bazı sağlayıcılar (örn. Slack) birkaç saniye içinde zaman aşımına uğrar. Resolver herkese açık bir uç nokta olarak erişilebilir olduğundan, onu edge'inizde hız sınırlama ile koruyun.
|
||||
</Note>
|
||||
|
||||
#### Veritabanı olay tetikleyicisi yükü
|
||||
|
||||
@@ -200,24 +200,29 @@ export default defineFrontComponent({
|
||||
|
||||
前端组件在沙盒 Web Worker 中于浏览器端运行,而[逻辑函数](/l/zh/developers/extend/apps/logic/logic-functions)在服务器端运行。 二者之间没有直接的进程内调用——前端组件通过 HTTP 访问逻辑函数。
|
||||
|
||||
使用 `httpRouteTriggerSettings` 声明的逻辑函数会通过 `/s/` 端点暴露在 `${TWENTY_API_URL}/s\<path>` 下。 你的前端组件使用来自 `twenty-client-sdk/rest` 的 `RestApiClient` 调用该路由,该客户端会使用 Twenty 注入到 worker 中的 `TWENTY_APP_ACCESS_TOKEN` 进行身份验证。
|
||||
使用 `httpRouteTriggerSettings` 声明的逻辑函数,可以通过其路由路径在 HTTP 上进行访问。 Twenty 会将提供你函数服务的基础 URL 作为 `TWENTY_FUNCTIONS_URL` 注入到 worker 中,同时注入用于对调用进行身份验证的 `TWENTY_APP_ACCESS_TOKEN`。 目前还没有用于调用你自定义函数的专用 SDK 客户端,因此请使用普通的 `fetch` 来调用它们:
|
||||
|
||||
`RestApiClient` 正是为这种场景而构建的。 它会从 worker 环境中读取 `TWENTY_API_URL` 和 `TWENTY_APP_ACCESS_TOKEN`,附加 `Authorization: Bearer` 请求头,对 JSON 进行序列化和解析,并在 token 或 URL 缺失或响应为非 2xx 时抛出 `RestApiClientError`——这样你就不必在每个组件中重复实现这些样板逻辑。
|
||||
> **在 Twenty Cloud 上,HTTP 触发的逻辑函数通过每个工作区的专用域名提供服务**,域名为 `https://\<your-workspace-subdomain>.twenty.com\<path>`——这正是 `TWENTY_FUNCTIONS_URL` 所解析到的地址。 对于外部调用方,请从函数的 **HTTP trigger** 设置或应用的 **Settings** 选项卡中复制准确的 URL。
|
||||
|
||||
<Warning>
|
||||
旧版的 `/s/` 函数路由已被**弃用**,并将于 **2026-07-24 停用**。 请改用上面的 `TWENTY_FUNCTIONS_URL`,并在该日期之前迁移所有硬编码的 `/s/` URL。 `/s/` 路由在自托管场景下仍可用。
|
||||
</Warning>
|
||||
|
||||
无头前端组件可以通过 `Command` 组件在挂载时执行调用,然后自动卸载:
|
||||
|
||||
```tsx src/front-components/sync-prs.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { Command } from 'twenty-sdk/command';
|
||||
import { RestApiClient } from 'twenty-client-sdk/rest';
|
||||
|
||||
const SyncPrs = () => {
|
||||
const execute = async () => {
|
||||
const client = new RestApiClient();
|
||||
|
||||
await client.post('/s/github/fetch-prs', {
|
||||
owner: 'twentyhq',
|
||||
repo: 'twenty',
|
||||
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -233,7 +238,7 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
传递给客户端的路径是该路由的公共路径——逻辑函数的 `httpRouteTriggerSettings.path`,并以 `/s` 作为前缀。 保持 `isAuthRequired: true`;客户端会为你的组件提供由 Twenty 签发的应用访问令牌:
|
||||
附加到 `TWENTY_FUNCTIONS_URL` 的路径是逻辑函数的 `httpRouteTriggerSettings.path`。 保持 `isAuthRequired: true`;Twenty 为你的组件生成的 `TWENTY_APP_ACCESS_TOKEN` 会对请求进行认证:
|
||||
|
||||
```ts src/logic-functions/fetch-prs.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -258,12 +263,12 @@ export default defineLogicFunction({
|
||||
```
|
||||
|
||||
<Note>
|
||||
`TWENTY_API_URL` 和 `TWENTY_APP_ACCESS_TOKEN` 会被自动注入——参见 [应用变量](#application-variables)。 由于机密应用变量永远不会暴露给前端组件,请将 API 密钥和其他敏感逻辑保留在逻辑函数中,而不是前端组件中。
|
||||
`TWENTY_FUNCTIONS_URL` 和 `TWENTY_APP_ACCESS_TOKEN` 会被自动注入——参见 [应用变量](#application-variables)。 由于机密应用变量永远不会暴露给前端组件,请将 API 密钥和其他敏感逻辑保留在逻辑函数中,而不是前端组件中。
|
||||
</Note>
|
||||
|
||||
### RestApiClient 参考
|
||||
### 调用 Twenty REST API
|
||||
|
||||
从 `twenty-client-sdk/rest` 中导入 `RestApiClient`。 它与 `CoreApiClient` 和 `MetadataApiClient` 属于同一客户端家族,但目标是你应用的 HTTP 路由,而不是 GraphQL API。
|
||||
要在前端组件中读取或写入 Twenty 记录,请使用来自 `twenty-client-sdk/rest` 的 `RestApiClient`。 它与 `CoreApiClient` 和 `MetadataApiClient` 属于同一客户端家族,但目标是 Twenty REST API(`/rest/...`),而不是 GraphQL API,其基础 URL 来自 `TWENTY_API_URL`。
|
||||
|
||||
| 方法 | 描述 |
|
||||
| --------------------------------- | ----------------- |
|
||||
@@ -280,7 +285,7 @@ export default defineLogicFunction({
|
||||
|
||||
```ts
|
||||
const client = new RestApiClient({
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://myworkspace.twenty.com',
|
||||
token: 'my-token',
|
||||
});
|
||||
```
|
||||
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
||||
const client = new RestApiClient();
|
||||
|
||||
try {
|
||||
const prs = await client.get('/s/github/fetch-prs', {
|
||||
query: { state: 'open' },
|
||||
const people = await client.get('/rest/people', {
|
||||
query: { limit: 10 },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RestApiClientError) {
|
||||
@@ -374,10 +379,11 @@ export default defineFrontComponent({
|
||||
|
||||
以下系统变量始终可以通过 `process.env` 获取:
|
||||
|
||||
| 变量 | 描述 |
|
||||
| ------------------------- | ------------------ |
|
||||
| `TWENTY_API_URL` | Twenty API 的基础 URL |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | 限定在你的应用角色范围内的短期令牌 |
|
||||
| 变量 | 描述 |
|
||||
| ------------------------- | -------------------------------------------- |
|
||||
| `TWENTY_FUNCTIONS_URL` | 提供你应用 HTTP 逻辑函数的基础 URL(由 `RestApiClient` 使用) |
|
||||
| `TWENTY_API_URL` | Twenty 核心 API 的基础 URL |
|
||||
| `TWENTY_APP_ACCESS_TOKEN` | 限定在你的应用角色范围内的短期令牌 |
|
||||
|
||||
## 宿主通信 API
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export default defineLogicFunction({
|
||||
* **cron**:使用 CRON 表达式按计划运行你的函数。
|
||||
* **databaseEvent**:在工作区对象生命周期事件上运行。 当事件操作为 `updated` 时,可以在 `updatedFields` 数组中指定要监听的特定字段。 如果未定义或为空,任何更新都会触发该函数。
|
||||
> 例如 `person.updated`、`*.created`、`company.*`
|
||||
* **serverWebhook**:从第三方服务(Stripe、GitHub、Svix 等)接收入站 Webhooks 在单个以注册为作用域的端点上,并从有效负载中解析目标工作区。 参见 [服务端 Webhook 触发器](#server-webhook-trigger)。
|
||||
* **serverRoute**:公开一个注册作用域的单一 HTTP 路由。 一个在所有者工作区中运行的 **resolver** 函数(使用 `serverRouteTriggerSettings` 声明)会返回目标工作区以及要分发到的目标逻辑函数;平台随后运行该**目标**函数并返回其响应。 参见 [服务端路由触发器](#server-route-trigger)。
|
||||
|
||||
<Note>
|
||||
你也可以使用 CLI 手动执行函数:
|
||||
@@ -173,54 +173,108 @@ const handler = async (event: RoutePayload) => {
|
||||
状态码必须是有效的 HTTP 状态码(介于 100 和 599 之间)。 响应头名称的匹配不区分大小写。
|
||||
</Note>
|
||||
|
||||
#### 服务端 Webhook 触发器
|
||||
#### 服务端路由触发器
|
||||
|
||||
`httpRouteTriggerSettings` 在 `/s/` 下暴露一个函数,并根据请求主机解析 workspace——这在每个 workspace 都有自己域名时有效。 然而,第三方服务商会将每个租户的事件发送到**同一个** webhook URL。 对于这种情况,请使用 `serverWebhookTriggerSettings`:该函数可在注册范围的端点访问,并且 workspace 将从负载中解析。
|
||||
`httpRouteTriggerSettings` 在 `/s/` 下暴露一个函数,并根据请求主机解析 workspace——这在每个 workspace 都有自己域名时有效。 然而,第三方服务商会将每个租户的事件发送到**同一个** URL。 在这种情况下,请使用 `serverRouteTriggerSettings`。
|
||||
|
||||
```ts src/logic-functions/handle-provider-webhook.logic-function.ts
|
||||
触发器由两部分组成:
|
||||
|
||||
1. 一个在 **所有者工作区**(拥有应用注册的工作区)中运行的 **resolver** 逻辑函数——使用 `serverRouteTriggerSettings` 声明。 它检查传入请求并返回 `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`,同时选择目标工作区和目标函数。 resolver 是唯一的授权点——URL 只携带 resolver 的标识符。 **这里是验证请求签名的首选位置**:resolver 在任何副作用之前运行,可以访问原始的 `rawBody` 和转发的请求头,并且可以在不触及目标的情况下直接拒绝请求。
|
||||
2. 一个 **target** 逻辑函数——一个常规的、按工作区划分的逻辑函数——随后在解析得到的工作区中运行,并使用 resolver 返回的负载(如果 resolver 未对其进行转换,则使用原始请求负载)。 它的返回值将成为 HTTP 响应。
|
||||
|
||||
```ts src/logic-functions/resolve-server-route.logic-function.ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
|
||||
// Runs in the owner workspace. Verifies the request signature, picks
|
||||
// which target function should handle the event, and returns the
|
||||
// workspace + target the platform should dispatch to.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Verify the signature yourself before doing anything (see below).
|
||||
// Return a non-2xx Response to make the provider retry.
|
||||
return { received: true };
|
||||
// Fail closed if the secret isn't configured — never fall back to an
|
||||
// empty key, which would let any caller forge a matching signature.
|
||||
const secret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not configured');
|
||||
}
|
||||
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
throw new Error('invalid signature');
|
||||
}
|
||||
|
||||
const body = (event.body ?? {}) as {
|
||||
metadata?: { twentyWorkspaceId?: string };
|
||||
type?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
workspaceId: body.metadata?.twentyWorkspaceId ?? '',
|
||||
// Route different event types to different target functions.
|
||||
targetLogicFunctionUniversalIdentifier:
|
||||
body.type === 'invoice.paid'
|
||||
? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid
|
||||
: 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event
|
||||
};
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-provider-webhook',
|
||||
name: 'resolve-server-route',
|
||||
handler,
|
||||
serverWebhookTriggerSettings: {
|
||||
workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
|
||||
forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'],
|
||||
serverRouteTriggerSettings: {
|
||||
forwardedRequestHeaders: ['x-hub-signature-256'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
该函数可在以下地址访问:
|
||||
```ts src/logic-functions/handle-invoice-paid.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier
|
||||
// Runs in the resolved workspace. The resolver has already authenticated
|
||||
// the request, so this handler can focus on the actual work.
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10',
|
||||
name: 'handle-invoice-paid',
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
这两个标识符都是来自清单的 `universalIdentifier`——即应用注册的 universalIdentifier 和此逻辑函数的 universalIdentifier。 在服务商处注册该 URL。
|
||||
该端点可在以下地址访问:
|
||||
|
||||
**Workspace 解析。** 由于一个端点为每个 workspace 提供服务,你的集成必须在传递内容中的某处放入目标 `workspaceId`,而 `workspaceIdResolver.{ source, path }` 用于告知平台从哪里读取它:
|
||||
```
|
||||
POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier
|
||||
```
|
||||
|
||||
| 字段 | 值 | 备注 |
|
||||
| ---- | ----------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `来源` | `body` \| `query` \| `header` | `body` 读取解析后的 JSON。 `query` 是最通用的——通常你可以控制所注册的回调 URL,因此可以追加 `?twentyWorkspaceId=…`。 |
|
||||
| `路径` | 点路径,例如 `metadata.twentyWorkspaceId` | 仅限字母数字 / `_` / `-` 片段;原型键将被拒绝。 |
|
||||
该标识符是清单(manifest)中 resolver 的 `universalIdentifier`。 在服务商处注册该 URL。
|
||||
|
||||
解析得到的值必须是有效的 workspace UUID,**并且** 你的应用必须已安装在该 workspace 中,否则请求会在函数运行前被拒绝。
|
||||
**Resolver 合约。** SDK 的 `LogicFunctionConfig` 类型在编译时强制执行这一点:一旦你设置了 `serverRouteTriggerSettings`,你的处理程序就被限制为返回 `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }`(或其 `Promise`)。 `workspaceId` 必须是已安装目标函数的工作区,否则请求会以 `404` 被拒绝。
|
||||
|
||||
| 字段 | 类型 | 备注 |
|
||||
| ---------------------------------------- | ------------ | -------------------------------------- |
|
||||
| `workspaceId` | `string` | 目标将运行所在的工作区 UUID。 |
|
||||
| `targetLogicFunctionUniversalIdentifier` | `string` | 要在该工作区中调用的逻辑函数的 `universalIdentifier`。 |
|
||||
| `payload` | `object`(可选) | 如果设置,将会替换发送到目标的请求正文。 |
|
||||
|
||||
<Warning>
|
||||
**签名验证由你负责。** 平台不会为此触发器验证 webhook 签名——它只会解析 workspace 并运行你的函数。 你的处理程序必须使用 `event.rawBody` 以及你在 `forwardedRequestHeaders` 中列出的请求头自行验证签名,并与作为服务器/应用变量存储的密钥进行比对。 始终在产生任何副作用**之前**进行验证,并使用常量时间比较。
|
||||
**签名验证由你负责——请在 resolver 中进行验证。** 平台不会验证请求签名。 resolver 是执行验证的推荐位置:它最先运行,可以访问 `event.rawBody` 以及你在 `forwardedRequestHeaders` 中列出的请求头,并且只要抛出错误(或返回任意不匹配的 `workspaceId`),就会在调用目标之前停止分发。 如果你反而将验证下推到 target 中,那么 target 必须小心不要丢失 `rawBody` 和请求头——也就是说,resolver 不应返回 `payload`。 始终在产生任何副作用**之前**进行验证,并使用常量时间比较。
|
||||
</Warning>
|
||||
|
||||
大多数服务商使用 HMAC-SHA256 进行签名;不同之处在于请求头名称、摘要编码方式以及被签名的负载字符串。 例如:
|
||||
对于请求签名,大多数服务商使用 HMAC-SHA256 进行签名;不同之处在于请求头名称、摘要编码方式以及被签名的负载字符串。 例如:
|
||||
|
||||
| 提供商 | 要转发的请求头 | 签名字符串 | 摘要 |
|
||||
| ------------------------- | ------------------------------------------------------ | ---------------------------- | ---------------------------------- |
|
||||
@@ -230,31 +284,10 @@ POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniv
|
||||
| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 |
|
||||
| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex(前缀为 `v0=`) |
|
||||
|
||||
```ts
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-hub-signature-256'] ?? '';
|
||||
const expected =
|
||||
'sha256=' +
|
||||
createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '')
|
||||
.update(event.rawBody ?? '')
|
||||
.digest('hex');
|
||||
|
||||
const a = Buffer.from(signature);
|
||||
const b = Buffer.from(expected);
|
||||
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||
return new Response({ error: 'invalid signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ...handle the verified event
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
上面的 resolver 示例已经展示了 GitHub 的 HMAC-SHA256 流程——请根据你要集成的服务商,调整请求头名称、摘要编码方式以及被签名的负载字符串。
|
||||
|
||||
<Note>
|
||||
该函数**同步**运行,你返回的值会成为 HTTP 响应,因此服务商可以看到你的状态码,并在非 2xx 时重试。 保持处理程序足够快速——某些服务商(例如 Slack)会在几秒内超时。 由于函数在检查签名前运行,请在边缘通过速率限制来保护此端点。
|
||||
target **同步**运行,其返回值会成为 HTTP 响应,因此调用方可以看到你的状态码,并在非 2xx 时进行重试。 保持两个处理程序都足够快速——某些服务商(例如 Slack)会在几秒内超时。 由于 resolver 可以作为公共端点访问,请在边缘(edge)对其进行速率限制保护。
|
||||
</Note>
|
||||
|
||||
#### 数据库事件触发器有效负载
|
||||
|
||||
Reference in New Issue
Block a user