@directus/api 33.3.1 → 34.0.1

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.
Files changed (118) hide show
  1. package/dist/ai/chat/lib/create-ui-stream.js +2 -1
  2. package/dist/ai/chat/lib/transform-file-parts.d.ts +12 -0
  3. package/dist/ai/chat/lib/transform-file-parts.js +36 -0
  4. package/dist/ai/files/adapters/anthropic.d.ts +3 -0
  5. package/dist/ai/files/adapters/anthropic.js +25 -0
  6. package/dist/ai/files/adapters/google.d.ts +3 -0
  7. package/dist/ai/files/adapters/google.js +58 -0
  8. package/dist/ai/files/adapters/index.d.ts +3 -0
  9. package/dist/ai/files/adapters/index.js +3 -0
  10. package/dist/ai/files/adapters/openai.d.ts +3 -0
  11. package/dist/ai/files/adapters/openai.js +22 -0
  12. package/dist/ai/files/controllers/upload.d.ts +2 -0
  13. package/dist/ai/files/controllers/upload.js +101 -0
  14. package/dist/ai/files/lib/fetch-provider.d.ts +1 -0
  15. package/dist/ai/files/lib/fetch-provider.js +23 -0
  16. package/dist/ai/files/lib/upload-to-provider.d.ts +4 -0
  17. package/dist/ai/files/lib/upload-to-provider.js +26 -0
  18. package/dist/ai/files/router.d.ts +1 -0
  19. package/dist/ai/files/router.js +5 -0
  20. package/dist/ai/files/types.d.ts +5 -0
  21. package/dist/ai/files/types.js +1 -0
  22. package/dist/ai/providers/anthropic-file-support.d.ts +12 -0
  23. package/dist/ai/providers/anthropic-file-support.js +94 -0
  24. package/dist/ai/providers/registry.js +3 -6
  25. package/dist/ai/tools/fields/index.d.ts +3 -3
  26. package/dist/ai/tools/fields/index.js +9 -3
  27. package/dist/ai/tools/flows/index.d.ts +16 -16
  28. package/dist/ai/tools/schema.d.ts +8 -8
  29. package/dist/ai/tools/schema.js +2 -2
  30. package/dist/app.js +10 -1
  31. package/dist/auth/drivers/oauth2.js +10 -4
  32. package/dist/auth/drivers/openid.js +10 -4
  33. package/dist/auth/drivers/saml.js +20 -10
  34. package/dist/auth/utils/resolve-login-redirect.d.ts +11 -0
  35. package/dist/auth/utils/resolve-login-redirect.js +62 -0
  36. package/dist/controllers/deployment-webhooks.d.ts +2 -0
  37. package/dist/controllers/deployment-webhooks.js +95 -0
  38. package/dist/controllers/deployment.js +61 -165
  39. package/dist/controllers/files.js +2 -1
  40. package/dist/controllers/server.js +32 -26
  41. package/dist/controllers/tus.js +33 -2
  42. package/dist/controllers/utils.js +18 -0
  43. package/dist/database/get-ast-from-query/lib/parse-fields.js +52 -26
  44. package/dist/database/helpers/date/dialects/oracle.js +2 -0
  45. package/dist/database/helpers/date/dialects/sqlite.js +2 -0
  46. package/dist/database/helpers/date/types.d.ts +1 -1
  47. package/dist/database/helpers/date/types.js +3 -1
  48. package/dist/database/helpers/fn/dialects/mssql.d.ts +1 -0
  49. package/dist/database/helpers/fn/dialects/mssql.js +21 -0
  50. package/dist/database/helpers/fn/dialects/mysql.d.ts +2 -0
  51. package/dist/database/helpers/fn/dialects/mysql.js +30 -0
  52. package/dist/database/helpers/fn/dialects/oracle.d.ts +1 -0
  53. package/dist/database/helpers/fn/dialects/oracle.js +21 -0
  54. package/dist/database/helpers/fn/dialects/postgres.d.ts +14 -0
  55. package/dist/database/helpers/fn/dialects/postgres.js +40 -0
  56. package/dist/database/helpers/fn/dialects/sqlite.d.ts +1 -0
  57. package/dist/database/helpers/fn/dialects/sqlite.js +12 -0
  58. package/dist/database/helpers/fn/json/parse-function.d.ts +19 -0
  59. package/dist/database/helpers/fn/json/parse-function.js +66 -0
  60. package/dist/database/helpers/fn/types.d.ts +8 -0
  61. package/dist/database/helpers/fn/types.js +19 -0
  62. package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -0
  63. package/dist/database/helpers/schema/dialects/mysql.js +11 -0
  64. package/dist/database/helpers/schema/types.d.ts +1 -0
  65. package/dist/database/helpers/schema/types.js +3 -0
  66. package/dist/database/index.js +2 -1
  67. package/dist/database/migrations/20260211A-add-deployment-webhooks.d.ts +3 -0
  68. package/dist/database/migrations/20260211A-add-deployment-webhooks.js +37 -0
  69. package/dist/database/run-ast/lib/apply-query/aggregate.js +4 -4
  70. package/dist/database/run-ast/lib/apply-query/filter/get-filter-type.d.ts +2 -2
  71. package/dist/database/run-ast/lib/apply-query/filter/operator.js +17 -7
  72. package/dist/database/run-ast/lib/parse-current-level.js +8 -1
  73. package/dist/database/run-ast/run-ast.js +11 -1
  74. package/dist/database/run-ast/utils/apply-function-to-column-name.js +7 -1
  75. package/dist/database/run-ast/utils/get-column.js +13 -2
  76. package/dist/deployment/deployment.d.ts +25 -2
  77. package/dist/deployment/drivers/netlify.d.ts +6 -2
  78. package/dist/deployment/drivers/netlify.js +114 -12
  79. package/dist/deployment/drivers/vercel.d.ts +5 -2
  80. package/dist/deployment/drivers/vercel.js +84 -5
  81. package/dist/deployment.d.ts +5 -0
  82. package/dist/deployment.js +34 -0
  83. package/dist/permissions/modules/validate-access/lib/validate-item-access.js +1 -1
  84. package/dist/permissions/utils/get-unaliased-field-key.js +9 -1
  85. package/dist/request/is-denied-ip.js +24 -23
  86. package/dist/services/authentication.js +27 -22
  87. package/dist/services/collections.js +1 -0
  88. package/dist/services/deployment-projects.d.ts +31 -2
  89. package/dist/services/deployment-projects.js +109 -5
  90. package/dist/services/deployment-runs.d.ts +19 -1
  91. package/dist/services/deployment-runs.js +86 -0
  92. package/dist/services/deployment.d.ts +44 -3
  93. package/dist/services/deployment.js +263 -15
  94. package/dist/services/files/utils/get-metadata.js +6 -6
  95. package/dist/services/files.d.ts +3 -1
  96. package/dist/services/files.js +26 -3
  97. package/dist/services/graphql/resolvers/query.js +23 -6
  98. package/dist/services/graphql/resolvers/system.js +35 -27
  99. package/dist/services/payload.d.ts +6 -0
  100. package/dist/services/payload.js +27 -2
  101. package/dist/services/server.js +1 -1
  102. package/dist/services/users.js +6 -1
  103. package/dist/test-utils/README.md +112 -0
  104. package/dist/test-utils/controllers.d.ts +65 -0
  105. package/dist/test-utils/controllers.js +100 -0
  106. package/dist/test-utils/database.d.ts +1 -1
  107. package/dist/test-utils/database.js +3 -1
  108. package/dist/utils/get-field-relational-depth.d.ts +13 -0
  109. package/dist/utils/get-field-relational-depth.js +22 -0
  110. package/dist/utils/parse-value.d.ts +4 -0
  111. package/dist/utils/parse-value.js +11 -0
  112. package/dist/utils/sanitize-query.js +3 -2
  113. package/dist/utils/split-fields.d.ts +4 -0
  114. package/dist/utils/split-fields.js +32 -0
  115. package/dist/utils/validate-query.js +2 -1
  116. package/package.json +36 -36
  117. package/dist/auth/utils/is-login-redirect-allowed.d.ts +0 -7
  118. package/dist/auth/utils/is-login-redirect-allowed.js +0 -39
