@directus/api 35.2.0 → 36.0.0-rc.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.
Files changed (188) hide show
  1. package/dist/ai/chat/models/chat-request.js +48 -48
  2. package/dist/ai/chat/models/object-request.js +6 -6
  3. package/dist/ai/chat/models/providers.js +14 -14
  4. package/dist/ai/chat/utils/parse-json-schema-7.js +22 -22
  5. package/dist/ai/mcp/server.js +44 -6
  6. package/dist/ai/mcp/utils.js +31 -0
  7. package/dist/ai/tools/assets/index.js +3 -3
  8. package/dist/ai/tools/collections/index.js +18 -18
  9. package/dist/ai/tools/fields/index.js +18 -18
  10. package/dist/ai/tools/files/index.js +18 -18
  11. package/dist/ai/tools/flows/index.js +16 -16
  12. package/dist/ai/tools/folders/index.js +18 -18
  13. package/dist/ai/tools/items/index.js +17 -17
  14. package/dist/ai/tools/operations/index.js +16 -16
  15. package/dist/ai/tools/relations/index.js +22 -22
  16. package/dist/ai/tools/schema/index.js +3 -3
  17. package/dist/ai/tools/schema.js +159 -159
  18. package/dist/ai/tools/system/index.js +3 -3
  19. package/dist/ai/tools/trigger-flow/index.js +3 -3
  20. package/dist/app.js +33 -9
  21. package/dist/auth/drivers/ldap.js +3 -1
  22. package/dist/auth/drivers/local.js +2 -0
  23. package/dist/auth/drivers/oauth2.js +3 -1
  24. package/dist/auth/drivers/openid.js +3 -1
  25. package/dist/auth/drivers/saml.js +2 -0
  26. package/dist/auth/utils/check-local-disabled.js +16 -0
  27. package/dist/auth/utils/check-sso-enabled.js +14 -0
  28. package/dist/auth.js +8 -5
  29. package/dist/cli/commands/bootstrap/index.js +3 -0
  30. package/dist/cli/commands/cache/clear.js +6 -1
  31. package/dist/cli/commands/roles/create.js +4 -1
  32. package/dist/cli/commands/users/create.js +3 -0
  33. package/dist/constants.js +8 -1
  34. package/dist/controllers/access.js +1 -1
  35. package/dist/controllers/activity.js +2 -1
  36. package/dist/controllers/assets.js +2 -0
  37. package/dist/controllers/auth.js +13 -5
  38. package/dist/controllers/collections.js +1 -1
  39. package/dist/controllers/comments.js +1 -1
  40. package/dist/controllers/dashboards.js +1 -1
  41. package/dist/controllers/fields.js +1 -1
  42. package/dist/controllers/files.js +3 -1
  43. package/dist/controllers/flows.js +6 -5
  44. package/dist/controllers/folders.js +1 -1
  45. package/dist/controllers/graphql.js +2 -0
  46. package/dist/controllers/items.js +3 -1
  47. package/dist/controllers/license.js +119 -0
  48. package/dist/controllers/mcp/index.js +38 -0
  49. package/dist/controllers/mcp/oauth-clients.js +68 -0
  50. package/dist/controllers/mcp/oauth-consent-page.js +316 -0
  51. package/dist/controllers/mcp/oauth.js +381 -0
  52. package/dist/controllers/mcp/templates/oauth-consent.liquid +62 -0
  53. package/dist/controllers/mcp/templates/oauth-error.liquid +28 -0
  54. package/dist/controllers/notifications.js +1 -1
  55. package/dist/controllers/operations.js +1 -1
  56. package/dist/controllers/panels.js +1 -1
  57. package/dist/controllers/permissions.js +1 -1
  58. package/dist/controllers/policies.js +1 -1
  59. package/dist/controllers/presets.js +1 -1
  60. package/dist/controllers/revisions.js +3 -2
  61. package/dist/controllers/roles.js +1 -1
  62. package/dist/controllers/server.js +38 -9
  63. package/dist/controllers/shares.js +1 -1
  64. package/dist/controllers/translations.js +1 -1
  65. package/dist/controllers/users.js +1 -1
  66. package/dist/controllers/utils.js +2 -2
  67. package/dist/controllers/versions.js +12 -5
  68. package/dist/database/get-ast-from-query/lib/convert-wildcards.js +10 -1
  69. package/dist/database/get-ast-from-query/lib/parse-fields.js +2 -1
  70. package/dist/database/helpers/fn/dialects/mysql.js +7 -12
  71. package/dist/database/helpers/fn/dialects/oracle.js +3 -4
  72. package/dist/database/helpers/fn/dialects/postgres.js +4 -26
  73. package/dist/database/helpers/fn/json/mysql-json-path.js +22 -0
  74. package/dist/database/helpers/fn/json/parse-function.js +14 -6
  75. package/dist/database/helpers/fn/json/postgres-json-path.js +54 -0
  76. package/dist/database/migrations/20260110A-add-ai-provider-settings.js +4 -4
  77. package/dist/database/migrations/20260217A-null-item-versions.js +14 -0
  78. package/dist/database/migrations/20260312A-add-ai-translation-settings.js +18 -0
  79. package/dist/database/migrations/20260507A-add-licensing.js +22 -0
  80. package/dist/database/migrations/20260512A-add-autosave-revision-interval.js +14 -0
  81. package/dist/database/migrations/20260512B-add-mcp-oauth.js +87 -0
  82. package/dist/database/run-ast/lib/apply-query/filter/operator.js +116 -33
  83. package/dist/database/run-ast/lib/apply-query/index.js +4 -1
  84. package/dist/database/run-ast/lib/apply-query/sort.js +17 -7
  85. package/dist/database/run-ast/lib/get-db-query.js +21 -9
  86. package/dist/database/run-ast/lib/parse-current-level.js +2 -1
  87. package/dist/database/run-ast/run-ast.js +2 -1
  88. package/dist/database/run-ast/utils/get-column.js +2 -1
  89. package/dist/extensions/lib/installation/manager.js +1 -1
  90. package/dist/extensions/lib/sandbox/register/operation.js +1 -1
  91. package/dist/extensions/lib/sync/sync.js +1 -1
  92. package/dist/extensions/manager.js +3 -3
  93. package/dist/flows.js +5 -5
  94. package/dist/license/entitlements/lib/collections.js +37 -0
  95. package/dist/license/entitlements/lib/custom-llms-enabled.js +18 -0
  96. package/dist/license/entitlements/lib/custom-permission-rules-enabled.js +41 -0
  97. package/dist/license/entitlements/lib/flows.js +29 -0
  98. package/dist/license/entitlements/lib/seats.js +103 -0
  99. package/dist/license/entitlements/lib/sso-enabled.js +45 -0
  100. package/dist/license/entitlements/manager.js +256 -0
  101. package/dist/license/index.js +4 -0
  102. package/dist/license/manager.js +505 -0
  103. package/dist/license/utils/compute-license-status.js +27 -0
  104. package/dist/license/utils/get-core-grace-expires-at.js +38 -0
  105. package/dist/license/utils/get-license-key.js +23 -0
  106. package/dist/license/utils/get-license-token.js +23 -0
  107. package/dist/license/utils/handle-license-error.js +41 -0
  108. package/dist/license/utils/is-in-core-grace-period.js +11 -0
  109. package/dist/license/utils/is-sso-bypass-allowed.js +21 -0
  110. package/dist/license/utils/use-rpc.js +33 -0
  111. package/dist/middleware/cache.js +4 -1
  112. package/dist/middleware/error-handler.js +11 -0
  113. package/dist/middleware/extract-token.js +11 -2
  114. package/dist/middleware/is-admin.js +16 -0
  115. package/dist/middleware/is-locked.js +16 -0
  116. package/dist/middleware/mcp-oauth-guard.js +23 -0
  117. package/dist/middleware/request-counter.js +5 -2
  118. package/dist/packages/types/dist/index.js +117 -122
  119. package/dist/permissions/modules/process-ast/utils/extract-paths-from-query.js +10 -1
  120. package/dist/permissions/utils/get-unaliased-field-key.js +2 -1
  121. package/dist/request/is-denied-ip.js +2 -0
  122. package/dist/schedules/license.js +31 -0
  123. package/dist/schedules/oauth-cleanup.js +26 -0
  124. package/dist/schedules/retention.js +1 -1
  125. package/dist/schedules/telemetry.js +4 -1
  126. package/dist/schedules/tus.js +1 -1
  127. package/dist/schedules/utils/duration-to-cron.js +36 -0
  128. package/dist/services/activity.js +15 -0
  129. package/dist/services/authentication.js +12 -5
  130. package/dist/services/collections.js +40 -10
  131. package/dist/services/fields.js +6 -6
  132. package/dist/services/flows.js +12 -0
  133. package/dist/services/graphql/resolvers/system-admin.js +2 -2
  134. package/dist/services/graphql/resolvers/system-global.js +1 -1
  135. package/dist/services/graphql/resolvers/system.js +34 -18
  136. package/dist/services/graphql/schema/get-types.js +23 -2
  137. package/dist/services/graphql/schema/parse-query.js +8 -0
  138. package/dist/services/graphql/schema/read.js +12 -0
  139. package/dist/services/graphql/types/json-filter.js +30 -0
  140. package/dist/services/index.js +6 -6
  141. package/dist/services/items.js +32 -14
  142. package/dist/services/mcp-oauth/cimd.js +307 -0
  143. package/dist/services/mcp-oauth/index.js +1185 -0
  144. package/dist/services/mcp-oauth/types/error.js +22 -0
  145. package/dist/services/mcp-oauth/utils/cimd-egress.js +182 -0
  146. package/dist/services/mcp-oauth/utils/domain.js +21 -0
  147. package/dist/services/mcp-oauth/utils/loopback.js +11 -0
  148. package/dist/services/mcp-oauth/utils/redirect.js +84 -0
  149. package/dist/services/mcp-oauth/utils/registration-debug.js +131 -0
  150. package/dist/services/payload.js +2 -1
  151. package/dist/services/permissions.js +31 -9
  152. package/dist/services/revisions.js +15 -0
  153. package/dist/services/server.js +21 -4
  154. package/dist/services/settings.js +37 -3
  155. package/dist/services/users.js +13 -6
  156. package/dist/services/utils.js +6 -1
  157. package/dist/services/versions.js +137 -69
  158. package/dist/utils/calculate-field-depth.js +1 -0
  159. package/dist/utils/deep-freeze.js +24 -0
  160. package/dist/utils/extract-function-name.js +13 -0
  161. package/dist/utils/generate-translations.js +5 -5
  162. package/dist/utils/get-accountability-for-token.js +13 -1
  163. package/dist/utils/get-cache-key.js +1 -1
  164. package/dist/utils/get-history-filter-query.js +22 -0
  165. package/dist/utils/get-schema.js +2 -2
  166. package/dist/utils/get-service.js +3 -3
  167. package/dist/utils/is-admin.js +9 -0
  168. package/dist/utils/parse-oauth-scope.js +12 -0
  169. package/dist/utils/sanitize-query.js +1 -1
  170. package/dist/utils/split-field-path.js +29 -0
  171. package/dist/utils/transaction.js +2 -2
  172. package/dist/utils/translations-validation.js +2 -2
  173. package/dist/utils/validate-query.js +35 -4
  174. package/dist/utils/validate-user-count-integrity.js +28 -5
  175. package/dist/utils/verify-session-jwt.js +5 -2
  176. package/dist/utils/versioning/handle-version.js +130 -48
  177. package/dist/utils/versioning/remove-circular.js +17 -0
  178. package/dist/websocket/authenticate.js +2 -1
  179. package/dist/websocket/collab/collab.js +1 -1
  180. package/dist/websocket/collab/room.js +1 -1
  181. package/dist/websocket/controllers/base.js +12 -0
  182. package/dist/websocket/controllers/graphql.js +1 -1
  183. package/dist/websocket/handlers/subscribe.js +1 -1
  184. package/dist/websocket/messages.js +64 -64
  185. package/dist/websocket/utils/items.js +2 -2
  186. package/license +90 -80
  187. package/package.json +33 -32
  188. package/dist/controllers/mcp.js +0 -31
