@directus/api 34.0.0 → 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.
@@ -29,7 +29,7 @@ export declare const FieldsValidateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
29
29
  action: z.ZodLiteral<"update">;
30
30
  data: z.ZodArray<z.ZodObject<{
31
31
  field: z.ZodString;
32
- type: z.ZodString;
32
+ type: z.ZodOptional<z.ZodString>;
33
33
  name: z.ZodOptional<z.ZodString>;
34
34
  children: z.ZodOptional<z.ZodUnion<readonly [z.ZodArray<z.ZodRecord<z.ZodString, z.ZodAny>>, z.ZodNull]>>;
35
35
  collection: z.ZodOptional<z.ZodString>;
@@ -51,7 +51,7 @@ export declare const FieldsInputSchema: z.ZodObject<{
51
51
  collection: z.ZodOptional<z.ZodString>;
52
52
  field: z.ZodOptional<z.ZodString>;
53
53
  data: z.ZodOptional<z.ZodArray<z.ZodObject<{
54
- field: z.ZodOptional<z.ZodString>;
54
+ field: z.ZodNonOptional<z.ZodOptional<z.ZodString>>;
55
55
  type: z.ZodOptional<z.ZodNullable<z.ZodString>>;
56
56
  name: z.ZodOptional<z.ZodOptional<z.ZodString>>;
57
57
  collection: z.ZodOptional<z.ZodOptional<z.ZodString>>;
@@ -87,7 +87,7 @@ export declare const fields: import("../types.js").ToolConfig<{
87
87
  action: "update";
88
88
  data: {
89
89
  field: string;
90
- type: string;
90
+ type?: string | undefined;
91
91
  name?: string | undefined;
92
92
  children?: Record<string, any>[] | null | undefined;
93
93
  collection?: string | undefined;
@@ -28,7 +28,7 @@ export const FieldsValidateSchema = z.discriminatedUnion('action', [
28
28
  }),
29
29
  FieldsBaseValidateSchema.extend({
30
30
  action: z.literal('update'),
31
- data: z.array(RawFieldItemValidateSchema),
31
+ data: z.array(RawFieldItemValidateSchema.partial({ type: true })),
32
32
  }),
33
33
  FieldsBaseValidateSchema.extend({
34
34
  action: z.literal('delete'),
@@ -38,11 +38,17 @@ export const FieldsValidateSchema = z.discriminatedUnion('action', [
38
38
  export const FieldsInputSchema = z.object({
39
39
  action: z.enum(['read', 'create', 'update', 'delete']).describe('The operation to perform'),
40
40
  collection: z.string().describe('The name of the collection').optional(),
41
- field: z.string().optional(),
41
+ field: z
42
+ .string()
43
+ .describe('The name of the field. Required for delete. Optional for read (omit to read all fields). Do not use for create or update.')
44
+ .optional(),
42
45
  data: z
43
46
  .array(FieldItemInputSchema.extend({
44
47
  children: RawFieldItemInputSchema.shape.children,
45
- }).partial())
48
+ })
49
+ .partial()
50
+ .required({ field: true }))
51
+ .describe('Array of field objects for create/update actions. Each object must include "field" (the field name).')
46
52
  .optional(),
47
53
  });
48
54
  export const fields = defineTool({
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { useEnv } from '@directus/env';
1
2
  import { ErrorCode, ForbiddenError, isDirectusError, RouteNotFoundError } from '@directus/errors';
2
3
  import { format } from 'date-fns';
3
4
  import { Router } from 'express';
@@ -8,32 +9,37 @@ import { SpecificationService } from '../services/specifications.js';
8
9
  import asyncHandler from '../utils/async-handler.js';
9
10
  import { createAdmin } from '../utils/create-admin.js';
10
11
  const router = Router();
11
- router.get('/specs/oas', asyncHandler(async (req, res, next) => {
12
- const service = new SpecificationService({
13
- accountability: req.accountability,
14
- schema: req.schema,
15
- });
16
- res.locals['payload'] = await service.oas.generate(req.headers.host);
17
- return next();
18
- }), respond);
19
- router.get('/specs/graphql/:scope?', asyncHandler(async (req, res) => {
20
- const service = new SpecificationService({
21
- accountability: req.accountability,
22
- schema: req.schema,
23
- });
24
- const serverService = new ServerService({
25
- accountability: req.accountability,
26
- schema: req.schema,
27
- });
28
- const scope = req.params['scope'] || 'items';
29
- if (['items', 'system'].includes(scope) === false)
30
- throw new RouteNotFoundError({ path: req.path });
31
- const info = await serverService.serverInfo();
32
- const result = await service.graphql.generate(scope);
33
- const filename = info['project'].project_name + '_' + format(new Date(), 'yyyy-MM-dd') + '.graphql';
34
- res.attachment(filename);
35
- res.send(result);
36
- }));
12
+ const env = useEnv();
13
+ if (env['OPENAPI_ENABLED'] !== false) {
14
+ router.get('/specs/oas', asyncHandler(async (req, res, next) => {
15
+ const service = new SpecificationService({
16
+ accountability: req.accountability,
17
+ schema: req.schema,
18
+ });
19
+ res.locals['payload'] = await service.oas.generate(req.headers.host);
20
+ return next();
21
+ }), respond);
22
+ }
23
+ if (env['GRAPHQL_INTROSPECTION'] !== false) {
24
+ router.get('/specs/graphql/:scope?', asyncHandler(async (req, res) => {
25
+ const service = new SpecificationService({
26
+ accountability: req.accountability,
27
+ schema: req.schema,
28
+ });
29
+ const serverService = new ServerService({
30
+ accountability: req.accountability,
31
+ schema: req.schema,
32
+ });
33
+ const scope = req.params['scope'] || 'items';
34
+ if (['items', 'system'].includes(scope) === false)
35
+ throw new RouteNotFoundError({ path: req.path });
36
+ const info = await serverService.serverInfo();
37
+ const result = await service.graphql.generate(scope);
38
+ const filename = info['project'].project_name + '_' + format(new Date(), 'yyyy-MM-dd') + '.graphql';
39
+ res.attachment(filename);
40
+ res.send(result);
41
+ }));
42
+ }
37
43
  router.get('/info', asyncHandler(async (req, res, next) => {
38
44
  const service = new ServerService({
39
45
  accountability: req.accountability,
@@ -1,3 +1,5 @@
1
+ import { createError } from '@directus/errors';
2
+ import { ERRORS, Metadata } from '@tus/utils';
1
3
  import { Router } from 'express';
2
4
  import getDatabase from '../database/index.js';
3
5
  import { validateAccess } from '../permissions/modules/validate-access/validate-access.js';
@@ -18,11 +20,40 @@ const mapAction = (method) => {
18
20
  const checkFileAccess = asyncHandler(async (req, _res, next) => {
19
21
  if (req.accountability) {
20
22
  const action = mapAction(req.method);
21
- await validateAccess({
23
+ const validateAccessOptions = {
22
24
  action,
23
25
  collection: 'directus_files',
24
26
  accountability: req.accountability,
25
- }, {
27
+ };
28
+ if (req.method === 'POST' && req.header('upload-metadata')) {
29
+ let metadata;
30
+ try {
31
+ metadata = Metadata.parse(req.header('upload-metadata'));
32
+ }
33
+ catch {
34
+ throw new (createError('INVALID_METADATA', ERRORS.INVALID_METADATA.body, ERRORS.INVALID_METADATA.status_code))();
35
+ }
36
+ // On replacement ensure update for that record
37
+ if (metadata['id']) {
38
+ validateAccessOptions.action = 'update';
39
+ validateAccessOptions.primaryKeys = [metadata['id']];
40
+ }
41
+ // Validate permissions for any payload fields
42
+ const fields = [];
43
+ for (const field of Object.keys(req.schema.collections['directus_files'].fields)) {
44
+ // PK is not mutable, access to record is already checked via `primaryKeys` for updates
45
+ if (field === 'id') {
46
+ continue;
47
+ }
48
+ if (field in metadata) {
49
+ fields.push(field);
50
+ }
51
+ }
52
+ if (fields.length > 0) {
53
+ validateAccessOptions.fields = fields;
54
+ }
55
+ }
56
+ await validateAccess(validateAccessOptions, {
26
57
  schema: req.schema,
27
58
  knex: getDatabase(),
28
59
  });
@@ -3,6 +3,7 @@ import argon2 from 'argon2';
3
3
  import Busboy from 'busboy';
4
4
  import { Router } from 'express';
5
5
  import Joi from 'joi';
6
+ import { resolveLoginRedirect } from '../auth/utils/resolve-login-redirect.js';
6
7
  import collectionExists from '../middleware/collection-exists.js';
7
8
  import { respond } from '../middleware/respond.js';
8
9
  import { ExportService, ImportService } from '../services/import-export.js';
@@ -125,4 +126,21 @@ router.post('/cache/clear', asyncHandler(async (req, res) => {
125
126
  await service.clearCache({ system: clearSystemCache });
126
127
  res.status(200).end();
127
128
  }));
129
+ router.post('/resolve-redirect', asyncHandler(async (req, res) => {
130
+ if (!req.body?.redirect) {
131
+ throw new InvalidPayloadError({ reason: `"redirect" is required` });
132
+ }
133
+ if (req.body?.provider && typeof req.body.provider !== 'string') {
134
+ throw new InvalidPayloadError({ reason: `"provider" must be a string` });
135
+ }
136
+ try {
137
+ const resolved = resolveLoginRedirect(req.body.redirect, {
138
+ provider: req.body.provider,
139
+ });
140
+ return res.json({ data: resolved });
141
+ }
142
+ catch {
143
+ throw new InvalidPayloadError({ reason: `Invalid "redirect" provided` });
144
+ }
145
+ }));
128
146
  export default router;
@@ -2,11 +2,11 @@ export function applyAggregate(schema, dbQuery, aggregate, collection, hasJoins)
2
2
  for (const [operation, fields] of Object.entries(aggregate)) {
3
3
  if (!fields)
4
4
  continue;
5
+ if (operation === 'countAll') {
6
+ dbQuery.count('*', { as: 'countAll' });
7
+ continue;
8
+ }
5
9
  for (const field of fields) {
6
- if (operation === 'countAll') {
7
- dbQuery.count('*', { as: 'countAll' });
8
- continue;
9
- }
10
10
  if (operation === 'count' && field === '*') {
11
11
  dbQuery.count('*', { as: 'count' });
12
12
  continue;
@@ -1,8 +1,8 @@
1
1
  import type { FieldOverview } from '@directus/types';
2
2
  export declare function getFilterType(fields: Record<string, FieldOverview>, key: string, collection?: string): {
3
- type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "json" | "float" | "alias" | "uuid" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
3
+ type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "float" | "alias" | "uuid" | "json" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
4
4
  special?: never;
5
5
  } | {
6
- type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "json" | "float" | "alias" | "uuid" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
6
+ type: "string" | "boolean" | "binary" | "time" | "integer" | "unknown" | "date" | "text" | "float" | "alias" | "uuid" | "json" | "dateTime" | "timestamp" | "bigInteger" | "decimal" | "hash" | "csv" | "geometry" | "geometry.Point" | "geometry.LineString" | "geometry.Polygon" | "geometry.MultiPoint" | "geometry.MultiLineString" | "geometry.MultiPolygon";
7
7
  special: string[];
8
8
  };
@@ -15,7 +15,7 @@ export async function validateItemAccess(options, context) {
15
15
  const hasPrimaryKeys = options.primaryKeys && options.primaryKeys.length > 0;
16
16
  // For non-singletons, we must have PKs to validate against
17
17
  if (!isSingleton && !hasPrimaryKeys) {
18
- throw new Error(`Primary keys are required for non-singleton collection "${options.collection}"`);
18
+ return { accessAllowed: false };
19
19
  }
20
20
  // When we're looking up access to specific items, we have to read them from the database to
21
21
  // make sure you are allowed to access them.
@@ -135,35 +135,43 @@ export function injectSystemResolvers(gql, schemaComposer, { CreateCollectionTyp
135
135
  },
136
136
  });
137
137
  }
138
- /** Globally available query */
139
- schemaComposer.Query.addFields({
140
- server_specs_oas: {
141
- type: GraphQLJSON,
142
- resolve: async () => {
143
- const service = new SpecificationService({ schema: gql.schema, accountability: gql.accountability });
144
- return await service.oas.generate();
145
- },
146
- },
147
- server_specs_graphql: {
148
- type: GraphQLString,
149
- args: {
150
- scope: new GraphQLEnumType({
151
- name: 'graphql_sdl_scope',
152
- values: {
153
- items: { value: 'items' },
154
- system: { value: 'system' },
155
- },
156
- }),
138
+ if (env['OPENAPI_ENABLED'] !== false) {
139
+ schemaComposer.Query.addFields({
140
+ server_specs_oas: {
141
+ type: GraphQLJSON,
142
+ resolve: async () => {
143
+ const service = new SpecificationService({ schema: gql.schema, accountability: gql.accountability });
144
+ return await service.oas.generate();
145
+ },
157
146
  },
158
- resolve: async (_, args) => {
159
- const service = new GraphQLService({
160
- schema: gql.schema,
161
- accountability: gql.accountability,
162
- scope: args['scope'] ?? 'items',
163
- });
164
- return await generateSchema(service, 'sdl');
147
+ });
148
+ }
149
+ /** Globally available query */
150
+ if (env['GRAPHQL_INTROSPECTION'] !== false) {
151
+ schemaComposer.Query.addFields({
152
+ server_specs_graphql: {
153
+ type: GraphQLString,
154
+ args: {
155
+ scope: new GraphQLEnumType({
156
+ name: 'graphql_sdl_scope',
157
+ values: {
158
+ items: { value: 'items' },
159
+ system: { value: 'system' },
160
+ },
161
+ }),
162
+ },
163
+ resolve: async (_, args) => {
164
+ const service = new GraphQLService({
165
+ schema: gql.schema,
166
+ accountability: gql.accountability,
167
+ scope: args['scope'] ?? 'items',
168
+ });
169
+ return await generateSchema(service, 'sdl');
170
+ },
165
171
  },
166
- },
172
+ });
173
+ }
174
+ schemaComposer.Query.addFields({
167
175
  server_ping: {
168
176
  type: GraphQLString,
169
177
  resolve: () => 'pong',
@@ -17,6 +17,7 @@ This directory contains mock implementations for commonly used modules in servic
17
17
  - **[files-service.ts](#files-servicets)** - FilesService mocks
18
18
  - **[folders-service.ts](#folders-servicets)** - FoldersService mocks
19
19
  - **[test-helpers.ts](#test-helpersts)** - Test data factory functions
20
+ - **[controllers.ts](#controllersts)** - Controller/router testing helpers
20
21
 
21
22
  ## Quick Start
22
23
 
@@ -521,6 +522,116 @@ const buildTreeSpy = vi.spyOn(FoldersService.prototype, 'buildTree').mockResolve
521
522
 
522
523
  ---
523
524
 
525
+ ### controllers.ts
526
+
527
+ Provides helpers for extracting route handlers from Express routers and creating mock Express request/response objects
528
+ for controller tests.
529
+
530
+ #### `getRouteHandler(router, method, path)`
531
+
532
+ Extracts the middleware/handler stack for a specific route from an Express router.
533
+
534
+ **Parameters:**
535
+
536
+ - `router`: The Express `Router` instance
537
+ - `method`: HTTP method (`'GET'`, `'POST'`, `'PATCH'`, `'DELETE'`, etc.)
538
+ - `path`: Route path (e.g. `'/'`, `'/:id'`)
539
+
540
+ **Returns:** Array of `{ handle: (...args) => any }` layers for the matched route
541
+
542
+ **Throws:** If no matching route is found
543
+
544
+ **Example:**
545
+
546
+ ```typescript
547
+ import { default as router } from './tus.js';
548
+ import { getRouteHandler } from '../test-utils/controllers.js';
549
+
550
+ const [checkAccess, handler] = getRouteHandler(router, 'POST', '/');
551
+ await checkAccess?.handle(req, res, next);
552
+ ```
553
+
554
+ #### `createMockRequest(overrides?)`
555
+
556
+ Creates a mock Express Request pre-populated with common Directus properties (`accountability`, `schema`,
557
+ `sanitizedQuery`, etc.).
558
+
559
+ **Parameters:**
560
+
561
+ - `overrides` (optional): Properties to merge into the mock request
562
+
563
+ **Returns:** Mock `Request` object
564
+
565
+ **Example:**
566
+
567
+ ```typescript
568
+ import { createMockRequest } from '../test-utils/controllers.js';
569
+
570
+ // Minimal request
571
+ const req = createMockRequest({ schema });
572
+
573
+ // With accountability and custom header
574
+ const req = createMockRequest({
575
+ method: 'POST',
576
+ accountability,
577
+ schema,
578
+ header: vi.fn().mockReturnValue('some-value'),
579
+ });
580
+ ```
581
+
582
+ #### `createMockResponse(overrides?)`
583
+
584
+ Creates a mock Express Response with chainable methods (`status`, `json`, `send`, `set`, `end`).
585
+
586
+ **Parameters:**
587
+
588
+ - `overrides` (optional): Properties to merge into the mock response
589
+
590
+ **Returns:** Mock `Response` object
591
+
592
+ **Example:**
593
+
594
+ ```typescript
595
+ import { createMockResponse } from '../test-utils/controllers.js';
596
+
597
+ const res = createMockResponse();
598
+ ```
599
+
600
+ #### Full Controller Test Example
601
+
602
+ ```typescript
603
+ import { SchemaBuilder } from '@directus/schema-builder';
604
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
605
+ import { createMockRequest, createMockResponse, getRouteHandler } from '../test-utils/controllers.js';
606
+ import { default as router } from './controller.js';
607
+
608
+ const schema = new SchemaBuilder()
609
+ .collection('collection', (c) => {
610
+ c.field('id').integer().primary();
611
+ c.field('title').string();
612
+ })
613
+ .build();
614
+
615
+ describe('controller', () => {
616
+ beforeEach(() => {
617
+ vi.clearAllMocks();
618
+ });
619
+
620
+ test('validates access on POST', async () => {
621
+ const req = createMockRequest({ method: 'POST', accountability, schema });
622
+ const res = createMockResponse();
623
+ const next = vi.fn();
624
+
625
+ const [firstHandler] = getRouteHandler(router, 'POST', '/');
626
+ await firstHandler?.handle(req, res, next);
627
+
628
+ expect(next).toHaveBeenCalled();
629
+ });
630
+ });
631
+ ```
632
+
633
+ ---
634
+
524
635
  ## Common Patterns
525
636
 
526
637
  ### Full Service Test Setup
@@ -737,6 +848,7 @@ See these files for complete examples:
737
848
 
738
849
  - [collections.test.ts](../services/collections.test.ts) - Full service test with schema operations
739
850
  - [fields.test.ts](../services/fields.test.ts) - Complex service test with field management
851
+ - [tus.test.ts](../controllers/tus.test.ts) - Controller test with route handler extraction and access validation
740
852
 
741
853
  ---
742
854
 
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Controller testing utilities
3
+ * Provides helpers for extracting route handlers and creating mock Express request/response objects
4
+ */
5
+ import type { Request, Response, Router } from 'express';
6
+ /**
7
+ * Get a route handler stack from an Express router
8
+ *
9
+ * @param router The Express router instance to search
10
+ * @param method HTTP method (GET, POST, PATCH, DELETE, etc.)
11
+ * @param path Route path (e.g. '/', '/:id')
12
+ * @returns Array of middleware/handler layers for the matched route
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { default as router } from './controller.js';
17
+
18
+ * const [firstHandler, secondHandler] = getRouteHandler(router, 'POST', '/');
19
+ * await firstHandler?.handle(req, res, next);
20
+ * ```
21
+ */
22
+ export declare function getRouteHandler(router: Router, method: string, path: string): Array<{
23
+ handle: (...args: any[]) => any;
24
+ }>;
25
+ /**
26
+ * Creates a mock Express Request with common Directus properties.
27
+ *
28
+ * @param overrides Properties to merge into the mock request
29
+ * @returns Mock Express Request object
30
+ *
31
+ * @example
32
+ * ```typescript
33
+ * // Basic usage
34
+ * const req = createMockRequest({ method: 'POST', accountability });
35
+ *
36
+ * // With custom schema
37
+ * const req = createMockRequest({
38
+ * method: 'PATCH',
39
+ * params: { id: 'file-1' },
40
+ * schema: mySchema,
41
+ * });
42
+ *
43
+ * // With custom header mock
44
+ * const req = createMockRequest({
45
+ * header: vi.fn().mockReturnValue('some-header-value'),
46
+ * });
47
+ * ```
48
+ */
49
+ export declare function createMockRequest(overrides?: Partial<Request>): Request;
50
+ /**
51
+ * Creates a mock Express Response with chainable methods.
52
+ *
53
+ * @param overrides Properties to merge into the mock response
54
+ * @returns Mock Express Response object
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * // Basic usage
59
+ * const res = createMockResponse();
60
+ *
61
+ * // With custom locals
62
+ * const res = createMockResponse({ locals: { payload: { data: [] } } });
63
+ * ```
64
+ */
65
+ export declare function createMockResponse(overrides?: Partial<Response>): Response;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Controller testing utilities
3
+ * Provides helpers for extracting route handlers and creating mock Express request/response objects
4
+ */
5
+ import { vi } from 'vitest';
6
+ /**
7
+ * Get a route handler stack from an Express router
8
+ *
9
+ * @param router The Express router instance to search
10
+ * @param method HTTP method (GET, POST, PATCH, DELETE, etc.)
11
+ * @param path Route path (e.g. '/', '/:id')
12
+ * @returns Array of middleware/handler layers for the matched route
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { default as router } from './controller.js';
17
+
18
+ * const [firstHandler, secondHandler] = getRouteHandler(router, 'POST', '/');
19
+ * await firstHandler?.handle(req, res, next);
20
+ * ```
21
+ */
22
+ export function getRouteHandler(router, method, path) {
23
+ const stack = router.stack;
24
+ const layer = stack.find((l) => l.route?.path === path && l.route?.methods[method.toLowerCase()]);
25
+ if (!layer)
26
+ throw new Error(`No route found for ${method} ${path}`);
27
+ return layer.route.stack;
28
+ }
29
+ /**
30
+ * Creates a mock Express Request with common Directus properties.
31
+ *
32
+ * @param overrides Properties to merge into the mock request
33
+ * @returns Mock Express Request object
34
+ *
35
+ * @example
36
+ * ```typescript
37
+ * // Basic usage
38
+ * const req = createMockRequest({ method: 'POST', accountability });
39
+ *
40
+ * // With custom schema
41
+ * const req = createMockRequest({
42
+ * method: 'PATCH',
43
+ * params: { id: 'file-1' },
44
+ * schema: mySchema,
45
+ * });
46
+ *
47
+ * // With custom header mock
48
+ * const req = createMockRequest({
49
+ * header: vi.fn().mockReturnValue('some-header-value'),
50
+ * });
51
+ * ```
52
+ */
53
+ export function createMockRequest(overrides = {}) {
54
+ const headerFn = vi.fn().mockReturnValue(undefined);
55
+ return {
56
+ method: 'GET',
57
+ headers: {},
58
+ params: {},
59
+ body: {},
60
+ header: headerFn,
61
+ get: headerFn,
62
+ is: vi.fn().mockReturnValue(false),
63
+ token: null,
64
+ collection: '',
65
+ singleton: false,
66
+ accountability: undefined,
67
+ sanitizedQuery: {},
68
+ schema: {
69
+ collections: {},
70
+ relations: [],
71
+ },
72
+ ...overrides,
73
+ };
74
+ }
75
+ /**
76
+ * Creates a mock Express Response with chainable methods.
77
+ *
78
+ * @param overrides Properties to merge into the mock response
79
+ * @returns Mock Express Response object
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * // Basic usage
84
+ * const res = createMockResponse();
85
+ *
86
+ * // With custom locals
87
+ * const res = createMockResponse({ locals: { payload: { data: [] } } });
88
+ * ```
89
+ */
90
+ export function createMockResponse(overrides = {}) {
91
+ return {
92
+ locals: {},
93
+ status: vi.fn().mockReturnThis(),
94
+ json: vi.fn().mockReturnThis(),
95
+ send: vi.fn().mockReturnThis(),
96
+ set: vi.fn().mockReturnThis(),
97
+ end: vi.fn().mockReturnThis(),
98
+ ...overrides,
99
+ };
100
+ }
@@ -24,7 +24,7 @@ import type { DatabaseClient } from '@directus/types';
24
24
  * ```
25
25
  */
26
26
  export declare function mockDatabase(client?: DatabaseClient): {
27
- default: import("vitest").Mock<(...args: any[]) => any>;
27
+ default: import("vitest").Mock<() => import("vitest").MockedFunction<import("knex").Knex<any, unknown[]>>>;
28
28
  getDatabaseClient: import("vitest").Mock<(...args: any[]) => any>;
29
29
  getSchemaInspector: import("vitest").Mock<(...args: any[]) => any>;
30
30
  };
@@ -3,6 +3,7 @@
3
3
  * Provides simplified mocks for src/database/index module used in service testing
4
4
  */
5
5
  import { vi } from 'vitest';
6
+ import { createMockKnex } from './knex.js';
6
7
  /**
7
8
  * Creates a standard database mock for service tests
8
9
  * This matches the pattern used across all service test files
@@ -24,8 +25,9 @@ import { vi } from 'vitest';
24
25
  * ```
25
26
  */
26
27
  export function mockDatabase(client = 'postgres') {
28
+ const { db } = createMockKnex();
27
29
  return {
28
- default: vi.fn(),
30
+ default: vi.fn(() => db),
29
31
  getDatabaseClient: vi.fn().mockReturnValue(client),
30
32
  getSchemaInspector: vi.fn(),
31
33
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directus/api",
3
- "version": "34.0.0",
3
+ "version": "34.0.1",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "keywords": [
6
6
  "directus",
@@ -58,10 +58,10 @@
58
58
  "dist"
59
59
  ],
60
60
  "dependencies": {
61
- "@ai-sdk/anthropic": "3.0.9",
62
- "@ai-sdk/google": "3.0.6",
63
- "@ai-sdk/openai": "3.0.7",
64
- "@ai-sdk/openai-compatible": "2.0.4",
61
+ "@ai-sdk/anthropic": "3.0.58",
62
+ "@ai-sdk/google": "3.0.43",
63
+ "@ai-sdk/openai": "3.0.41",
64
+ "@ai-sdk/openai-compatible": "2.0.35",
65
65
  "@authenio/samlify-node-xmllint": "2.0.0",
66
66
  "@aws-sdk/client-sesv2": "3.928.0",
67
67
  "@godaddy/terminus": "4.12.1",
@@ -72,7 +72,7 @@
72
72
  "@rollup/plugin-virtual": "3.0.2",
73
73
  "@tus/server": "2.3.0",
74
74
  "@tus/utils": "0.6.0",
75
- "ai": "6.0.25",
75
+ "ai": "6.0.116",
76
76
  "archiver": "7.0.1",
77
77
  "argon2": "0.44.0",
78
78
  "async": "3.2.6",
@@ -118,8 +118,8 @@
118
118
  "keyv": "5.5.3",
119
119
  "knex": "3.1.0",
120
120
  "ldapts": "8.1.3",
121
- "liquidjs": "10.24.0",
122
- "lodash-es": "4.17.21",
121
+ "liquidjs": "10.25.0",
122
+ "lodash-es": "4.17.23",
123
123
  "marked": "16.4.1",
124
124
  "micromustache": "8.0.3",
125
125
  "mime-types": "3.0.1",
@@ -161,31 +161,31 @@
161
161
  "ws": "8.18.3",
162
162
  "zod": "4.1.12",
163
163
  "zod-validation-error": "4.0.2",
164
- "@directus/ai": "1.2.0",
165
- "@directus/env": "5.6.0",
166
- "@directus/errors": "2.2.0",
164
+ "@directus/ai": "1.3.0",
167
165
  "@directus/constants": "14.2.0",
168
- "@directus/extensions": "3.0.20",
169
- "@directus/extensions-sdk": "17.0.10",
170
- "@directus/extensions-registry": "3.0.20",
171
- "@directus/app": "15.5.0",
166
+ "@directus/app": "15.5.1",
167
+ "@directus/env": "5.6.1",
168
+ "@directus/errors": "2.2.0",
169
+ "@directus/extensions": "3.0.21",
170
+ "@directus/extensions-registry": "3.0.21",
172
171
  "@directus/format-title": "12.1.1",
172
+ "@directus/memory": "3.1.4",
173
+ "@directus/pressure": "3.0.19",
173
174
  "@directus/schema": "13.0.5",
174
- "@directus/pressure": "3.0.18",
175
- "@directus/schema-builder": "0.0.15",
175
+ "@directus/schema-builder": "0.0.16",
176
+ "@directus/extensions-sdk": "17.0.11",
176
177
  "@directus/specs": "12.0.1",
177
- "@directus/storage-driver-azure": "12.0.18",
178
178
  "@directus/storage": "12.0.3",
179
- "@directus/storage-driver-gcs": "12.0.18",
179
+ "@directus/storage-driver-gcs": "12.0.19",
180
+ "@directus/storage-driver-cloudinary": "12.0.19",
181
+ "@directus/storage-driver-s3": "12.1.5",
180
182
  "@directus/storage-driver-local": "12.0.3",
181
- "@directus/storage-driver-cloudinary": "12.0.18",
182
- "@directus/storage-driver-s3": "12.1.4",
183
- "@directus/storage-driver-supabase": "3.0.18",
184
- "@directus/system-data": "4.2.0",
185
- "@directus/utils": "13.3.0",
186
- "@directus/validation": "2.0.18",
187
- "@directus/memory": "3.1.3",
188
- "directus": "11.16.0"
183
+ "@directus/system-data": "4.3.0",
184
+ "@directus/storage-driver-supabase": "3.0.19",
185
+ "@directus/validation": "2.0.19",
186
+ "@directus/utils": "13.3.1",
187
+ "directus": "11.16.1",
188
+ "@directus/storage-driver-azure": "12.0.19"
189
189
  },
190
190
  "devDependencies": {
191
191
  "@directus/tsconfig": "3.0.0",
@@ -228,8 +228,8 @@
228
228
  "knex-mock-client": "3.0.2",
229
229
  "typescript": "5.9.3",
230
230
  "vitest": "3.2.4",
231
- "@directus/types": "14.3.0",
232
- "@directus/schema-builder": "0.0.15"
231
+ "@directus/schema-builder": "0.0.16",
232
+ "@directus/types": "14.3.1"
233
233
  },
234
234
  "optionalDependencies": {
235
235
  "@keyv/redis": "3.0.1",
@@ -1,7 +0,0 @@
1
- /**
2
- * Checks if the defined redirect after successful SSO login is in the allow list
3
- * @param provider SSO provider name
4
- * @param redirect URL to redirect to after login
5
- * @returns True if the redirect is allowed, false otherwise
6
- */
7
- export declare function isLoginRedirectAllowed(provider: string, redirect: unknown): boolean;
@@ -1,39 +0,0 @@
1
- import { useEnv } from '@directus/env';
2
- import { toArray } from '@directus/utils';
3
- import { useLogger } from '../../logger/index.js';
4
- import isUrlAllowed from '../../utils/is-url-allowed.js';
5
- /**
6
- * Checks if the defined redirect after successful SSO login is in the allow list
7
- * @param provider SSO provider name
8
- * @param redirect URL to redirect to after login
9
- * @returns True if the redirect is allowed, false otherwise
10
- */
11
- export function isLoginRedirectAllowed(provider, redirect) {
12
- if (!redirect)
13
- return true; // empty redirect
14
- if (typeof redirect !== 'string')
15
- return false; // invalid type
16
- const env = useEnv();
17
- const publicUrl = env['PUBLIC_URL'];
18
- if (!URL.canParse(redirect)) {
19
- if (!redirect.startsWith('//')) {
20
- // should be a relative path like `/admin/test`
21
- return true;
22
- }
23
- // domain without protocol `//example.com/test`
24
- return false;
25
- }
26
- const envKey = `AUTH_${provider.toUpperCase()}_REDIRECT_ALLOW_LIST`;
27
- if (envKey in env) {
28
- if (isUrlAllowed(redirect, [...toArray(env[envKey]), publicUrl]))
29
- return true;
30
- }
31
- if (URL.canParse(publicUrl) === false) {
32
- useLogger().error('Invalid PUBLIC_URL for login redirect');
33
- return false;
34
- }
35
- const { protocol: redirectProtocol, host: redirectHost } = new URL(redirect);
36
- const { protocol: publicProtocol, host: publicHost } = new URL(publicUrl);
37
- // allow redirects to the defined PUBLIC_URL (protocol + host including port)
38
- return `${redirectProtocol}//${redirectHost}` === `${publicProtocol}//${publicHost}`;
39
- }