@directus/api 32.0.1 → 32.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,11 @@ import type { RoleMap } from '../../types/rolemap.js';
5
5
  import { LocalAuthDriver } from './local.js';
6
6
  export declare class OAuth2AuthDriver extends LocalAuthDriver {
7
7
  client: Client;
8
- redirectUrl: string;
9
8
  config: Record<string, any>;
10
9
  roleMap: RoleMap;
11
10
  constructor(options: AuthDriverOptions, config: Record<string, any>);
12
11
  generateCodeVerifier(): string;
13
- generateAuthUrl(codeVerifier: string, prompt?: boolean): string;
12
+ generateAuthUrl(codeVerifier: string, prompt?: boolean, callbackUrl?: string): string;
14
13
  private fetchUserId;
15
14
  getUserID(payload: Record<string, any>): Promise<string>;
16
15
  login(user: User): Promise<void>;
@@ -16,40 +16,37 @@ import { AuthenticationService } from '../../services/authentication.js';
16
16
  import asyncHandler from '../../utils/async-handler.js';
17
17
  import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
18
18
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
19
+ import { getSchema } from '../../utils/get-schema.js';
19
20
  import { getSecret } from '../../utils/get-secret.js';
20
- import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
21
21
  import { verifyJWT } from '../../utils/jwt.js';
22
22
  import { Url } from '../../utils/url.js';
23
+ import { generateCallbackUrl } from '../utils/generate-callback-url.js';
24
+ import { isLoginRedirectAllowed } from '../utils/is-login-redirect-allowed.js';
23
25
  import { LocalAuthDriver } from './local.js';
24
- import { getSchema } from '../../utils/get-schema.js';
25
26
  export class OAuth2AuthDriver extends LocalAuthDriver {
26
27
  client;
27
- redirectUrl;
28
28
  config;
29
29
  roleMap;
30
30
  constructor(options, config) {
31
31
  super(options, config);
32
- const env = useEnv();
33
32
  const logger = useLogger();
34
33
  const { authorizeUrl, accessUrl, profileUrl, clientId, clientSecret, ...additionalConfig } = config;
35
34
  if (!authorizeUrl || !accessUrl || !profileUrl || !clientId || !clientSecret || !additionalConfig['provider']) {
36
35
  logger.error('Invalid provider config');
37
36
  throw new InvalidProviderConfigError({ provider: additionalConfig['provider'] });
38
37
  }
39
- const redirectUrl = new Url(env['PUBLIC_URL']).addPath('auth', 'login', additionalConfig['provider'], 'callback');
40
- this.redirectUrl = redirectUrl.toString();
41
38
  this.config = additionalConfig;
42
39
  this.roleMap = {};
43
40
  const roleMapping = this.config['roleMapping'];
44
- if (roleMapping) {
45
- this.roleMap = roleMapping;
46
- }
47
41
  // role mapping will fail on login if AUTH_<provider>_ROLE_MAPPING is an array instead of an object.
48
42
  // This happens if the 'json:' prefix is missing from the variable declaration. To save the user from exhaustive debugging, we'll try to fail early here.
49
43
  if (roleMapping instanceof Array) {
50
44
  logger.error("[OAuth2] Expected a JSON-Object as role mapping, got an Array instead. Make sure you declare the variable with 'json:' prefix.");
51
45
  throw new InvalidProviderError();
52
46
  }
47
+ if (roleMapping) {
48
+ this.roleMap = roleMapping;
49
+ }
53
50
  const issuer = new Issuer({
54
51
  authorization_endpoint: authorizeUrl,
55
52
  token_endpoint: accessUrl,
@@ -67,7 +64,6 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
67
64
  this.client = new issuer.Client({
68
65
  client_id: clientId,
69
66
  client_secret: clientSecret,
70
- redirect_uris: [this.redirectUrl],
71
67
  response_types: ['code'],
72
68
  ...clientOptionsOverrides,
73
69
  });
@@ -75,7 +71,7 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
75
71
  generateCodeVerifier() {
76
72
  return generators.codeVerifier();
77
73
  }
78
- generateAuthUrl(codeVerifier, prompt = false) {
74
+ generateAuthUrl(codeVerifier, prompt = false, callbackUrl) {
79
75
  const { plainCodeChallenge } = this.config;
80
76
  try {
81
77
  const codeChallenge = plainCodeChallenge ? codeVerifier : generators.codeChallenge(codeVerifier);
@@ -89,6 +85,7 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
89
85
  code_challenge_method: plainCodeChallenge ? 'plain' : 'S256',
90
86
  // Some providers require state even with PKCE
91
87
  state: codeChallenge,
88
+ redirect_uri: callbackUrl,
92
89
  });
93
90
  }
94
91
  catch (e) {
@@ -116,7 +113,7 @@ export class OAuth2AuthDriver extends LocalAuthDriver {
116
113
  const codeChallenge = plainCodeChallenge
117
114
  ? payload['codeVerifier']
118
115
  : generators.codeChallenge(payload['codeVerifier']);
119
- tokenSet = await this.client.oauthCallback(this.redirectUrl, { code: payload['code'], state: payload['state'] }, { code_verifier: payload['codeVerifier'], state: codeChallenge });
116
+ tokenSet = await this.client.oauthCallback(payload['redirectUri'], { code: payload['code'], state: payload['state'] }, { code_verifier: payload['codeVerifier'], state: codeChallenge });
120
117
  userInfo = await this.client.userinfo(tokenSet.access_token);
121
118
  }
122
119
  catch (e) {
@@ -275,12 +272,19 @@ export function createOAuth2AuthRouter(providerName) {
275
272
  const provider = getAuthProvider(providerName);
276
273
  const codeVerifier = provider.generateCodeVerifier();
277
274
  const prompt = !!req.query['prompt'];
278
- const redirect = req.query['redirect'];
279
275
  const otp = req.query['otp'];
280
- if (isLoginRedirectAllowed(redirect, providerName) === false) {
276
+ const redirect = req.query['redirect'];
277
+ if (!isLoginRedirectAllowed(providerName, `${req.protocol}://${req.hostname}`, redirect)) {
281
278
  throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
282
279
  }
283
- const token = jwt.sign({ verifier: codeVerifier, redirect, prompt, otp }, getSecret(), {
280
+ const callbackUrl = generateCallbackUrl(providerName, `${req.protocol}://${req.get('host')}`);
281
+ const token = jwt.sign({
282
+ verifier: codeVerifier,
283
+ redirect,
284
+ prompt,
285
+ otp,
286
+ callbackUrl,
287
+ }, getSecret(), {
284
288
  expiresIn: '5m',
285
289
  issuer: 'directus',
286
290
  });
@@ -288,7 +292,7 @@ export function createOAuth2AuthRouter(providerName) {
288
292
  httpOnly: true,
289
293
  sameSite: 'lax',
290
294
  });
291
- return res.redirect(provider.generateAuthUrl(codeVerifier, prompt));
295
+ return res.redirect(provider.generateAuthUrl(codeVerifier, prompt, callbackUrl));
292
296
  }, respond);
293
297
  router.post('/callback', express.urlencoded({ extended: false }), (req, res) => {
294
298
  res.redirect(303, `./callback?${new URLSearchParams(req.body)}`);
@@ -303,7 +307,7 @@ export function createOAuth2AuthRouter(providerName) {
303
307
  logger.warn(e, `[OAuth2] Couldn't verify OAuth2 cookie`);
304
308
  throw new InvalidCredentialsError();
305
309
  }
306
- const { verifier, prompt, otp } = tokenData;
310
+ const { verifier, prompt, otp, callbackUrl } = tokenData;
307
311
  let { redirect } = tokenData;
308
312
  const accountability = createDefaultAccountability({
309
313
  ip: getIPFromReq(req),
@@ -326,6 +330,7 @@ export function createOAuth2AuthRouter(providerName) {
326
330
  code: req.query['code'],
327
331
  codeVerifier: verifier,
328
332
  state: req.query['state'],
333
+ callbackUrl,
329
334
  }, { session: authMode === 'session', ...(otp ? { otp: String(otp) } : {}) });
330
335
  }
331
336
  catch (error) {
@@ -5,13 +5,12 @@ import type { RoleMap } from '../../types/rolemap.js';
5
5
  import { LocalAuthDriver } from './local.js';
6
6
  export declare class OpenIDAuthDriver extends LocalAuthDriver {
7
7
  client: null | Client;
8
- redirectUrl: string;
9
8
  config: Record<string, any>;
10
9
  roleMap: RoleMap;
11
10
  constructor(options: AuthDriverOptions, config: Record<string, any>);
12
11
  private getClient;
13
12
  generateCodeVerifier(): string;
14
- generateAuthUrl(codeVerifier: string, prompt?: boolean): Promise<string>;
13
+ generateAuthUrl(codeVerifier: string, prompt?: boolean, callbackUrl?: string): Promise<string>;
15
14
  private fetchUserId;
16
15
  getUserID(payload: Record<string, any>): Promise<string>;
17
16
  login(user: User): Promise<void>;
@@ -16,20 +16,19 @@ import { AuthenticationService } from '../../services/authentication.js';
16
16
  import asyncHandler from '../../utils/async-handler.js';
17
17
  import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
18
18
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
19
+ import { getSchema } from '../../utils/get-schema.js';
19
20
  import { getSecret } from '../../utils/get-secret.js';
20
- import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
21
21
  import { verifyJWT } from '../../utils/jwt.js';
22
22
  import { Url } from '../../utils/url.js';
23
+ import { generateCallbackUrl } from '../utils/generate-callback-url.js';
24
+ import { isLoginRedirectAllowed } from '../utils/is-login-redirect-allowed.js';
23
25
  import { LocalAuthDriver } from './local.js';
24
- import { getSchema } from '../../utils/get-schema.js';
25
26
  export class OpenIDAuthDriver extends LocalAuthDriver {
26
27
  client;
27
- redirectUrl;
28
28
  config;
29
29
  roleMap;
30
30
  constructor(options, config) {
31
31
  super(options, config);
32
- const env = useEnv();
33
32
  const logger = useLogger();
34
33
  const { issuerUrl, clientId, clientSecret, clientPrivateKeys, clientTokenEndpointAuthMethod, provider, issuerDiscoveryMustSucceed, } = config;
35
34
  const isPrivateKeyJwtAuthMethod = clientTokenEndpointAuthMethod === 'private_key_jwt';
@@ -37,8 +36,6 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
37
36
  logger.error('Invalid provider config');
38
37
  throw new InvalidProviderConfigError({ provider });
39
38
  }
40
- const redirectUrl = new Url(env['PUBLIC_URL']).addPath('auth', 'login', provider, 'callback');
41
- this.redirectUrl = redirectUrl.toString();
42
39
  this.config = config;
43
40
  this.roleMap = {};
44
41
  const roleMapping = this.config['roleMapping'];
@@ -98,7 +95,6 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
98
95
  const client = new issuer.Client({
99
96
  client_id: clientId,
100
97
  ...(!isPrivateKeyJwtAuthMethod && { client_secret: clientSecret }),
101
- redirect_uris: [this.redirectUrl],
102
98
  response_types: ['code'],
103
99
  ...clientOptionsOverrides,
104
100
  }, isPrivateKeyJwtAuthMethod ? { keys: clientPrivateKeys } : undefined);
@@ -116,7 +112,7 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
116
112
  generateCodeVerifier() {
117
113
  return generators.codeVerifier();
118
114
  }
119
- async generateAuthUrl(codeVerifier, prompt = false) {
115
+ async generateAuthUrl(codeVerifier, prompt = false, callbackUrl) {
120
116
  const { plainCodeChallenge } = this.config;
121
117
  try {
122
118
  const client = await this.getClient();
@@ -132,6 +128,7 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
132
128
  // Some providers require state even with PKCE
133
129
  state: codeChallenge,
134
130
  nonce: codeChallenge,
131
+ redirect_uri: callbackUrl,
135
132
  });
136
133
  }
137
134
  catch (e) {
@@ -160,7 +157,7 @@ export class OpenIDAuthDriver extends LocalAuthDriver {
160
157
  const codeChallenge = plainCodeChallenge
161
158
  ? payload['codeVerifier']
162
159
  : generators.codeChallenge(payload['codeVerifier']);
163
- tokenSet = await client.callback(this.redirectUrl, { code: payload['code'], state: payload['state'], iss: payload['iss'] }, { code_verifier: payload['codeVerifier'], state: codeChallenge, nonce: codeChallenge });
160
+ tokenSet = await client.callback(payload['callbackUrl'], { code: payload['code'], state: payload['state'], iss: payload['iss'] }, { code_verifier: payload['codeVerifier'], state: codeChallenge, nonce: codeChallenge });
164
161
  userInfo = tokenSet.claims();
165
162
  if (client.issuer.metadata['userinfo_endpoint']) {
166
163
  userInfo = {
@@ -329,10 +326,17 @@ export function createOpenIDAuthRouter(providerName) {
329
326
  const prompt = !!req.query['prompt'];
330
327
  const redirect = req.query['redirect'];
331
328
  const otp = req.query['otp'];
332
- if (isLoginRedirectAllowed(redirect, providerName) === false) {
329
+ if (!isLoginRedirectAllowed(providerName, `${req.protocol}://${req.hostname}`, redirect)) {
333
330
  throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
334
331
  }
335
- const token = jwt.sign({ verifier: codeVerifier, redirect, prompt, otp }, getSecret(), {
332
+ const callbackUrl = generateCallbackUrl(providerName, `${req.protocol}://${req.get('host')}`);
333
+ const token = jwt.sign({
334
+ verifier: codeVerifier,
335
+ redirect,
336
+ prompt,
337
+ otp,
338
+ callbackUrl,
339
+ }, getSecret(), {
336
340
  expiresIn: (env[`AUTH_${providerName.toUpperCase()}_LOGIN_TIMEOUT`] ?? '5m'),
337
341
  issuer: 'directus',
338
342
  });
@@ -341,7 +345,7 @@ export function createOpenIDAuthRouter(providerName) {
341
345
  sameSite: 'lax',
342
346
  });
343
347
  try {
344
- return res.redirect(await provider.generateAuthUrl(codeVerifier, prompt));
348
+ return res.redirect(await provider.generateAuthUrl(codeVerifier, prompt, callbackUrl));
345
349
  }
346
350
  catch {
347
351
  return res.redirect(new Url(env['PUBLIC_URL'])
@@ -365,7 +369,7 @@ export function createOpenIDAuthRouter(providerName) {
365
369
  const url = new Url(env['PUBLIC_URL']).addPath('admin', 'login');
366
370
  return res.redirect(`${url.toString()}?reason=${ErrorCode.InvalidCredentials}`);
367
371
  }
368
- const { verifier, prompt, otp } = tokenData;
372
+ const { verifier, prompt, otp, callbackUrl } = tokenData;
369
373
  let { redirect } = tokenData;
370
374
  const accountability = createDefaultAccountability({ ip: getIPFromReq(req) });
371
375
  const userAgent = req.get('user-agent')?.substring(0, 1024);
@@ -387,6 +391,7 @@ export function createOpenIDAuthRouter(providerName) {
387
391
  codeVerifier: verifier,
388
392
  state: req.query['state'],
389
393
  iss: req.query['iss'],
394
+ callbackUrl,
390
395
  }, { session: authMode === 'session', ...(otp ? { otp: String(otp) } : {}) });
391
396
  }
392
397
  catch (error) {
@@ -13,8 +13,8 @@ import { AuthenticationService } from '../../services/authentication.js';
13
13
  import asyncHandler from '../../utils/async-handler.js';
14
14
  import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
15
15
  import { LocalAuthDriver } from './local.js';
16
- import { isLoginRedirectAllowed } from '../../utils/is-login-redirect-allowed.js';
17
16
  import { getSchema } from '../../utils/get-schema.js';
17
+ import { isLoginRedirectAllowed } from '../utils/is-login-redirect-allowed.js';
18
18
  // Register the samlify schema validator
19
19
  samlify.setSchemaValidator(validator);
20
20
  export class SAMLAuthDriver extends LocalAuthDriver {
@@ -95,7 +95,7 @@ export function createSAMLAuthRouter(providerName) {
95
95
  const parsedUrl = new URL(url);
96
96
  if (req.query['redirect']) {
97
97
  const redirect = req.query['redirect'];
98
- if (isLoginRedirectAllowed(redirect, providerName) === false) {
98
+ if (!isLoginRedirectAllowed(providerName, `${req.protocol}://${req.hostname}`, redirect)) {
99
99
  throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
100
100
  }
101
101
  parsedUrl.searchParams.append('RelayState', redirect);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Generate callback URL from origin
3
+ *
4
+ * @param string Origin URL
5
+ * @param providerName OAuth provider name
6
+ * @returns url
7
+ */
8
+ export declare function generateCallbackUrl(providerName: string, originUrl: string): string;
@@ -0,0 +1,11 @@
1
+ import { Url } from '../../utils/url.js';
2
+ /**
3
+ * Generate callback URL from origin
4
+ *
5
+ * @param string Origin URL
6
+ * @param providerName OAuth provider name
7
+ * @returns url
8
+ */
9
+ export function generateCallbackUrl(providerName, originUrl) {
10
+ return new Url(originUrl).addPath('auth', 'login', providerName, 'callback').toString();
11
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Check if the redirect URL is allowed
3
+ * @param originUrl Origin URL
4
+ * @param provider OAuth provider name
5
+ * @param redirect URL to redirect to
6
+ * @returns True if the redirect is allowed, false otherwise
7
+ */
8
+ export declare function isLoginRedirectAllowed(provider: string, originUrl: string, redirect: unknown): boolean;
@@ -1,19 +1,23 @@
1
1
  import { useEnv } from '@directus/env';
2
2
  import { toArray } from '@directus/utils';
3
- import { useLogger } from '../logger/index.js';
4
- import isUrlAllowed from './is-url-allowed.js';
3
+ import { useLogger } from '../../logger/index.js';
4
+ import isUrlAllowed from '../../utils/is-url-allowed.js';
5
5
  /**
6
- * Checks if the defined redirect after successful SSO login is in the allow list
6
+ * Check if the redirect URL is allowed
7
+ * @param originUrl Origin URL
8
+ * @param provider OAuth provider name
9
+ * @param redirect URL to redirect to
10
+ * @returns True if the redirect is allowed, false otherwise
7
11
  */
8
- export function isLoginRedirectAllowed(redirect, provider) {
12
+ export function isLoginRedirectAllowed(provider, originUrl, redirect) {
9
13
  if (!redirect)
10
14
  return true; // empty redirect
11
15
  if (typeof redirect !== 'string')
12
16
  return false; // invalid type
13
17
  const env = useEnv();
14
18
  const publicUrl = env['PUBLIC_URL'];
15
- if (URL.canParse(redirect) === false) {
16
- if (redirect.startsWith('//') === false) {
19
+ if (!URL.canParse(redirect)) {
20
+ if (!redirect.startsWith('//')) {
17
21
  // should be a relative path like `/admin/test`
18
22
  return true;
19
23
  }
@@ -21,6 +25,10 @@ export function isLoginRedirectAllowed(redirect, provider) {
21
25
  return false;
22
26
  }
23
27
  const { protocol: redirectProtocol, hostname: redirectDomain } = new URL(redirect);
28
+ const redirectUrl = `${redirectProtocol}//${redirectDomain}`;
29
+ // Security check: redirect URL must match the request origin
30
+ if (redirectUrl !== originUrl)
31
+ return false;
24
32
  const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
25
33
  if (envKey in env) {
26
34
  if (isUrlAllowed(redirect, [...toArray(env[envKey]), publicUrl]))
@@ -30,7 +38,7 @@ export function isLoginRedirectAllowed(redirect, provider) {
30
38
  useLogger().error('Invalid PUBLIC_URL for login redirect');
31
39
  return false;
32
40
  }
33
- // allow redirects to the defined PUBLIC_URL
34
41
  const { protocol: publicProtocol, hostname: publicDomain } = new URL(publicUrl);
35
- return `${redirectProtocol}//${redirectDomain}` === `${publicProtocol}//${publicDomain}`;
42
+ // allow redirects to the defined PUBLIC_URL
43
+ return redirectUrl === `${publicProtocol}//${publicDomain}`;
36
44
  }
@@ -74,6 +74,9 @@ router.get('/registry', asyncHandler(async (req, res, next) => {
74
74
  return next();
75
75
  }), respond);
76
76
  router.get(`/registry/account/:pk(${UUID_REGEX})`, asyncHandler(async (req, res, next) => {
77
+ if (req.accountability && req.accountability.admin !== true) {
78
+ throw new ForbiddenError();
79
+ }
77
80
  if (typeof req.params['pk'] !== 'string') {
78
81
  throw new ForbiddenError();
79
82
  }
@@ -86,6 +89,9 @@ router.get(`/registry/account/:pk(${UUID_REGEX})`, asyncHandler(async (req, res,
86
89
  return next();
87
90
  }), respond);
88
91
  router.get(`/registry/extension/:pk(${UUID_REGEX})`, asyncHandler(async (req, res, next) => {
92
+ if (req.accountability && req.accountability.admin !== true) {
93
+ throw new ForbiddenError();
94
+ }
89
95
  if (typeof req.params['pk'] !== 'string') {
90
96
  throw new ForbiddenError();
91
97
  }
@@ -1,5 +1,5 @@
1
1
  import { useEnv } from '@directus/env';
2
- import { ServiceUnavailableError } from '@directus/errors';
2
+ import { ErrorCode, isDirectusError, ServiceUnavailableError } from '@directus/errors';
3
3
  import { EXTENSION_PKG_KEY, ExtensionManifest } from '@directus/extensions';
4
4
  import { download } from '@directus/extensions-registry';
5
5
  import DriverLocal from '@directus/storage-driver-local';
@@ -25,7 +25,13 @@ export class InstallationManager {
25
25
  if (env['MARKETPLACE_REGISTRY'] && typeof env['MARKETPLACE_REGISTRY'] === 'string') {
26
26
  options.registry = env['MARKETPLACE_REGISTRY'];
27
27
  }
28
- const tarReadableStream = await download(versionId, env['MARKETPLACE_TRUST'] === 'sandbox', options);
28
+ let tarReadableStream;
29
+ try {
30
+ tarReadableStream = await download(versionId, env['MARKETPLACE_TRUST'] === 'sandbox', options);
31
+ }
32
+ catch (error) {
33
+ throw new ServiceUnavailableError({ service: 'marketplace', reason: 'Could not download the extension' }, { cause: error });
34
+ }
29
35
  if (!tarReadableStream) {
30
36
  throw new Error(`No readable stream returned from download`);
31
37
  }
@@ -65,7 +71,11 @@ export class InstallationManager {
65
71
  }
66
72
  catch (err) {
67
73
  logger.warn(err);
68
- throw new ServiceUnavailableError({ service: 'marketplace', reason: 'Could not download and extract the extension' }, { cause: err });
74
+ // rethrow marketplace servic unavailable
75
+ if (isDirectusError(err, ErrorCode.ServiceUnavailable)) {
76
+ throw err;
77
+ }
78
+ throw new ServiceUnavailableError({ service: 'extensions', reason: 'Failed to extract the extension or write it to storage' }, { cause: err });
69
79
  }
70
80
  finally {
71
81
  await rm(tempDir, { recursive: true });
@@ -247,12 +247,55 @@ UI button that users click to start flows
247
247
  - Data chain variable syntax
248
248
  - Operation-specific configuration
249
249
 
250
- **Workflow Process:**
250
+ **Critical Workflow - Follow This Order:**
251
251
 
252
- 1. Create flow first to get flow ID
253
- 2. Use `operations` tool to add/manage operations
254
- 3. Operations execute in sequence based on resolve/reject paths
255
- 4. Link operations via UUIDs in resolve/reject fields </operations_integration>
252
+ 1. Create flow first (using `flows` tool)
253
+ 2. Create all operations with null resolve/reject initially (using `operations` tool)
254
+ 3. Link operations together using UUIDs from step 2
255
+ 4. Update flow to set first operation as entry point
256
+
257
+ **Why This Order:** Operations must exist before they can be referenced. UUIDs only available after creation.
258
+
259
+ **Complete Example:**
260
+
261
+ ```json
262
+ // Step 1: Create flow
263
+ {"action": "create", "data": {
264
+ "name": "Email on Post Published",
265
+ "trigger": "event",
266
+ "options": {"type": "action", "scope": ["items.create"], "collections": ["posts"]}
267
+ }}
268
+ // Returns: {"id": "flow-uuid-123"}
269
+
270
+ // Step 2: Create operations with null connections
271
+ {"action": "create", "data": {
272
+ "flow": "flow-uuid-123", "key": "check_status", "type": "condition",
273
+ "position_x": 19, "position_y": 1,
274
+ "options": {"filter": {"$trigger": {"payload": {"status": {"_eq": "published"}}}}},
275
+ "resolve": null, "reject": null
276
+ }}
277
+ // Returns: {"id": "condition-uuid-456"}
278
+
279
+ {"action": "create", "data": {
280
+ "flow": "flow-uuid-123", "key": "send_email", "type": "mail",
281
+ "position_x": 37, "position_y": 1,
282
+ "options": {"to": ["admin@example.com"], "subject": "New post", "body": "{{$trigger.payload.title}}"},
283
+ "resolve": null, "reject": null
284
+ }}
285
+ // Returns: {"id": "email-uuid-789"}
286
+
287
+ // Step 3: Link operations via UUIDs
288
+ {"action": "update", "key": "condition-uuid-456", "data": {
289
+ "resolve": "email-uuid-789"
290
+ }}
291
+
292
+ // Step 4: Set flow entry point
293
+ {"action": "update", "key": "flow-uuid-123", "data": {
294
+ "operation": "condition-uuid-456"
295
+ }}
296
+ ```
297
+
298
+ </operations_integration>
256
299
 
257
300
  <flow_chaining>
258
301
 
@@ -319,15 +362,17 @@ UI button that users click to start flows
319
362
 
320
363
  ## Data Chain Access
321
364
 
322
- **See the `operations` tool for complete data chain syntax and examples.**
365
+ Operations can access data using `{{ variable }}` syntax:
323
366
 
324
- Operations can access:
367
+ - `{{ $trigger.payload }}` - Trigger data
368
+ - `{{ $accountability.user }}` - User context
369
+ - `{{ $env.VARIABLE_NAME }}` - Environment variables
370
+ - `{{ operation_key }}` - Result from specific operation (recommended)
371
+ - `{{ operation_key.field }}` - Specific field from operation result
372
+ - `{{ $last }}` - Previous operation result (⚠️ avoid - breaks when reordered)
325
373
 
326
- - `$trigger` - Initial trigger data
327
- - `$accountability` - User/permission context
328
- - `$env` - Environment variables
329
- - `<operationKey>` - Result of specific operation (recommended)
330
- - `$last` - Result of previous operation (avoid - breaks when reordered) </data_chain_warning>
374
+ **Always use operation keys** for reliable flows. If you reorder operations, `$last` will reference a different
375
+ operation. </data_chain_warning>
331
376
 
332
377
  <real_world_examples>
333
378