@@ -11,13 +11,13 @@ export declare const FlowsValidateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
11
11
  active: "active";
12
12
  inactive: "inactive";
13
13
  }>>;
14
- trigger: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
14
+ trigger: z.ZodOptional<z.ZodEnum<{
15
15
  operation: "operation";
16
16
  schedule: "schedule";
17
17
  event: "event";
18
18
  webhook: "webhook";
19
19
  manual: "manual";
20
- }>, z.ZodNull]>>;
20
+ }>>;
21
21
  options: z.ZodOptional<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodAny>, z.ZodNull]>>;
22
22
  operation: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>;
23
23
  operations: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -36,10 +36,10 @@ export declare const FlowsValidateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
36
36
  }, z.core.$strip>>>;
37
37
  date_created: z.ZodOptional<z.ZodString>;
38
38
  user_created: z.ZodOptional<z.ZodString>;
39
- accountability: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
39
+ accountability: z.ZodOptional<z.ZodEnum<{
40
40
  all: "all";
41
41
  activity: "activity";
42
- }>, z.ZodNull]>>;
42
+ }>>;
43
43
  }, z.core.$strip>;
44
44
  }, z.core.$strict>, z.ZodObject<{
45
45
  action: z.ZodLiteral<"read">;
@@ -79,13 +79,13 @@ export declare const FlowsValidateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
79
79
  active: "active";
80
80
  inactive: "inactive";
81
81
  }>>;