@@ -0,0 +1,30 @@
1
+ import { GraphQLScalarType } from "graphql";
2
+ import { GraphQLJSON } from "graphql-compose";
3
+
4
+ //#region src/services/graphql/types/json-filter.ts
5
+ function validateJsonFilterValue(value) {
6
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("json filter value must be a plain object mapping JSON path keys to filter operators");
7
+ for (const [key, operatorObj] of Object.entries(value)) if (key.trim().length === 0) throw new Error("json filter: keys must not be empty");
8
+ else if (key === "_or" || key === "_and") {
9
+ if (!Array.isArray(operatorObj)) throw new Error(`json filter: "${key}" must be an array of filter objects`);
10
+ } else if (typeof operatorObj !== "object" || operatorObj === null || Array.isArray(operatorObj)) throw new Error(`json filter: "${key}" must be a filter operator object (e.g. { "_eq": "value" })`);
11
+ return value;
12
+ }
13
+ /**
14
+ * A scalar representing a JSON filter value: a plain object mapping JSON path keys
15
+ * to filter operator objects (e.g. { "color": { "_eq": "red" }, "size": { "_gt": 5 } }).
16
+ */
17
+ const GraphQLJsonFilter = new GraphQLScalarType({
18
+ ...GraphQLJSON,
19
+ name: "GraphQLJsonFilter",
20
+ description: "A JSON filter value: a plain object mapping JSON path keys to filter operators (e.g. { \"key\": { \"_eq\": \"value\" } })",
21
+ parseValue(value) {
22
+ return validateJsonFilterValue(value);
23
+ },
24
+ parseLiteral(ast, variables) {
25
+ return validateJsonFilterValue(GraphQLJSON.parseLiteral(ast, variables));
26
+ }
27
+ });
28
+
29
+ //#endregion
30
+ export { GraphQLJsonFilter };
@@ -4,22 +4,22 @@ import { ItemsService } from "./items.js";
4
4
  import { FilesService } from "./files.js";
5
5
  import { FoldersService } from "./folders.js";
6
6
  import { AssetsService } from "./assets.js";
7
- import { RelationsService } from "./relations.js";
8
- import { FieldsService, systemFieldUpdateSchema } from "./fields.js";
9
- import { CollectionsService } from "./collections.js";
10
- import { ActivityService } from "./activity.js";
11
7
  import { AccessService } from "./access.js";
8
+ import { ActivityService } from "./activity.js";
12
9
  import { MailService } from "./mail/index.js";
10
+ import { RelationsService } from "./relations.js";
11
+ import { FlowsService } from "./flows.js";
12
+ import { RevisionsService } from "./revisions.js";
13
13
  import { ExtensionReadError, ExtensionsService } from "./extensions.js";
14
14
  import { SettingsService } from "./settings.js";
15
15
  import { UsersService } from "./users.js";
16
16
  import { NotificationsService } from "./notifications.js";
17
17
  import { ExportService, ImportService, createErrorTracker, getHeadingsForCsvExport } from "./import-export.js";
18
- import { RevisionsService } from "./revisions.js";
19
18
  import { TFAService } from "./tfa.js";
20
19
  import { AuthenticationService } from "./authentication.js";
21
20
  import { CommentsService } from "./comments.js";
22
21
  import { DashboardsService } from "./dashboards.js";
22
+ import { FieldsService, systemFieldUpdateSchema } from "./fields.js";
23
23
  import { DeploymentProjectsService } from "./deployment-projects.js";
24
24
  import { DeploymentRunsService } from "./deployment-runs.js";
25
25
  import { DeploymentService } from "./deployment.js";
@@ -39,7 +39,7 @@ import { SharesService } from "./shares.js";
39
39
  import { TranslationsService } from "./translations.js";