82
- trigger: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
82
+ trigger: z.ZodOptional<z.ZodEnum<{
83
83
  operation: "operation";
84
84
  schedule: "schedule";
85
85
  event: "event";
86
86
  webhook: "webhook";
87
87
  manual: "manual";
88
- }>, z.ZodNull]>>;
88
+ }>>;
89
89
  options: z.ZodOptional<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodAny>, z.ZodNull]>>;
90
90
  operation: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>;
91
91
  operations: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -104,10 +104,10 @@ export declare const FlowsValidateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
104
104
  }, z.core.$strip>>>;
105
105
  date_created: z.ZodOptional<z.ZodString>;
106
106
  user_created: z.ZodOptional<z.ZodString>;
107
- accountability: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
107
+ accountability: z.ZodOptional<z.ZodEnum<{
108
108
  all: "all";
109
109
  activity: "activity";
110
- }>, z.ZodNull]>>;
110
+ }>>;
111
111
  }, z.core.$strip>;
112
112
  query: z.ZodOptional<z.ZodObject<{
113
113
  fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -176,13 +176,13 @@ export declare const FlowsInputSchema: z.ZodObject<{
176
176
  active: "active";
177
177
  inactive: "inactive";
178
178
  }>>;
179
- trigger: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
179
+ trigger: z.ZodOptional<z.ZodEnum<{
180
180
  operation: "operation";
181
181
  schedule: "schedule";
182
182
  event: "event";
183
183
  webhook: "webhook";
184
184
  manual: "manual";
185
- }>, z.ZodNull]>>;
185
+ }>>;
186
186
  options: z.ZodOptional<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodAny>, z.ZodNull]>>;
187
187
  operation: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>;
188
188
  operations: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -201,10 +201,10 @@ export declare const FlowsInputSchema: z.ZodObject<{
201
201
  }, z.core.$strip>>>;
202
202
  date_created: z.ZodOptional<z.ZodString>;
203
203
  user_created: z.ZodOptional<z.ZodString>;
204
- accountability: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
204
+ accountability: z.ZodOptional<z.ZodEnum<{
205
205
  all: "all";
206
206
  activity: "activity";
207
- }>, z.ZodNull]>>;
207
+ }>>;
208
208
  }, z.core.$strip>>;
209
209
  key: z.ZodOptional<z.ZodString>;
210
210
  }, z.core.$strip>;
@@ -217,7 +217,7 @@ export declare const flows: import("../types.js").ToolConfig<{
217
217
  color?: string | null | undefined;
218
218
  description?: string | null | undefined;
219
219
  status?: "active" | "inactive" | undefined;
220
- trigger?: "operation" | "schedule" | "event" | "webhook" | "manual" | null | undefined;
220
+ trigger?: "operation" | "schedule" | "event" | "webhook" | "manual" | undefined;
221
221
  options?: Record<string, any> | null | undefined;
222
222
  operation?: string | null | undefined;
223
223
  operations?: {
@@ -236,7 +236,7 @@ export declare const flows: import("../types.js").ToolConfig<{
236
236
  }[] | undefined;
237
237
  date_created?: string | undefined;
238
238
  user_created?: string | undefined;
239
- accountability?: "all" | "activity" | null | undefined;
239
+ accountability?: "all" | "activity" | undefined;
240
240
  };
241
241
  } | {
242
242
  action: "read";
@@ -273,7 +273,7 @@ export declare const flows: import("../types.js").ToolConfig<{
273
273
  color?: string | null | undefined;
274
274
  description?: string | null | undefined;
275
275
  status?: "active" | "inactive" | undefined;
276
- trigger?: "operation" | "schedule" | "event" | "webhook" | "manual" | null | undefined;
276
+ trigger?: "operation" | "schedule" | "event" | "webhook" | "manual" | undefined;
277
277
  options?: Record<string, any> | null | undefined;
278
278
  operation?: string | null | undefined;
279
279
  operations?: {
@@ -292,7 +292,7 @@ export declare const flows: import("../types.js").ToolConfig<{
292
292
  }[] | undefined;
293
293
  date_created?: string | undefined;
294
294
  user_created?: string | undefined;
295
- accountability?: "all" | "activity" | null | undefined;
295
+ accountability?: "all" | "activity" | undefined;
296
296
  };
297
297
  query?: {
298
298
  fields?: string[] | undefined;
@@ -272,13 +272,13 @@ export declare const FlowItemInputSchema: z.ZodObject<{
272
272
  active: "active";
273
273
  inactive: "inactive";
274
274
  }>>;
275
- trigger: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
275
+ trigger: z.ZodOptional<z.ZodEnum<{
276
276
  operation: "operation";
277
277
  schedule: "schedule";
278
278
  event: "event";
279
279
  webhook: "webhook";
280
280
  manual: "manual";
281
- }>, z.ZodNull]>>;
281
+ }>>;
282
282
  options: z.ZodOptional<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodAny>, z.ZodNull]>>;
283
283
  operation: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>;
284
284
  operations: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -297,10 +297,10 @@ export declare const FlowItemInputSchema: z.ZodObject<{
297
297
  }, z.core.$strip>>>;
298
298
  date_created: z.ZodOptional<z.ZodString>;
299
299
  user_created: z.ZodOptional<z.ZodString>;
300
- accountability: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
300
+ accountability: z.ZodOptional<z.ZodEnum<{
301
301
  all: "all";
302
302
  activity: "activity";
303
- }>, z.ZodNull]>>;
303
+ }>>;
304
304
  }, z.core.$strip>;
305
305
  export declare const FlowItemValidateSchema: z.ZodObject<{
306
306
  id: z.ZodOptional<z.ZodString>;
@@ -312,13 +312,13 @@ export declare const FlowItemValidateSchema: z.ZodObject<{
312
312
  active: "active";
313
313
  inactive: "inactive";
314
314
  }>>;
315
- trigger: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
315
+ trigger: z.ZodOptional<z.ZodEnum<{
316
316
  operation: "operation";
317
317
  schedule: "schedule";
318
318
  event: "event";
319
319
  webhook: "webhook";
320
320
  manual: "manual";
321
- }>, z.ZodNull]>>;
321
+ }>>;
322
322
  options: z.ZodOptional<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodAny>, z.ZodNull]>>;
323
323
  operation: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNull]>>;
324
324
  operations: z.ZodOptional<z.ZodArray<z.ZodObject<{
@@ -337,10 +337,10 @@ export declare const FlowItemValidateSchema: z.ZodObject<{
337
337
  }, z.core.$strip>>>;
338
338
  date_created: z.ZodOptional<z.ZodString>;
339
339
  user_created: z.ZodOptional<z.ZodString>;
340
- accountability: z.ZodOptional<z.ZodUnion<readonly [z.ZodEnum<{
340
+ accountability: z.ZodOptional<z.ZodEnum<{
341
341
  all: "all";
342
342
  activity: "activity";
343
- }>, z.ZodNull]>>;
343
+ }>>;
344
344
  }, z.core.$strip>;
345
345
  export declare const TriggerFlowInputSchema: z.ZodObject<{
346
346
  id: z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>;
@@ -135,13 +135,13 @@ export const FlowItemInputSchema = z
135
135
  color: z.union([z.string(), z.null()]),
136
136
  description: z.union([z.string(), z.null()]),
137
137
  status: z.enum(['active', 'inactive']),
138
- trigger: z.union([z.enum(['event', 'schedule', 'operation', 'webhook', 'manual']), z.null()]),
138
+ trigger: z.enum(['event', 'schedule', 'operation', 'webhook', 'manual']),
139
139
  options: z.union([z.record(z.string(), z.any()), z.null()]),
140
140
  operation: z.union([z.string(), z.null()]),
141
141
  operations: z.array(OperationItemInputSchema),
142
142
  date_created: z.string(),
143
143
  user_created: z.string(),
144
- accountability: z.union([z.enum(['all', 'activity']), z.null()]),
144
+ accountability: z.enum(['all', 'activity']),
145
145
  })
146
146
  .partial();
147
147
  export const FlowItemValidateSchema = FlowItemInputSchema;
package/dist/app.js CHANGED
@@ -10,6 +10,7 @@ import express from 'express';
10
10
  import { merge } from 'lodash-es';
11
11
  import qs from 'qs';
12
12
  import { aiChatRouter } from './ai/chat/router.js';
13
+ import { aiFilesRouter } from './ai/files/router.js';
13
14
  import { registerAuthProviders } from './auth.js';
14
15
  import accessRouter from './controllers/access.js';
15
16
  import activityRouter from './controllers/activity.js';
@@ -18,6 +19,7 @@ import authRouter from './controllers/auth.js';
18
19
  import collectionsRouter from './controllers/collections.js';
19
20
  import commentsRouter from './controllers/comments.js';
20
21
  import dashboardsRouter from './controllers/dashboards.js';