40
40
  import { VersionsService } from "./versions.js";
41
41
  import { WebSocketService } from "./websocket.js";
42
- import { FlowsService } from "./flows.js";
42
+ import { CollectionsService } from "./collections.js";
43
43
 
44
44
  //#region src/services/index.ts
45
45
  var services_exports = /* @__PURE__ */ __export({
@@ -13,13 +13,13 @@ import { runAst } from "../database/run-ast/run-ast.js";
13
13
  import { processPayload } from "../permissions/modules/process-payload/process-payload.js";
14
14
  import { shouldClearCache } from "../utils/should-clear-cache.js";
15
15
  import { validateKeys } from "../utils/validate-keys.js";
16
- import { validateUserCountIntegrity } from "../utils/validate-user-count-integrity.js";
16
+ import { captureSeatCount, validateUserCountIntegrity } from "../utils/validate-user-count-integrity.js";
17
17
  import { handleVersion } from "../utils/versioning/handle-version.js";
18
18
  import { useEnv } from "@directus/env";
19
19
  import { ErrorCode, ForbiddenError, InvalidPayloadError, isDirectusError } from "@directus/errors";
20
20
  import { getRelationsForCollection } from "@directus/utils";
21
21
  import { assign, clone, cloneDeep, difference, omit, pick, without } from "lodash-es";
22
- import { Action } from "@directus/constants";
22
+ import { Action, isPublishedVersionKey } from "@directus/constants";
23
23
  import { isSystemCollection } from "@directus/system-data";
24
24
 
25
25
  //#region src/services/items.ts
@@ -86,6 +86,7 @@ var ItemsService = class ItemsService {
86
86
  async createOne(data, opts = {}) {
87
87
  if (!opts.mutationTracker) opts.mutationTracker = this.createMutationTracker();
88
88
  if (!opts.bypassLimits) opts.mutationTracker.trackMutations(1);
89
+ if (this.collection === "directus_users") opts.userIntegrityCheckFlags = (opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None) | UserIntegrityCheckFlag.UserLimits;
89
90
  const primaryKeyField = this.schema.collections[this.collection].primary;
90
91
  const fields = Object.keys(this.schema.collections[this.collection].fields);
91
92
  const aliases = Object.values(this.schema.collections[this.collection].fields).filter((field) => field.alias === true).map((field) => field.field);
@@ -99,6 +100,7 @@ var ItemsService = class ItemsService {
99
100
  * update tree
100
101
  */
101
102
  const primaryKey = await transaction(this.knex, async (trx) => {
103
+ const previousSeatCount = await captureSeatCount(trx, opts.userIntegrityCheckFlags);
102
104
  const payloadAfterHooks = opts.emitEvents !== false ? await emitter_default.emitFilter(this.eventScope === "items" ? ["items.create", `${this.collection}.items.create`] : `${this.eventScope}.create`, payload, { collection: this.collection }, {
103
105
  database: trx,
104
106
  schema: this.schema,
@@ -159,7 +161,8 @@ var ItemsService = class ItemsService {
159
161
  if (userIntegrityCheckFlags) if (opts.onRequireUserIntegrityCheck) opts.onRequireUserIntegrityCheck(userIntegrityCheckFlags);
160
162
  else await validateUserCountIntegrity({
161
163
  flags: userIntegrityCheckFlags,
162
- knex: trx
164
+ knex: trx,
165
+ previousSeatCount
163
166
  });
164
167
  if (opts.skipTracking !== true && this.accountability && this.schema.collections[this.collection].accountability !== null) {
165
168
  const { ActivityService } = await import("./activity.js");
@@ -232,7 +235,9 @@ var ItemsService = class ItemsService {
232
235
  */
233
236
  async createMany(data, opts = {}) {
234
237
  if (!opts.mutationTracker) opts.mutationTracker = this.createMutationTracker();
238
+ if (this.collection === "directus_users") opts.userIntegrityCheckFlags = (opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None) | UserIntegrityCheckFlag.UserLimits;
235
239
  const { primaryKeys, nestedActionEvents } = await transaction(this.knex, async (knex) => {
240
+ const previousSeatCount = await captureSeatCount(knex, opts.userIntegrityCheckFlags);
236
241
  const service = this.fork({ knex });
237
242
  let userIntegrityCheckFlags = opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None;
238
243
  const primaryKeys$1 = [];
@@ -255,7 +260,8 @@ var ItemsService = class ItemsService {
255
260
  if (userIntegrityCheckFlags) if (opts.onRequireUserIntegrityCheck) opts.onRequireUserIntegrityCheck(userIntegrityCheckFlags);
256
261
  else await validateUserCountIntegrity({
257
262
  flags: userIntegrityCheckFlags,
258
- knex
263
+ knex,
264
+ previousSeatCount
259
265
  });
260
266
  return {
261
267
  primaryKeys: primaryKeys$1,
@@ -271,6 +277,7 @@ var ItemsService = class ItemsService {
271
277
  * Get items by query.
272
278
  */
273
279
  async readByQuery(query, opts) {
280
+ if (query.version && !isPublishedVersionKey(query.version)) return await handleVersion(this, opts?.key ?? null, query, opts);
274
281
  const updatedQuery = opts?.emitEvents !== false ? await emitter_default.emitFilter(this.eventScope === "items" ? ["items.query", `${this.collection}.items.query`] : `${this.eventScope}.query`, query, { collection: this.collection }, {
275
282
  database: this.knex,
276
283
  schema: this.schema,
@@ -325,9 +332,10 @@ var ItemsService = class ItemsService {
325
332
  const primaryKeyField = this.schema.collections[this.collection].primary;
326
333
  validateKeys(this.schema, this.collection, primaryKeyField, key);
327
334
  const queryWithKey = assign({}, query, { filter: assign({}, query.filter, { [primaryKeyField]: { _eq: key } }) });
328
- let results = [];
329
- if (query.version && query.version !== "main") results = [await handleVersion(this, key, queryWithKey, opts)];
330
- else results = await this.readByQuery(queryWithKey, opts);
335
+ const results = await this.readByQuery(queryWithKey, {
336
+ ...opts,
337
+ key
338
+ });
331
339
  if (results.length === 0) throw new ForbiddenError();
332
340
  return results[0];
333
341
  }
@@ -373,6 +381,7 @@ var ItemsService = class ItemsService {
373
381
  const keys = [];
374
382
  try {
375
383
  await transaction(this.knex, async (knex) => {
384
+ const previousSeatCount = await captureSeatCount(knex, opts.userIntegrityCheckFlags);
376
385
  const service = this.fork({ knex });
377
386
  let userIntegrityCheckFlags = opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None;
378
387
  for (const index in data) {
@@ -390,7 +399,8 @@ var ItemsService = class ItemsService {
390
399
  if (userIntegrityCheckFlags) if (opts.onRequireUserIntegrityCheck) opts.onRequireUserIntegrityCheck(userIntegrityCheckFlags);
391
400
  else await validateUserCountIntegrity({
392
401
  flags: userIntegrityCheckFlags,
393
- knex
402
+ knex,
403
+ previousSeatCount
394
404
  });
395
405
  });
396
406
  } finally {
@@ -404,6 +414,7 @@ var ItemsService = class ItemsService {
404
414
  async updateMany(keys, data, opts = {}) {
405
415
  if (!opts.mutationTracker) opts.mutationTracker = this.createMutationTracker();
406
416
  if (!opts.bypassLimits) opts.mutationTracker.trackMutations(keys.length);
417
+ if (this.collection === "directus_users" && data["status"] === "active") opts.userIntegrityCheckFlags = (opts.userIntegrityCheckFlags ?? UserIntegrityCheckFlag.None) | UserIntegrityCheckFlag.UserLimits;
407
418
  const primaryKeyField = this.schema.collections[this.collection].primary;
408
419
  validateKeys(this.schema, this.collection, primaryKeyField, keys);
409
420
  const fields = Object.keys(this.schema.collections[this.collection].fields);
@@ -441,6 +452,7 @@ var ItemsService = class ItemsService {
441
452
  }) : payloadAfterHooks;
442
453
  if (opts.preMutationError) throw opts.preMutationError;
443
454
  await transaction(this.knex, async (trx) => {
455
+ const previousSeatCount = await captureSeatCount(trx, opts.userIntegrityCheckFlags);
444
456
  const payloadService = new PayloadService(this.collection, {
445
457
  accountability: this.accountability,
446
458
  knex: trx,
@@ -470,7 +482,8 @@ var ItemsService = class ItemsService {
470
482
  if (userIntegrityCheckFlags) if (opts?.onRequireUserIntegrityCheck) opts.onRequireUserIntegrityCheck(userIntegrityCheckFlags);
471
483
  else await validateUserCountIntegrity({
472
484
  flags: userIntegrityCheckFlags,
473
- knex: trx
485
+ knex: trx,
486
+ previousSeatCount
474
487
  });
475
488
  if (opts.skipTracking !== true && this.accountability && this.schema.collections[this.collection].accountability !== null) {
476
489
  const { ActivityService } = await import("./activity.js");
@@ -622,11 +635,13 @@ var ItemsService = class ItemsService {
622
635
  });
623
636
  if (opts.preMutationError) throw opts.preMutationError;
624
637
  await transaction(this.knex, async (trx) => {
638
+ const previousSeatCount = await captureSeatCount(trx, opts.userIntegrityCheckFlags);
625
639
  await trx(this.collection).whereIn(primaryKeyField, keysAfterHooks).delete();
626
640
  if (opts.userIntegrityCheckFlags) if (opts.onRequireUserIntegrityCheck) opts.onRequireUserIntegrityCheck(opts.userIntegrityCheckFlags);
627
641
  else await validateUserCountIntegrity({
628
642
  flags: opts.userIntegrityCheckFlags,
629
- knex: trx
643
+ knex: trx,
644
+ previousSeatCount
630
645
  });
631
646
  if (opts.skipTracking !== true && this.accountability && this.schema.collections[this.collection].accountability !== null) {
632
647
  const { ActivityService } = await import("./activity.js");
@@ -670,12 +685,15 @@ var ItemsService = class ItemsService {
670
685
  async readSingleton(query, opts) {
671
686
  query = clone(query);
672
687
  query.limit = 1;
673
- let record;
674
- if (query.version && query.version !== "main") {
688
+ if (query.version && !isPublishedVersionKey(query.version)) {
675
689
  const primaryKeyField = this.schema.collections[this.collection].primary;
676
690
  const key = (await this.knex.select(primaryKeyField).from(this.collection).first())?.[primaryKeyField];
677
- if (key) record = await handleVersion(this, key, query, opts);
678
- } else record = (await this.readByQuery(query, opts))[0];
691
+ opts = {
692
+ ...opts,
693
+ key
694
+ };
695
+ }
696
+ const record = (await this.readByQuery(query, opts))[0];
679
697
  if (!record) {
680
698
  let fields = Object.entries(this.schema.collections[this.collection].fields);
681
699
  const defaults = {};
@@ -0,0 +1,307 @@
1
+ import { useLogger } from "../../logger/index.js";
2
+ import { getAxios } from "../../request/index.js";
3
+ import { OAuthError } from "./types/error.js";
4
+ import { CimdEgressError, createCimdLookup } from "./utils/cimd-egress.js";
5
+ import { validateRedirectUri } from "./utils/redirect.js";
6
+ import { useEnv } from "@directus/env";
7
+ import { isIP } from "node:net";
8
+ import { performance } from "node:perf_hooks";
9
+
10
+ //#region src/services/mcp-oauth/cimd.ts
11
+ const MIN_TTL_MS = 3e5;
12
+ const MAX_TTL_MS = 864e5;
13
+ const DEFAULT_TTL_MS = 36e5;
14
+ const MAX_CLIENT_NAME_LENGTH = 200;
15
+ const MAX_URL_LENGTH = 255;
16
+ const DEFAULT_BLOCKED_TLDS = [
17
+ "test",
18
+ "localhost",
19
+ "invalid",
20
+ "example",
21
+ "local",
22
+ "onion"
23
+ ];
24
+ /**
25
+ * Forbidden shared-secret auth methods per draft-ietf-oauth-client-id-metadata-document-01 Section 4.1:
26
+ * "the token_endpoint_auth_method property MUST NOT include client_secret_post, client_secret_basic,
27
+ * client_secret_jwt, or any other method based around a shared symmetric secret."
28
+ */
29
+ const FORBIDDEN_AUTH_METHODS = new Set([
30
+ "client_secret_basic",
31
+ "client_secret_post",
32
+ "client_secret_jwt"
33
+ ]);
34
+ /**
35
+ * Detect whether a client_id is a CIMD URL or a DCR-registered ID.
36
+ * Returns 'cimd' for valid CIMD URLs, 'dcr' for anything else that should go to DB lookup,
37
+ * or null if it looks like a CIMD URL but fails validation.
38
+ */
39
+ function detectClientIdType(clientId) {
40
+ if (clientId.startsWith("https://") || useEnv()["MCP_OAUTH_CIMD_ALLOW_HTTP"] && clientId.startsWith("http://")) return isValidCimdClientId(clientId) ? "cimd" : null;
41
+ return "dcr";
42
+ }
43
+ /**
44
+ * Strict CIMD client_id URL validation with debug logging on each rejection.
45
+ */
46
+ function isValidCimdClientId(input) {
47
+ const logger = useLogger();
48
+ const env = useEnv();
49
+ let url;
50
+ try {
51
+ url = new URL(input);
52
+ } catch {
53
+ logger.debug({
54
+ client_id: input,
55
+ reason: "unparseable URL"
56
+ }, "CIMD client_id rejected");
57
+ return false;
58
+ }
59
+ const allowHttp = env["MCP_OAUTH_CIMD_ALLOW_HTTP"];
60
+ if (url.protocol !== "https:" && !(allowHttp && url.protocol === "http:")) {
61
+ logger.debug({
62
+ client_id: input,
63
+ reason: "not HTTPS"
64
+ }, "CIMD client_id rejected");
65
+ return false;
66
+ }
67
+ if (url.pathname === "/") {
68
+ logger.debug({
69
+ client_id: input,
70
+ reason: "root path"
71
+ }, "CIMD client_id rejected");
72
+ return false;
73
+ }
74
+ if (url.search) {
75
+ logger.debug({
76
+ client_id: input,
77
+ reason: "query string present"
78
+ }, "CIMD client_id rejected");
79
+ return false;
80
+ }
81
+ if (url.hash) {
82
+ logger.debug({
83
+ client_id: input,
84
+ reason: "fragment present"
85
+ }, "CIMD client_id rejected");
86
+ return false;
87
+ }
88
+ if (url.username || url.password) {
89
+ logger.debug({
90
+ client_id: input,
91
+ reason: "credentials in URL"
92
+ }, "CIMD client_id rejected");
93
+ return false;
94
+ }
95
+ if (/(?:^|\/)\.\.?(?:\/|$)/.test(url.pathname)) {
96
+ logger.debug({
97
+ client_id: input,
98
+ reason: "dot segments in path"
99
+ }, "CIMD client_id rejected");
100
+ return false;
101
+ }
102
+ const hostname = url.hostname.replace(/\.$/, "").toLowerCase();
103
+ if (isIP(hostname.replace(/^\[|\]$/g, ""))) {
104
+ logger.debug({
105
+ client_id: input,
106
+ reason: "IP address hostname"
107
+ }, "CIMD client_id rejected");
108
+ return false;
109
+ }
110
+ const envTlds = env["MCP_OAUTH_CIMD_BLOCKED_TLDS"];
111
+ const suffixes = (envTlds.length > 0 ? envTlds : DEFAULT_BLOCKED_TLDS).map((t) => `.${t.toLowerCase()}`);
112
+ for (const suffix of suffixes) if (hostname === suffix.slice(1) || hostname.endsWith(suffix)) {
113
+ logger.debug({
114
+ client_id: input,
115
+ reason: `blocked TLD: ${suffix}`
116
+ }, "CIMD client_id rejected");
117
+ return false;
118
+ }
119
+ if (input.length > MAX_URL_LENGTH) {
120
+ logger.debug({
121
+ client_id: input,
122
+ reason: "URL too long"
123
+ }, "CIMD client_id rejected");
124
+ return false;
125
+ }
126
+ if (url.href !== input) {
127
+ logger.debug({
128
+ client_id: input,
129
+ reason: `non-canonical (${url.href})`
130
+ }, "CIMD client_id rejected");
131
+ return false;
132
+ }
133
+ return true;
134
+ }
135
+ /** Read allowed domains from env, filtering empty strings. */
136
+ function getAllowedDomains() {
137
+ return useEnv()["MCP_OAUTH_CIMD_ALLOWED_DOMAINS"].filter((s) => s !== "");
138
+ }
139
+ /**
140
+ * Parse Cache-Control and Expires headers per RFC 9111.
141
+ * Returns TTL in milliseconds, clamped to [5min, 24h]. Default: 1h.
142
+ */
143
+ function resolveCacheTtl(headers) {
144
+ const cc = headers["cache-control"];
145
+ if (cc) {
146
+ const directives = cc.split(",").map((d) => d.trim());
147
+ for (const directive of directives) {
148
+ const eqIdx = directive.indexOf("=");
149
+ const name = (eqIdx === -1 ? directive : directive.slice(0, eqIdx)).toLowerCase();
150
+ if (name === "no-store" && eqIdx === -1) return 0;
151
+ if (name === "no-cache" && eqIdx === -1) return 0;
152
+ }
153
+ for (const directive of directives) {
154
+ const eqIdx = directive.indexOf("=");
155
+ if (eqIdx === -1) continue;
156
+ if (directive.slice(0, eqIdx).toLowerCase() === "max-age") {
157
+ const seconds = parseInt(directive.slice(eqIdx + 1), 10);
158
+ if (!isNaN(seconds)) {
159
+ const ms = seconds * 1e3;
160
+ return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, ms));
161
+ }
162
+ }
163
+ }
164
+ }
165
+ const expires = headers["expires"];
166
+ if (expires) {
167
+ const expiresMs = new Date(expires).getTime();
168
+ if (!isNaN(expiresMs)) {
169
+ const delta = expiresMs - Date.now();
170
+ if (delta <= 0) return 0;
171
+ return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, delta));
172
+ }
173
+ }
174
+ return DEFAULT_TTL_MS;
175
+ }
176
+ /** Validate a URI is HTTPS. Used for optional metadata fields (client_uri, logo_uri, etc.). */
177
+ function validateOptionalHttpsUri(value, field) {
178
+ if (typeof value !== "string") throw new OAuthError(400, "invalid_client_metadata", `${field} must be a string`);
179
+ try {
180
+ if (new URL(value).protocol !== "https:") throw new OAuthError(400, "invalid_client_metadata", `${field} must use HTTPS`);
181
+ } catch (err) {
182
+ if (err instanceof OAuthError) throw err;
183
+ throw new OAuthError(400, "invalid_client_metadata", `${field} is not a valid URL`);
184
+ }
185
+ }
186
+ const CONTENT_TYPE_JSON_RE = /^application\/(?:json|[\w.-]+\+json)(?:\s*;|$)/i;
187
+ /**
188
+ * Fetch and validate a CIMD metadata document.
189
+ * When `etag` is provided, sends If-None-Match and accepts 304.
190
+ */
191
+ async function fetchCimdMetadata(clientId, etag) {
192
+ const logger = useLogger();
193
+ const axios = await getAxios();
194
+ let url;
195
+ try {
196
+ url = new URL(clientId);
197
+ } catch {
198
+ logger.debug({
199
+ client_id: clientId,
200
+ reason: "unparseable URL"
201
+ }, "CIMD metadata fetch rejected");
202
+ throw new OAuthError(400, "invalid_client_metadata", "Failed to fetch client metadata document");
203
+ }
204
+ if (isIP(url.hostname.replace(/^\[|\]$/g, ""))) {
205
+ logger.debug({
206
+ client_id: clientId,
207
+ reason: "IP address hostname"
208
+ }, "CIMD metadata fetch rejected");
209
+ throw new OAuthError(400, "invalid_client_metadata", "Failed to fetch client metadata document");
210
+ }
211
+ const headers = {};
212
+ if (etag) headers["If-None-Match"] = etag;
213
+ let response;
214
+ try {
215
+ const requestConfig = {
216
+ headers,
217
+ lookup: createCimdLookup({ deadlineAt: performance.now() + 3e3 }),
218
+ maxRedirects: 0,
219
+ maxContentLength: 5120,
220
+ proxy: false,
221
+ timeout: 3e3,
222
+ responseType: "json",
223
+ validateStatus: (s) => s === 200 || (etag ? s === 304 : false)
224
+ };
225
+ response = await axios.get(clientId, requestConfig);
226
+ } catch (err) {
227
+ if (err instanceof CimdEgressError) {
228
+ const reason = err.reason;
229
+ logger.debug({
230
+ client_id: clientId,
231
+ ...typeof reason === "string" ? { reason } : {}
232
+ }, "CIMD metadata fetch rejected");
233
+ throw new OAuthError(400, "invalid_client_metadata", "Failed to fetch client metadata document");
234
+ }
235
+ logger.debug({
236
+ client_id: clientId,
237
+ error: err?.message
238
+ }, "CIMD metadata fetch failed");
239
+ throw new OAuthError(400, "invalid_client_metadata", "Failed to fetch client metadata document");
240
+ }
241
+ if (response.status === 304) {
242
+ const respHeaders = response.headers;
243
+ return {
244
+ notModified: true,
245
+ ttlMs: !!respHeaders["cache-control"] ? resolveCacheTtl(respHeaders) : null
246
+ };
247
+ }
248
+ const contentType = response.headers["content-type"];
249
+ if (!contentType || !CONTENT_TYPE_JSON_RE.test(contentType)) throw new OAuthError(400, "invalid_client_metadata", "Client metadata document must be JSON");
250
+ const doc = response.data;
251
+ if (!doc || typeof doc !== "object") throw new OAuthError(400, "invalid_client_metadata", "Client metadata document must be a JSON object");
252
+ if (doc.client_id !== clientId) {
253
+ logger.debug({
254
+ client_id: clientId,
255
+ doc_client_id: doc.client_id
256
+ }, "CIMD client_id mismatch");
257
+ throw new OAuthError(400, "invalid_client_metadata", "client_id in document does not match fetch URL");
258
+ }
259
+ if (!doc.client_name || typeof doc.client_name !== "string" || doc.client_name.trim().length === 0) throw new OAuthError(400, "invalid_client_metadata", "client_name is required");
260
+ if (doc.client_name.length > MAX_CLIENT_NAME_LENGTH) throw new OAuthError(400, "invalid_client_metadata", `client_name must not exceed ${MAX_CLIENT_NAME_LENGTH} characters`);
261
+ if (!Array.isArray(doc.redirect_uris) || doc.redirect_uris.length === 0) throw new OAuthError(400, "invalid_client_metadata", "redirect_uris is required and must be a non-empty array");
262
+ for (const uri of doc.redirect_uris) try {
263
+ validateRedirectUri(uri);
264
+ } catch (err) {
265
+ if (err instanceof OAuthError) throw err;
266
+ throw new OAuthError(400, "invalid_redirect_uri", "Invalid redirect URI in metadata document");
267
+ }
268
+ if ("client_secret" in doc) throw new OAuthError(400, "invalid_client_metadata", "CIMD documents must not contain client_secret");
269
+ if ("client_secret_expires_at" in doc) throw new OAuthError(400, "invalid_client_metadata", "CIMD documents must not contain client_secret_expires_at");
270
+ if (doc.grant_types !== void 0 && !Array.isArray(doc.grant_types)) throw new OAuthError(400, "invalid_client_metadata", "grant_types must be an array");
271
+ const grantTypes = doc.grant_types ?? ["authorization_code"];
272
+ if (!grantTypes.includes("authorization_code")) throw new OAuthError(400, "invalid_client_metadata", "grant_types must include authorization_code");
273
+ if (doc.response_types !== void 0) {
274
+ if (!Array.isArray(doc.response_types) || doc.response_types.length !== 1 || doc.response_types[0] !== "code") throw new OAuthError(400, "invalid_client_metadata", "response_types must be [\"code\"] if present");
275
+ }
276
+ if (doc.token_endpoint_auth_method !== void 0 && typeof doc.token_endpoint_auth_method !== "string") throw new OAuthError(400, "invalid_client_metadata", "token_endpoint_auth_method must be a string");
277
+ const authMethod = doc.token_endpoint_auth_method ?? "none";
278
+ if (FORBIDDEN_AUTH_METHODS.has(authMethod)) throw new OAuthError(400, "invalid_client_metadata", "Shared-secret authentication methods are forbidden for CIMD");
279
+ if (authMethod !== "none") throw new OAuthError(400, "invalid_client_metadata", `Unsupported token_endpoint_auth_method: ${authMethod}`);
280
+ for (const field of [
281
+ "client_uri",
282
+ "logo_uri",
283
+ "tos_uri",
284
+ "policy_uri"
285
+ ]) if (doc[field] !== void 0) validateOptionalHttpsUri(doc[field], field);
286
+ const responseHeaders = response.headers;
287
+ return {
288
+ notModified: false,
289
+ metadata: {
290
+ client_id: doc.client_id,
291
+ client_name: doc.client_name,
292
+ redirect_uris: doc.redirect_uris,
293
+ grant_types: grantTypes,
294
+ response_types: doc.response_types,
295
+ token_endpoint_auth_method: authMethod,
296
+ ...doc.client_uri && { client_uri: doc.client_uri },
297
+ ...doc.logo_uri && { logo_uri: doc.logo_uri },
298
+ ...doc.tos_uri && { tos_uri: doc.tos_uri },
299
+ ...doc.policy_uri && { policy_uri: doc.policy_uri }
300
+ },
301
+ etag: responseHeaders["etag"] ?? null,
302
+ ttlMs: resolveCacheTtl(responseHeaders)
303
+ };
304
+ }
305
+
306
+ //#endregion
307
+ export { detectClientIdType, fetchCimdMetadata, getAllowedDomains, isValidCimdClientId, resolveCacheTtl };