22
+ import deploymentWebhookRouter from './controllers/deployment-webhooks.js';
21
23
  import deploymentRouter from './controllers/deployment.js';
22
24
  import extensionsRouter from './controllers/extensions.js';
23
25
  import fieldsRouter from './controllers/fields.js';
@@ -48,7 +50,7 @@ import usersRouter from './controllers/users.js';
48
50
  import utilsRouter from './controllers/utils.js';
49
51
  import versionsRouter from './controllers/versions.js';
50
52
  import { isInstalled, validateDatabaseConnection, validateDatabaseExtensions, validateMigrations, } from './database/index.js';
51
- import { registerDeploymentDrivers } from './deployment.js';
53
+ import { ensureDeploymentWebhooks, registerDeploymentDrivers } from './deployment.js';
52
54
  import emitter from './emitter.js';
53
55
  import { getExtensionManager } from './extensions/index.js';
54
56
  import { getFlowManager } from './flows.js';
@@ -96,6 +98,7 @@ export default async function createApp() {
96
98
  await validateStorage();
97
99
  await registerAuthProviders();
98
100
  registerDeploymentDrivers();
101
+ await ensureDeploymentWebhooks();
99
102
  const extensionManager = getExtensionManager();
100
103
  const flowManager = getFlowManager();
101
104
  await extensionManager.initialize();
@@ -161,6 +164,9 @@ export default async function createApp() {
161
164
  app.use((req, res, next) => {
162
165
  express.json({
163
166
  limit: env['MAX_PAYLOAD_SIZE'],
167
+ verify: (req, _res, buf) => {
168
+ req.rawBody = buf;
169
+ },
164
170
  })(req, res, (err) => {
165
171
  if (err) {
166
172
  return next(new InvalidPayloadError({ reason: err.message }));
@@ -214,6 +220,8 @@ export default async function createApp() {
214
220
  app.use(rateLimiter);
215
221
  }
216
222
  app.get('/server/ping', (_req, res) => res.send('pong'));
223
+ // Public webhook endpoint (signature-verified by the provider)
224
+ app.use('/deployments/webhooks', deploymentWebhookRouter);
217
225
  app.use(authenticate);
218
226
  app.use(schema);
219
227
  app.use(sanitizeQuery);
@@ -243,6 +251,7 @@ export default async function createApp() {
243
251
  }
244
252
  if (toBoolean(env['AI_ENABLED']) === true) {
245
253
  app.use('/ai/chat', aiChatRouter);
254
+ app.use('/ai/files', aiFilesRouter);
246
255
  }
247
256
  if (env['METRICS_ENABLED'] === true) {
248
257
  app.use('/metrics', metricsRouter);
@@ -21,7 +21,7 @@ import { getSecret } from '../../utils/get-secret.js';
21
21
  import { verifyJWT } from '../../utils/jwt.js';
22
22
  import { Url } from '../../utils/url.js';
23
23
  import { generateCallbackUrl } from '../utils/generate-callback-url.js';
24
- import { isLoginRedirectAllowed } from '../utils/is-login-redirect-allowed.js';
24
+ import { resolveLoginRedirect } from '../utils/resolve-login-redirect.js';
25
25
  import { LocalAuthDriver } from './local.js';
26
26
  export class OAuth2AuthDriver extends LocalAuthDriver {
27
27
  client;
@@ -273,8 +273,12 @@ export function createOAuth2AuthRouter(providerName) {
273
273
  const codeVerifier = provider.generateCodeVerifier();
274
274
  const prompt = !!req.query['prompt'];
275
275
  const otp = req.query['otp'];
276
- const redirect = req.query['redirect'];
277
- if (!isLoginRedirectAllowed(providerName, redirect)) {
276
+ let redirect = req.query['redirect'];
277
+ try {
278
+ redirect = resolveLoginRedirect(redirect, { provider: providerName });
279
+ }
280
+ catch (e) {
281
+ useLogger().error(e);
278
282
  throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
279
283
  }
280
284
  const callbackUrl = generateCallbackUrl(providerName, `${req.protocol}://${req.get('host')}`);
@@ -356,8 +360,10 @@ export function createOAuth2AuthRouter(providerName) {
356
360
  const claims = verifyJWT(accessToken, getSecret());
357
361
  if (claims?.enforce_tfa === true) {
358
362
  const url = new Url(env['PUBLIC_URL']).addPath('admin', 'tfa-setup');
359
- if (redirect)
363
+ if (redirect) {
360
364
  url.setQuery('redirect', redirect);
365
+ url.setQuery('provider', providerName);
366
+ }
361
367
  redirect = url.toString();
362
368
  }
363
369
  }
@@ -21,7 +21,7 @@ import { getSecret } from '../../utils/get-secret.js';
21
21
  import { verifyJWT } from '../../utils/jwt.js';
22
22
  import { Url } from '../../utils/url.js';
23
23
  import { generateCallbackUrl } from '../utils/generate-callback-url.js';
24
- import { isLoginRedirectAllowed } from '../utils/is-login-redirect-allowed.js';
24
+ import { resolveLoginRedirect } from '../utils/resolve-login-redirect.js';
25
25
  import { LocalAuthDriver } from './local.js';
26
26
  export class OpenIDAuthDriver extends LocalAuthDriver {
27
27
  client;
@@ -324,9 +324,13 @@ export function createOpenIDAuthRouter(providerName) {
324
324
  const provider = getAuthProvider(providerName);
325
325
  const codeVerifier = provider.generateCodeVerifier();
326
326
  const prompt = !!req.query['prompt'];
327
- const redirect = req.query['redirect'];
327
+ let redirect = req.query['redirect'];
328
328
  const otp = req.query['otp'];
329
- if (!isLoginRedirectAllowed(providerName, redirect)) {
329
+ try {
330
+ redirect = resolveLoginRedirect(redirect, { provider: providerName });
331
+ }
332
+ catch (e) {
333
+ useLogger().error(e);
330
334
  throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
331
335
  }
332
336
  const callbackUrl = generateCallbackUrl(providerName, `${req.protocol}://${req.get('host')}`);
@@ -418,8 +422,10 @@ export function createOpenIDAuthRouter(providerName) {
418
422
  const claims = verifyJWT(accessToken, getSecret());
419
423
  if (claims?.enforce_tfa === true) {
420
424
  const url = new Url(env['PUBLIC_URL']).addPath('admin', 'tfa-setup');
421
- if (redirect)
425
+ if (redirect) {
422
426
  url.setQuery('redirect', redirect);
427
+ url.setQuery('provider', providerName);
428
+ }
423
429
  redirect = url.toString();
424
430
  }
425
431
  }
@@ -13,7 +13,7 @@ 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 { getSchema } from '../../utils/get-schema.js';
16
- import { isLoginRedirectAllowed } from '../utils/is-login-redirect-allowed.js';
16
+ import { resolveLoginRedirect } from '../utils/resolve-login-redirect.js';
17
17
  import { LocalAuthDriver } from './local.js';
18
18
  // Register the samlify schema validator
19
19
  samlify.setSchemaValidator(validator);
@@ -94,8 +94,12 @@ export function createSAMLAuthRouter(providerName) {
94
94
  const { context: url } = sp.createLoginRequest(idp, 'redirect');
95
95
  const parsedUrl = new URL(url);
96
96
  if (req.query['redirect']) {
97
- const redirect = req.query['redirect'];
98
- if (!isLoginRedirectAllowed(providerName, redirect)) {
97
+ let redirect = req.query['redirect'];
98
+ try {
99
+ redirect = resolveLoginRedirect(redirect, { provider: providerName });
100
+ }
101
+ catch (e) {
102
+ useLogger().error(e);
99
103
  throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
100
104
  }
101
105
  parsedUrl.searchParams.append('RelayState', redirect);
@@ -115,10 +119,16 @@ export function createSAMLAuthRouter(providerName) {
115
119
  }));
116
120
  router.post('/acs', express.urlencoded({ extended: false }), asyncHandler(async (req, res, next) => {
117
121
  const logger = useLogger();
118
- const relayState = req.body?.RelayState;
122
+ let redirect = req.body?.RelayState;
119
123
  const authMode = (env[`AUTH_${providerName.toUpperCase()}_MODE`] ?? 'session');
120
- if (relayState && isLoginRedirectAllowed(providerName, relayState) === false) {
121
- throw new InvalidPayloadError({ reason: `URL "${relayState}" can't be used to redirect after login` });
124
+ if (redirect) {
125
+ try {
126
+ redirect = resolveLoginRedirect(redirect, { provider: providerName });
127
+ }
128
+ catch (e) {
129
+ useLogger().error(e);
130
+ throw new InvalidPayloadError({ reason: `URL "${redirect}" can't be used to redirect after login` });
131
+ }
122
132
  }
123
133
  try {
124
134
  const { sp, idp } = getAuthProvider(providerName);
@@ -134,19 +144,19 @@ export function createSAMLAuthRouter(providerName) {
134
144
  expires,
135
145
  },
136
146
  };
137
- if (relayState) {
147
+ if (redirect) {
138
148
  if (authMode === 'session') {
139
149
  res.cookie(env['SESSION_COOKIE_NAME'], accessToken, SESSION_COOKIE_OPTIONS);
140
150
  }
141
151
  else {
142
152
  res.cookie(env['REFRESH_TOKEN_COOKIE_NAME'], refreshToken, REFRESH_COOKIE_OPTIONS);
143
153
  }
144
- return res.redirect(relayState);
154
+ return res.redirect(redirect);
145
155
  }
146
156
  return next();
147
157
  }
148
158
  catch (error) {
149
- if (relayState) {
159
+ if (redirect) {
150
160
  let reason = 'UNKNOWN_EXCEPTION';
151
161
  if (isDirectusError(error)) {
152
162
  reason = error.code;
@@ -154,7 +164,7 @@ export function createSAMLAuthRouter(providerName) {
154
164
  else {
155
165
  logger.warn(error, `[SAML] Unexpected error during SAML login`);
156
166
  }
157
- return res.redirect(`${relayState.split('?')[0]}?reason=${reason}`);
167
+ return res.redirect(`${redirect.split('?')[0]}?reason=${reason}`);
158
168
  }
159
169
  logger.warn(error, `[SAML] Unexpected error during SAML login`);
160
170
  throw error;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Resolves and validates the redirect URL after a successful SSO login.
3
+ * Returns a safe redirect path or URL, or throws if the redirect is invalid or not allowed.
4
+ * @param redirect URL or relative path to redirect to after login
5
+ * @param opts.provider SSO provider name, used to check provider-specific allow lists
6
+ * @returns Resolved redirect path or URL string
7
+ * @throws If the redirect is not a string, PUBLIC_URL is not defined, or the redirect is not allowed
8
+ */
9
+ export declare function resolveLoginRedirect(redirect: unknown, opts?: {
10
+ provider?: string | undefined;
11
+ }): string;
@@ -0,0 +1,62 @@
1
+ import { useEnv } from '@directus/env';
2
+ import { toArray } from '@directus/utils';
3
+ import isUrlAllowed from '../../utils/is-url-allowed.js';
4
+ /**
5
+ * Resolves and validates the redirect URL after a successful SSO login.
6
+ * Returns a safe redirect path or URL, or throws if the redirect is invalid or not allowed.
7
+ * @param redirect URL or relative path to redirect to after login
8
+ * @param opts.provider SSO provider name, used to check provider-specific allow lists
9
+ * @returns Resolved redirect path or URL string
10
+ * @throws If the redirect is not a string, PUBLIC_URL is not defined, or the redirect is not allowed
11
+ */
12
+ export function resolveLoginRedirect(redirect, opts = {}) {
13
+ const env = useEnv();
14
+ const publicURL = env['PUBLIC_URL'];
15
+ // Default empty redirect to root
16
+ if (!redirect)
17
+ return '/';
18
+ if (typeof redirect !== 'string')
19
+ throw new Error('"redirect" must be a string');
20
+ if (!publicURL)
21
+ throw new Error('"PUBLIC_URL" must be defined');
22
+ // Relative URL
23
+ if (URL.canParse(redirect) === false) {
24
+ try {
25
+ const dummyDomain = 'http://dummy.local';
26
+ const { protocol: dummyProtocol, host: dummyHost } = new URL(dummyDomain);
27
+ const parsedRelativeURL = new URL(redirect, dummyDomain);
28
+ if (dummyProtocol !== parsedRelativeURL.protocol || dummyHost !== parsedRelativeURL.host) {
29
+ throw new Error('Relative URL mismatch');
30
+ }
31
+ // If the protocol & host match then it is a safe relative path
32
+ return parsedRelativeURL.toString().replace(dummyDomain, '');
33
+ }
34
+ catch {
35
+ // Unparsable URL
36
+ throw new Error('Invalid relative URL');
37
+ }
38
+ }
39
+ // Absolute redirect
40
+ const parsedAbsoluteURL = new URL(redirect);
41
+ if (!['http:', 'https:'].includes(parsedAbsoluteURL.protocol)) {
42
+ throw new Error('Only http/https redirect protocols are allowed');
43
+ }
44
+ if (opts.provider) {
45
+ const envKey = `AUTH_${opts.provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
46
+ if (envKey in env) {
47
+ const allowedList = toArray(String(env[envKey]));
48
+ allowedList.push(publicURL);
49
+ if (isUrlAllowed(redirect, allowedList)) {
50
+ return parsedAbsoluteURL.toString();
51
+ }
52
+ }
53
+ }
54
+ if (URL.canParse(publicURL) === false)
55
+ throw new Error('PUBLIC_URL must be a valid URL');
56
+ const { protocol: publicProtocol, host: publicHost } = new URL(publicURL);
57
+ // Reject "app" redirects not matching PUBLIC_URL
58
+ if (publicProtocol !== parsedAbsoluteURL.protocol || publicHost !== parsedAbsoluteURL.host) {
59
+ throw new Error('App "redirect" must match PUBLIC_URL');
60
+ }
61
+ return parsedAbsoluteURL.toString();
62
+ }
@@ -0,0 +1,2 @@
1
+ declare const router: import("express-serve-static-core").Router;
2
+ export default router;
@@ -0,0 +1,95 @@
1
+ import { DEPLOYMENT_PROVIDER_TYPES } from '@directus/types';
2
+ import express from 'express';
3
+ import getDatabase from '../database/index.js';
4
+ import { getDeploymentDriver } from '../deployment.js';
5
+ import emitter from '../emitter.js';
6
+ import { useLogger } from '../logger/index.js';
7
+ import { DeploymentProjectsService } from '../services/deployment-projects.js';
8
+ import { DeploymentRunsService } from '../services/deployment-runs.js';
9
+ import { DeploymentService } from '../services/deployment.js';
10
+ import asyncHandler from '../utils/async-handler.js';
11
+ import { getSchema } from '../utils/get-schema.js';
12
+ const router = express.Router();
13
+ router.post('/:provider', asyncHandler(async (req, res) => {
14
+ const logger = useLogger();
15
+ const provider = req.params['provider'];
16
+ if (!DEPLOYMENT_PROVIDER_TYPES.includes(provider)) {
17
+ return res.sendStatus(404);
18
+ }
19
+ const rawBody = req.rawBody;
20
+ if (!rawBody) {
21
+ logger.debug(`[webhook:${provider}] No raw body`);
22
+ return res.sendStatus(400);
23
+ }
24
+ const schema = await getSchema();
25
+ const knex = getDatabase();
26
+ const deploymentService = new DeploymentService({
27
+ schema,
28
+ knex,
29
+ accountability: null,
30
+ });
31
+ let webhookConfig;
32
+ try {
33
+ webhookConfig = await deploymentService.getWebhookConfig(provider);
34
+ }
35
+ catch {
36
+ logger.warn(`[webhook:${provider}] No webhook config found`);
37
+ return res.sendStatus(404);
38
+ }
39
+ if (!webhookConfig.webhook_secret) {
40
+ logger.warn(`[webhook:${provider}] No webhook secret configured`);
41
+ return res.sendStatus(404);
42
+ }
43
+ const driver = getDeploymentDriver(provider, webhookConfig.credentials, webhookConfig.options);
44
+ // Fallback for providers whose API doesn't support webhook signature headers (e.g. Netlify)
45
+ const headers = { ...req.headers };
46
+ const queryToken = req.query['token'];
47
+ if (typeof queryToken === 'string') {
48
+ headers['x-webhook-token'] = queryToken;
49
+ }
50
+ const event = driver.verifyAndParseWebhook(rawBody, headers, webhookConfig.webhook_secret);
51
+ if (!event) {
52
+ logger.warn(`[webhook:${provider}] Verification failed or unknown event`);
53
+ try {
54
+ const body = JSON.parse(rawBody.toString('utf-8'));
55
+ logger.warn(`[webhook:${provider}] Raw event type: ${body.type ?? body.state ?? 'unknown'}`);
56
+ }
57
+ catch {
58
+ logger.warn(`[webhook:${provider}] Unparseable body: ${rawBody.toString('utf-8').slice(0, 200)}`);
59
+ }
60
+ return res.sendStatus(401);
61
+ }
62
+ // Look up project by external_id
63
+ const projectsService = new DeploymentProjectsService({
64
+ schema,
65
+ knex,
66
+ accountability: null,
67
+ });
68
+ const project = await projectsService.readByExternalId(event.project_external_id);
69
+ if (!project) {
70
+ // 410 signals the provider this project is no longer tracked
71
+ logger.info(`[webhook:${provider}] Project ${event.project_external_id} not tracked`);
72
+ return res.sendStatus(410);
73
+ }
74
+ const runsService = new DeploymentRunsService({
75
+ schema,
76
+ knex,
77
+ accountability: null,
78
+ });
79
+ const runId = await runsService.processWebhookEvent(project.id, event);
80
+ // Emit action events
81
+ const eventPayload = {
82
+ provider,
83
+ project_id: project.id,
84
+ run_id: runId,
85
+ external_id: event.deployment_external_id,
86
+ status: event.status,
87
+ url: event.url,
88
+ target: event.target,
89
+ timestamp: event.timestamp,
90
+ };
91
+ emitter.emitAction(['deployment.webhook', `deployment.webhook.${event.type}`], eventPayload, null);
92
+ logger.info(`[webhook:${provider}] Processed: ${event.type} → run ${runId}`);
93
+ return res.sendStatus(200);
94
+ }));
95
+ export default router;