@directus/api 35.2.0 → 36.0.0-rc.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.
- package/dist/ai/chat/models/chat-request.js +48 -48
- package/dist/ai/chat/models/object-request.js +6 -6
- package/dist/ai/chat/models/providers.js +14 -14
- package/dist/ai/chat/utils/parse-json-schema-7.js +22 -22
- package/dist/ai/mcp/server.js +44 -6
- package/dist/ai/mcp/utils.js +31 -0
- package/dist/ai/tools/assets/index.js +3 -3
- package/dist/ai/tools/collections/index.js +18 -18
- package/dist/ai/tools/fields/index.js +18 -18
- package/dist/ai/tools/files/index.js +18 -18
- package/dist/ai/tools/flows/index.js +16 -16
- package/dist/ai/tools/folders/index.js +18 -18
- package/dist/ai/tools/items/index.js +17 -17
- package/dist/ai/tools/operations/index.js +16 -16
- package/dist/ai/tools/relations/index.js +22 -22
- package/dist/ai/tools/schema/index.js +3 -3
- package/dist/ai/tools/schema.js +159 -159
- package/dist/ai/tools/system/index.js +3 -3
- package/dist/ai/tools/trigger-flow/index.js +3 -3
- package/dist/app.js +35 -11
- package/dist/auth/drivers/ldap.js +3 -1
- package/dist/auth/drivers/local.js +2 -0
- package/dist/auth/drivers/oauth2.js +3 -1
- package/dist/auth/drivers/openid.js +3 -1
- package/dist/auth/drivers/saml.js +2 -0
- package/dist/auth/utils/check-local-disabled.js +16 -0
- package/dist/auth/utils/check-sso-enabled.js +14 -0
- package/dist/auth.js +8 -5
- package/dist/cli/commands/bootstrap/index.js +3 -0
- package/dist/cli/commands/cache/clear.js +6 -1
- package/dist/cli/commands/roles/create.js +4 -1
- package/dist/cli/commands/users/create.js +3 -0
- package/dist/constants.js +8 -1
- package/dist/controllers/access.js +1 -1
- package/dist/controllers/activity.js +2 -1
- package/dist/controllers/assets.js +2 -0
- package/dist/controllers/auth.js +13 -5
- package/dist/controllers/collections.js +1 -1
- package/dist/controllers/comments.js +1 -1
- package/dist/controllers/dashboards.js +1 -1
- package/dist/controllers/fields.js +1 -1
- package/dist/controllers/files.js +3 -1
- package/dist/controllers/flows.js +6 -5
- package/dist/controllers/folders.js +1 -1
- package/dist/controllers/graphql.js +2 -0
- package/dist/controllers/items.js +3 -1
- package/dist/controllers/license.js +119 -0
- package/dist/controllers/mcp/index.js +38 -0
- package/dist/controllers/mcp/oauth-clients.js +68 -0
- package/dist/controllers/mcp/oauth-consent-page.js +316 -0
- package/dist/controllers/mcp/oauth.js +381 -0
- package/dist/controllers/mcp/templates/oauth-consent.liquid +62 -0
- package/dist/controllers/mcp/templates/oauth-error.liquid +28 -0
- package/dist/controllers/notifications.js +1 -1
- package/dist/controllers/operations.js +1 -1
- package/dist/controllers/panels.js +1 -1
- package/dist/controllers/permissions.js +1 -1
- package/dist/controllers/policies.js +1 -1
- package/dist/controllers/presets.js +1 -1
- package/dist/controllers/revisions.js +3 -2
- package/dist/controllers/roles.js +1 -1
- package/dist/controllers/server.js +38 -10
- package/dist/controllers/shares.js +1 -1
- package/dist/controllers/translations.js +1 -1
- package/dist/controllers/users.js +1 -1
- package/dist/controllers/utils.js +2 -2
- package/dist/controllers/versions.js +12 -5
- package/dist/database/get-ast-from-query/lib/convert-wildcards.js +10 -1
- package/dist/database/get-ast-from-query/lib/parse-fields.js +2 -1
- package/dist/database/helpers/fn/dialects/mysql.js +7 -12
- package/dist/database/helpers/fn/dialects/oracle.js +3 -4
- package/dist/database/helpers/fn/dialects/postgres.js +4 -26
- package/dist/database/helpers/fn/json/mysql-json-path.js +22 -0
- package/dist/database/helpers/fn/json/parse-function.js +14 -6
- package/dist/database/helpers/fn/json/postgres-json-path.js +54 -0
- package/dist/database/migrations/20260110A-add-ai-provider-settings.js +4 -4
- package/dist/database/migrations/20260217A-null-item-versions.js +14 -0
- package/dist/database/migrations/20260312A-add-ai-translation-settings.js +18 -0
- package/dist/database/migrations/20260507A-add-licensing.js +22 -0
- package/dist/database/migrations/20260512A-add-autosave-revision-interval.js +14 -0
- package/dist/database/migrations/20260512B-add-mcp-oauth.js +87 -0
- package/dist/database/run-ast/lib/apply-query/filter/operator.js +116 -33
- package/dist/database/run-ast/lib/apply-query/index.js +4 -1
- package/dist/database/run-ast/lib/apply-query/sort.js +17 -7
- package/dist/database/run-ast/lib/get-db-query.js +21 -9
- package/dist/database/run-ast/lib/parse-current-level.js +2 -1
- package/dist/database/run-ast/run-ast.js +2 -1
- package/dist/database/run-ast/utils/get-column.js +2 -1
- package/dist/database/run-ast/utils/merge-with-parent-items.js +5 -3
- package/dist/extensions/lib/installation/manager.js +1 -1
- package/dist/extensions/lib/sandbox/register/operation.js +1 -1
- package/dist/extensions/lib/sync/sync.js +1 -1
- package/dist/extensions/manager.js +3 -3
- package/dist/flows.js +5 -5
- package/dist/license/entitlements/lib/collections.js +37 -0
- package/dist/license/entitlements/lib/custom-llms-enabled.js +18 -0
- package/dist/license/entitlements/lib/custom-permission-rules-enabled.js +41 -0
- package/dist/license/entitlements/lib/flows.js +29 -0
- package/dist/license/entitlements/lib/seats.js +103 -0
- package/dist/license/entitlements/lib/sso-enabled.js +45 -0
- package/dist/license/entitlements/manager.js +256 -0
- package/dist/license/index.js +4 -0
- package/dist/license/manager.js +505 -0
- package/dist/license/utils/compute-license-status.js +27 -0
- package/dist/license/utils/get-core-grace-expires-at.js +38 -0
- package/dist/license/utils/get-license-key.js +23 -0
- package/dist/license/utils/get-license-token.js +23 -0
- package/dist/license/utils/handle-license-error.js +41 -0
- package/dist/license/utils/is-in-core-grace-period.js +11 -0
- package/dist/license/utils/is-sso-bypass-allowed.js +21 -0
- package/dist/license/utils/use-rpc.js +33 -0
- package/dist/middleware/cache.js +4 -1
- package/dist/middleware/error-handler.js +11 -0
- package/dist/middleware/extract-token.js +11 -2
- package/dist/middleware/is-admin.js +16 -0
- package/dist/middleware/is-locked.js +16 -0
- package/dist/middleware/mcp-oauth-guard.js +23 -0
- package/dist/middleware/request-counter.js +5 -2
- package/dist/packages/types/dist/index.js +117 -122
- package/dist/permissions/modules/process-ast/utils/extract-paths-from-query.js +10 -1
- package/dist/permissions/utils/get-unaliased-field-key.js +2 -1
- package/dist/request/is-denied-ip.js +2 -0
- package/dist/schedules/license.js +31 -0
- package/dist/schedules/oauth-cleanup.js +26 -0
- package/dist/schedules/retention.js +1 -1
- package/dist/schedules/telemetry.js +4 -1
- package/dist/schedules/tus.js +1 -1
- package/dist/schedules/utils/duration-to-cron.js +36 -0
- package/dist/services/activity.js +15 -0
- package/dist/services/authentication.js +12 -5
- package/dist/services/collections.js +40 -10
- package/dist/services/fields.js +6 -6
- package/dist/services/flows.js +12 -0
- package/dist/services/graphql/resolvers/system-admin.js +2 -2
- package/dist/services/graphql/resolvers/system-global.js +1 -1
- package/dist/services/graphql/resolvers/system.js +43 -27
- package/dist/services/graphql/schema/get-types.js +28 -7
- package/dist/services/graphql/schema/parse-query.js +8 -0
- package/dist/services/graphql/schema/read.js +12 -0
- package/dist/services/graphql/types/json-filter.js +30 -0
- package/dist/services/index.js +6 -6
- package/dist/services/items.js +32 -14
- package/dist/services/mcp-oauth/cimd.js +307 -0
- package/dist/services/mcp-oauth/index.js +1185 -0
- package/dist/services/mcp-oauth/types/error.js +22 -0
- package/dist/services/mcp-oauth/utils/cimd-egress.js +182 -0
- package/dist/services/mcp-oauth/utils/domain.js +21 -0
- package/dist/services/mcp-oauth/utils/loopback.js +11 -0
- package/dist/services/mcp-oauth/utils/redirect.js +84 -0
- package/dist/services/mcp-oauth/utils/registration-debug.js +131 -0
- package/dist/services/payload.js +2 -1
- package/dist/services/permissions.js +31 -9
- package/dist/services/revisions.js +15 -0
- package/dist/services/server.js +66 -68
- package/dist/services/settings.js +37 -3
- package/dist/services/users.js +23 -6
- package/dist/services/utils.js +6 -1
- package/dist/services/versions.js +160 -70
- package/dist/utils/calculate-field-depth.js +1 -0
- package/dist/utils/create-admin.js +3 -3
- package/dist/utils/deep-freeze.js +24 -0
- package/dist/utils/extract-function-name.js +13 -0
- package/dist/utils/generate-translations.js +5 -5
- package/dist/utils/get-accountability-for-token.js +13 -1
- package/dist/utils/get-cache-key.js +1 -1
- package/dist/utils/get-history-filter-query.js +22 -0
- package/dist/utils/get-schema.js +2 -2
- package/dist/utils/get-service.js +3 -3
- package/dist/utils/is-admin.js +9 -0
- package/dist/utils/is-unauthenticated.js +15 -0
- package/dist/utils/parse-oauth-scope.js +12 -0
- package/dist/utils/sanitize-query.js +2 -2
- package/dist/utils/split-field-path.js +29 -0
- package/dist/utils/store.js +1 -1
- package/dist/utils/transaction.js +2 -2
- package/dist/utils/translations-validation.js +2 -2
- package/dist/utils/validate-query.js +35 -4
- package/dist/utils/validate-user-count-integrity.js +28 -5
- package/dist/utils/verify-session-jwt.js +5 -2
- package/dist/utils/versioning/handle-version.js +131 -48
- package/dist/utils/versioning/remove-circular.js +17 -0
- package/dist/websocket/authenticate.js +2 -1
- package/dist/websocket/collab/collab.js +1 -1
- package/dist/websocket/collab/room.js +1 -1
- package/dist/websocket/controllers/base.js +12 -0
- package/dist/websocket/controllers/graphql.js +1 -1
- package/dist/websocket/handlers/subscribe.js +1 -1
- package/dist/websocket/messages.js +64 -64
- package/dist/websocket/utils/items.js +2 -2
- package/license +90 -80
- package/package.json +33 -32
- package/dist/controllers/mcp.js +0 -31
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/services/mcp-oauth/types/error.ts
|
|
2
|
+
/**
|
|
3
|
+
* RFC 6749/7591 error with structured code for JSON serialization.
|
|
4
|
+
*
|
|
5
|
+
* `code` maps to the OAuth `error` field. `redirectable` controls whether
|
|
6
|
+
* the controller can redirect the error back to the client's redirect_uri
|
|
7
|
+
* (only safe after redirect_uri is validated against registered URIs).
|
|
8
|
+
*/
|
|
9
|
+
var OAuthError = class extends Error {
|
|
10
|
+
constructor(status, code, description, redirectable = false, headers = {}) {
|
|
11
|
+
super(description);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.description = description;
|
|
15
|
+
this.redirectable = redirectable;
|
|
16
|
+
this.headers = headers;
|
|
17
|
+
this.name = "OAuthError";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
//#endregion
|
|
22
|
+
export { OAuthError };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { IpBlocklist } from "@directus/utils/node";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { resolve4, resolve6 } from "node:dns/promises";
|
|
4
|
+
|
|
5
|
+
//#region src/services/mcp-oauth/utils/cimd-egress.ts
|
|
6
|
+
const DEFAULT_DNS_DEADLINE_MS = 1e3;
|
|
7
|
+
const IPV4_SPECIAL_USE_CIDRS = [
|
|
8
|
+
"0.0.0.0/8",
|
|
9
|
+
"0.0.0.0/32",
|
|
10
|
+
"10.0.0.0/8",
|
|
11
|
+
"100.64.0.0/10",
|
|
12
|
+
"127.0.0.0/8",
|
|
13
|
+
"169.254.0.0/16",
|
|
14
|
+
"172.16.0.0/12",
|
|
15
|
+
"192.0.0.0/24",
|
|
16
|
+
"192.0.0.0/29",
|
|
17
|
+
"192.0.0.8/32",
|
|
18
|
+
"192.0.0.9/32",
|
|
19
|
+
"192.0.0.10/32",
|
|
20
|
+
"192.0.0.170/32",
|
|
21
|
+
"192.0.0.171/32",
|
|
22
|
+
"192.0.2.0/24",
|
|
23
|
+
"192.31.196.0/24",
|
|
24
|
+
"192.52.193.0/24",
|
|
25
|
+
"192.88.99.2/32",
|
|
26
|
+
"192.168.0.0/16",
|
|
27
|
+
"192.175.48.0/24",
|
|
28
|
+
"198.18.0.0/15",
|
|
29
|
+
"198.51.100.0/24",
|
|
30
|
+
"203.0.113.0/24",
|
|
31
|
+
"240.0.0.0/4",
|
|
32
|
+
"255.255.255.255/32"
|
|
33
|
+
];
|
|
34
|
+
const IPV6_SPECIAL_USE_CIDRS = [
|
|
35
|
+
"::/128",
|
|
36
|
+
"::1/128",
|
|
37
|
+
"::ffff:0:0/96",
|
|
38
|
+
"64:ff9b::/96",
|
|
39
|
+
"64:ff9b:1::/48",
|
|
40
|
+
"100::/64",
|
|
41
|
+
"100:0:0:1::/64",
|
|
42
|
+
"2001::/23",
|
|
43
|
+
"2001::/32",
|
|
44
|
+
"2001:1::1/128",
|
|
45
|
+
"2001:1::2/128",
|
|
46
|
+
"2001:1::3/128",
|
|
47
|
+
"2001:2::/48",
|
|
48
|
+
"2001:3::/32",
|
|
49
|
+
"2001:4:112::/48",
|
|
50
|
+
"2001:20::/28",
|
|
51
|
+
"2001:30::/28",
|
|
52
|
+
"2001:db8::/32",
|
|
53
|
+
"2002::/16",
|
|
54
|
+
"2620:4f:8000::/48",
|
|
55
|
+
"3fff::/20",
|
|
56
|
+
"5f00::/16",
|
|
57
|
+
"fc00::/7",
|
|
58
|
+
"fe80::/10"
|
|
59
|
+
];
|
|
60
|
+
const specialUseIpv4Blocklist = new IpBlocklist();
|
|
61
|
+
const specialUseIpv6Blocklist = new IpBlocklist();
|
|
62
|
+
for (const cidr of IPV4_SPECIAL_USE_CIDRS) specialUseIpv4Blocklist.parseSubnet(cidr);
|
|
63
|
+
for (const cidr of IPV6_SPECIAL_USE_CIDRS) specialUseIpv6Blocklist.parseSubnet(cidr);
|
|
64
|
+
var CimdEgressError = class extends Error {
|
|
65
|
+
reason;
|
|
66
|
+
constructor(reason, options) {
|
|
67
|
+
super(reason, options);
|
|
68
|
+
this.name = "CimdEgressError";
|
|
69
|
+
this.reason = reason;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const defaultResolver = {
|
|
73
|
+
resolve4,
|
|
74
|
+
resolve6
|
|
75
|
+
};
|
|
76
|
+
/** Fail-closed IP classifier for the CIMD egress policy. */
|
|
77
|
+
function isSpecialUseIp(ip) {
|
|
78
|
+
const family = isIP(ip);
|
|
79
|
+
if (family === 0) return true;
|
|
80
|
+
try {
|
|
81
|
+
return family === 4 ? specialUseIpv4Blocklist.check(ip, "ipv4") : specialUseIpv6Blocklist.check(ip, "ipv6");
|
|
82
|
+
} catch {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function createCimdLookup(options) {
|
|
87
|
+
return (hostname, optionsOrCallback, callback) => {
|
|
88
|
+
const lookupOptions = normalizeLookupOptions(optionsOrCallback);
|
|
89
|
+
let settled = false;
|
|
90
|
+
const settleOnce = (settle) => {
|
|
91
|
+
if (settled) return;
|
|
92
|
+
settled = true;
|
|
93
|
+
settle();
|
|
94
|
+
};
|
|
95
|
+
const validationOptions = { deadlineAt: options.deadlineAt };
|
|
96
|
+
if (options.resolver) validationOptions.resolver = options.resolver;
|
|
97
|
+
validateCimdHostnameEgress(hostname, validationOptions).then((addresses) => {
|
|
98
|
+
const family = lookupOptions.family;
|
|
99
|
+
const selected = selectAddresses(addresses, family);
|
|
100
|
+
if (selected.length === 0) {
|
|
101
|
+
settleOnce(() => callback(new CimdEgressError("cimd_dns_empty_result"), ""));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (lookupOptions.all) {
|
|
105
|
+
settleOnce(() => callback(null, selected));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const first = selected[0];
|
|
109
|
+
settleOnce(() => callback(null, first.address, first.family));
|
|
110
|
+
}).catch((error) => {
|
|
111
|
+
settleOnce(() => callback(error, ""));
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function validateCimdHostnameEgress(hostname, options = {}) {
|
|
116
|
+
if (isIP(hostname.replace(/^\[|\]$/g, ""))) throw new CimdEgressError("cimd_dns_ip_literal");
|
|
117
|
+
const deadlineAt = options.deadlineAt ?? performance.now() + DEFAULT_DNS_DEADLINE_MS;
|
|
118
|
+
const resolver = options.resolver ?? defaultResolver;
|
|
119
|
+
if (deadlineAt - performance.now() <= 0) throw new CimdEgressError("cimd_dns_timeout");
|
|
120
|
+
return withDeadline(resolveAndValidate(hostname, resolver), deadlineAt);
|
|
121
|
+
}
|
|
122
|
+
async function resolveAndValidate(hostname, resolver) {
|
|
123
|
+
const [addresses4, addresses6] = await Promise.all([resolveFamily(() => resolver.resolve4(hostname)), resolveFamily(() => resolver.resolve6(hostname))]);
|
|
124
|
+
if (addresses4.length === 0 && addresses6.length === 0) throw new CimdEgressError("cimd_dns_empty_result");
|
|
125
|
+
for (const address of [...addresses4, ...addresses6]) if (isSpecialUseIp(address)) throw new CimdEgressError("cimd_dns_special_use_ip");
|
|
126
|
+
return {
|
|
127
|
+
addresses4,
|
|
128
|
+
addresses6
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function resolveFamily(resolve) {
|
|
132
|
+
try {
|
|
133
|
+
return await resolve();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (isEmptyDnsResult(error)) return [];
|
|
136
|
+
throw new CimdEgressError("cimd_dns_error", { cause: error });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function isEmptyDnsResult(error) {
|
|
140
|
+
return typeof error === "object" && error !== null && "code" in error && (error.code === "ENODATA" || error.code === "ENOTFOUND");
|
|
141
|
+
}
|
|
142
|
+
async function withDeadline(promise, deadlineAt) {
|
|
143
|
+
const remainingMs = Math.min(DEFAULT_DNS_DEADLINE_MS, deadlineAt - performance.now());
|
|
144
|
+
if (remainingMs <= 0) throw new CimdEgressError("cimd_dns_timeout");
|
|
145
|
+
let timeoutId;
|
|
146
|
+
const timeout = new Promise((_, reject) => {
|
|
147
|
+
timeoutId = setTimeout(() => reject(new CimdEgressError("cimd_dns_timeout")), Math.ceil(remainingMs));
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
return await Promise.race([promise, timeout]);
|
|
151
|
+
} finally {
|
|
152
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function normalizeLookupOptions(options) {
|
|
156
|
+
const lookupOptions = options;
|
|
157
|
+
return {
|
|
158
|
+
all: lookupOptions.all === true,
|
|
159
|
+
family: normalizeFamily(lookupOptions.family)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function normalizeFamily(family) {
|
|
163
|
+
if (family === 4 || family === "IPv4") return 4;
|
|
164
|
+
if (family === 6 || family === "IPv6") return 6;
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
function selectAddresses(addresses, family) {
|
|
168
|
+
const addresses4 = addresses.addresses4.map((address) => ({
|
|
169
|
+
address,
|
|
170
|
+
family: 4
|
|
171
|
+
}));
|
|
172
|
+
const addresses6 = addresses.addresses6.map((address) => ({
|
|
173
|
+
address,
|
|
174
|
+
family: 6
|
|
175
|
+
}));
|
|
176
|
+
if (family === 4) return addresses4;
|
|
177
|
+
if (family === 6) return addresses6;
|
|
178
|
+
return [...addresses4, ...addresses6];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
//#endregion
|
|
182
|
+
export { CimdEgressError, createCimdLookup, isSpecialUseIp, validateCimdHostnameEgress };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/services/mcp-oauth/utils/domain.ts
|
|
2
|
+
/**
|
|
3
|
+
* Check if a hostname matches any of the provided domain patterns.
|
|
4
|
+
* Supports exact match and `*.example.com` wildcard prefix (matches subdomains, not base).
|
|
5
|
+
* Case-insensitive. Whitespace in patterns is trimmed.
|
|
6
|
+
*/
|
|
7
|
+
function isDomainAllowed(hostname, patterns) {
|
|
8
|
+
const lower = hostname.toLowerCase();
|
|
9
|
+
for (const pattern of patterns) {
|
|
10
|
+
const p = pattern.toLowerCase().trim();
|
|
11
|
+
if (!p) continue;
|
|
12
|
+
if (p.startsWith("*.")) {
|
|
13
|
+
const suffix = p.slice(1);
|
|
14
|
+
if (lower.endsWith(suffix) && lower.length > suffix.length) return true;
|
|
15
|
+
} else if (lower === p) return true;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
21
|
+
export { isDomainAllowed };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/services/mcp-oauth/utils/loopback.ts
|
|
2
|
+
/**
|
|
3
|
+
* Check if a URL hostname is a loopback address (localhost, 127.0.0.1, [::1]).
|
|
4
|
+
* URL.hostname returns '[::1]' with brackets for IPv6 on Node 22+.
|
|
5
|
+
*/
|
|
6
|
+
function isLoopbackHost(hostname) {
|
|
7
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
//#endregion
|
|
11
|
+
export { isLoopbackHost };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { OAuthError } from "../types/error.js";
|
|
2
|
+
import { isDomainAllowed } from "./domain.js";
|
|
3
|
+
import { isLoopbackHost } from "./loopback.js";
|
|
4
|
+
import { useEnv } from "@directus/env";
|
|
5
|
+
|
|
6
|
+
//#region src/services/mcp-oauth/utils/redirect.ts
|
|
7
|
+
const MAX_REDIRECT_URI_LENGTH = 255;
|
|
8
|
+
function parseAllowedCustomRedirect(value) {
|
|
9
|
+
let parsed;
|
|
10
|
+
try {
|
|
11
|
+
parsed = new URL(value);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:") return null;
|
|
16
|
+
if (!parsed.hostname || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) return null;
|
|
17
|
+
if (parsed.pathname && parsed.pathname !== "/") return null;
|
|
18
|
+
return {
|
|
19
|
+
protocol: parsed.protocol,
|
|
20
|
+
hostname: parsed.hostname.toLowerCase()
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function getAllowedCustomRedirects() {
|
|
24
|
+
return (useEnv()["MCP_OAUTH_ALLOWED_CUSTOM_REDIRECTS"] ?? []).flatMap((value) => {
|
|
25
|
+
const redirect = parseAllowedCustomRedirect(value);
|
|
26
|
+
return redirect ? [redirect] : [];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function getAllowedCustomRedirectSchemes() {
|
|
30
|
+
return [...new Set(getAllowedCustomRedirects().map(({ protocol }) => protocol))];
|
|
31
|
+
}
|
|
32
|
+
function isAllowedCustomRedirectUri(parsed) {
|
|
33
|
+
if (parsed.port) return false;
|
|
34
|
+
return getAllowedCustomRedirects().some(({ protocol, hostname }) => parsed.protocol === protocol && parsed.hostname.toLowerCase() === hostname);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Validate a redirect URI per RFC 6749 Section 3.1.2 + OAuth 2.1 policy (HTTPS, no fragment, no userinfo).
|
|
38
|
+
* RFC 8252 Section 7.3: HTTP is allowed for loopback addresses (localhost, 127.0.0.1, [::1]).
|
|
39
|
+
* Compatibility: MCP_OAUTH_ALLOWED_CUSTOM_REDIRECTS configures known custom-scheme desktop redirects.
|
|
40
|
+
* Optional MCP_OAUTH_ALLOWED_REDIRECT_DOMAINS env var enforces a server-wide domain allowlist
|
|
41
|
+
* (loopback and known desktop redirects bypass the allowlist to keep native OAuth clients working).
|
|
42
|
+
*/
|
|
43
|
+
function validateRedirectUri(uri) {
|
|
44
|
+
if (typeof uri !== "string") throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri must be a string");
|
|
45
|
+
if (uri.length > MAX_REDIRECT_URI_LENGTH) throw new OAuthError(400, "invalid_redirect_uri", `redirect_uri must not exceed ${MAX_REDIRECT_URI_LENGTH} characters`);
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = new URL(uri);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new OAuthError(400, "invalid_redirect_uri", `Invalid redirect URI: ${uri}`);
|
|
51
|
+
}
|
|
52
|
+
if (parsed.hash) throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri must not contain a fragment");
|
|
53
|
+
if (parsed.username || parsed.password) throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri must not contain userinfo");
|
|
54
|
+
if (isAllowedCustomRedirectUri(parsed)) return;
|
|
55
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))) throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri must use HTTPS (except for localhost)");
|
|
56
|
+
const allowedDomains = useEnv()["MCP_OAUTH_ALLOWED_REDIRECT_DOMAINS"] ?? [];
|
|
57
|
+
if (allowedDomains.length > 0 && !isLoopbackHost(parsed.hostname) && !isDomainAllowed(parsed.hostname, allowedDomains)) throw new OAuthError(400, "invalid_redirect_uri", "redirect_uri domain is not in the allowlist");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if a requested redirect_uri matches any registered URI.
|
|
61
|
+
* RFC 6749 Section 3.1.2: exact string match for non-loopback.
|
|
62
|
+
* RFC 8252 Section 7.3: loopback redirect URIs (localhost, 127.0.0.1, [::1]) MUST allow any port
|
|
63
|
+
* at request time, since native apps bind to ephemeral ports.
|
|
64
|
+
*/
|
|
65
|
+
function matchRedirectUri(requested, registered) {
|
|
66
|
+
let reqUrl;
|
|
67
|
+
try {
|
|
68
|
+
reqUrl = new URL(requested);
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (reqUrl.username || reqUrl.password || reqUrl.hash) return false;
|
|
73
|
+
return registered.some((reg) => {
|
|
74
|
+
if (reg === requested) return true;
|
|
75
|
+
try {
|
|
76
|
+
const regUrl = new URL(reg);
|
|
77
|
+
if (isLoopbackHost(regUrl.hostname) && isLoopbackHost(reqUrl.hostname)) return regUrl.protocol === reqUrl.protocol && regUrl.username === reqUrl.username && regUrl.password === reqUrl.password && regUrl.hostname === reqUrl.hostname && regUrl.pathname === reqUrl.pathname && regUrl.search === reqUrl.search && regUrl.hash === reqUrl.hash;
|
|
78
|
+
} catch {}
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
export { getAllowedCustomRedirectSchemes, matchRedirectUri, validateRedirectUri };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { isObject } from "@directus/utils";
|
|
2
|
+
|
|
3
|
+
//#region src/services/mcp-oauth/utils/registration-debug.ts
|
|
4
|
+
const KNOWN_REGISTRATION_FIELDS = new Set([
|
|
5
|
+
"client_name",
|
|
6
|
+
"redirect_uris",
|
|
7
|
+
"grant_types",
|
|
8
|
+
"response_types",
|
|
9
|
+
"token_endpoint_auth_method",
|
|
10
|
+
"client_uri",
|
|
11
|
+
"logo_uri",
|
|
12
|
+
"tos_uri",
|
|
13
|
+
"policy_uri",
|
|
14
|
+
"client_secret",
|
|
15
|
+
"client_secret_expires_at"
|
|
16
|
+
]);
|
|
17
|
+
const OPTIONAL_URI_FIELDS = [
|
|
18
|
+
"client_uri",
|
|
19
|
+
"logo_uri",
|
|
20
|
+
"tos_uri",
|
|
21
|
+
"policy_uri"
|
|
22
|
+
];
|
|
23
|
+
const ALLOWED_GRANT_TYPES = new Set(["authorization_code", "refresh_token"]);
|
|
24
|
+
const ALLOWED_RESPONSE_TYPES = new Set(["code"]);
|
|
25
|
+
const ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS = new Set([
|
|
26
|
+
"none",
|
|
27
|
+
"client_secret_basic",
|
|
28
|
+
"client_secret_post"
|
|
29
|
+
]);
|
|
30
|
+
function typeOf(value) {
|
|
31
|
+
if (value === null) return "null";
|
|
32
|
+
if (Array.isArray(value)) return "array";
|
|
33
|
+
return typeof value;
|
|
34
|
+
}
|
|
35
|
+
function summarizeEnumArray(value, allowedValues) {
|
|
36
|
+
if (!Array.isArray(value)) return { type: typeOf(value) };
|
|
37
|
+
const stringValues = value.filter((item) => typeof item === "string");
|
|
38
|
+
const recognizedValues = [...new Set(stringValues.filter((item) => allowedValues.has(item)))].sort();
|
|
39
|
+
const unknownValueCount = stringValues.filter((item) => !allowedValues.has(item)).length;
|
|
40
|
+
return {
|
|
41
|
+
type: "array",
|
|
42
|
+
count: value.length,
|
|
43
|
+
recognized_values: recognizedValues,
|
|
44
|
+
unknown_value_count: unknownValueCount
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function summarizeUris(value) {
|
|
48
|
+
if (!Array.isArray(value)) return { type: typeOf(value) };
|
|
49
|
+
const uris = value.slice(0, 10).map((uri) => {
|
|
50
|
+
if (typeof uri !== "string") return { type: typeOf(uri) };
|
|
51
|
+
try {
|
|
52
|
+
const parsed = new URL(uri);
|
|
53
|
+
return {
|
|
54
|
+
scheme: parsed.protocol.replace(/:$/, ""),
|
|
55
|
+
hostname: parsed.hostname,
|
|
56
|
+
has_path: parsed.pathname !== "/",
|
|
57
|
+
has_query: parsed.search.length > 0,
|
|
58
|
+
has_fragment: parsed.hash.length > 0,
|
|
59
|
+
has_userinfo: parsed.username.length > 0 || parsed.password.length > 0
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return {
|
|
63
|
+
type: "string",
|
|
64
|
+
length: uri.length,
|
|
65
|
+
parseable: false
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
type: "array",
|
|
71
|
+
count: value.length,
|
|
72
|
+
uris
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function summarizeOptionalUri(value) {
|
|
76
|
+
if (value === void 0 || value === null) return { present: false };
|
|
77
|
+
if (typeof value !== "string") return {
|
|
78
|
+
present: true,
|
|
79
|
+
type: typeOf(value)
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
const parsed = new URL(value);
|
|
83
|
+
return {
|
|
84
|
+
present: true,
|
|
85
|
+
scheme: parsed.protocol.replace(/:$/, ""),
|
|
86
|
+
hostname: parsed.hostname,
|
|
87
|
+
has_query: parsed.search.length > 0,
|
|
88
|
+
has_fragment: parsed.hash.length > 0,
|
|
89
|
+
has_userinfo: parsed.username.length > 0 || parsed.password.length > 0
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
return {
|
|
93
|
+
present: true,
|
|
94
|
+
type: "string",
|
|
95
|
+
length: value.length,
|
|
96
|
+
parseable: false
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function summarizeDcrRegistrationMetadata(body) {
|
|
101
|
+
if (!isObject(body)) return { body_type: typeOf(body) };
|
|
102
|
+
const keys = Object.keys(body).sort();
|
|
103
|
+
const clientName = body["client_name"];
|
|
104
|
+
const authMethod = body["token_endpoint_auth_method"];
|
|
105
|
+
const optional_uris = {};
|
|
106
|
+
for (const field of OPTIONAL_URI_FIELDS) if (field in body) optional_uris[field] = summarizeOptionalUri(body[field]);
|
|
107
|
+
return {
|
|
108
|
+
body_type: "object",
|
|
109
|
+
body_keys: keys,
|
|
110
|
+
unknown_fields: keys.filter((key) => !KNOWN_REGISTRATION_FIELDS.has(key)),
|
|
111
|
+
client_name: {
|
|
112
|
+
type: typeOf(clientName),
|
|
113
|
+
present: typeof clientName === "string" && clientName.length > 0,
|
|
114
|
+
...typeof clientName === "string" ? { length: clientName.length } : {}
|
|
115
|
+
},
|
|
116
|
+
redirect_uris: summarizeUris(body["redirect_uris"]),
|
|
117
|
+
grant_types: summarizeEnumArray(body["grant_types"], ALLOWED_GRANT_TYPES),
|
|
118
|
+
response_types: summarizeEnumArray(body["response_types"], ALLOWED_RESPONSE_TYPES),
|
|
119
|
+
token_endpoint_auth_method: {
|
|
120
|
+
type: typeOf(authMethod),
|
|
121
|
+
...typeof authMethod === "string" && ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS.has(authMethod) ? { value: authMethod } : {},
|
|
122
|
+
...typeof authMethod === "string" && !ALLOWED_TOKEN_ENDPOINT_AUTH_METHODS.has(authMethod) ? { unknown_value: true } : {}
|
|
123
|
+
},
|
|
124
|
+
optional_uris,
|
|
125
|
+
client_secret_present: body["client_secret"] !== void 0,
|
|
126
|
+
client_secret_expires_at_present: body["client_secret_expires_at"] !== void 0
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
//#endregion
|
|
131
|
+
export { summarizeDcrRegistrationMetadata };
|
package/dist/services/payload.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useLogger } from "../logger/index.js";
|
|
2
2
|
import { UserIntegrityCheckFlag } from "../packages/types/dist/index.js";
|
|
3
|
+
import { extractFunctionName } from "../utils/extract-function-name.js";
|
|
3
4
|
import { getFunctions, getHelpers } from "../database/helpers/index.js";
|
|
4
5
|
import database_default from "../database/index.js";
|
|
5
6
|
import { decrypt, encrypt } from "../utils/encrypt.js";
|
|
@@ -219,7 +220,7 @@ var PayloadService = class {
|
|
|
219
220
|
processJsonFunctionResults(payloads, aliasMap = {}) {
|
|
220
221
|
const fn = getFunctions(this.knex, this.schema);
|
|
221
222
|
for (const [aliasField, originalField] of Object.entries(aliasMap)) {
|
|
222
|
-
if (
|
|
223
|
+
if (extractFunctionName(originalField) !== "json") continue;
|
|
223
224
|
for (const payload of payloads) payload[aliasField] = fn.parseJsonResult(payload[aliasField]);
|
|
224
225
|
}
|
|
225
226
|
}
|
|
@@ -4,8 +4,11 @@ import { fetchPolicies } from "../permissions/lib/fetch-policies.js";
|
|
|
4
4
|
import { fetchPermissions } from "../permissions/lib/fetch-permissions.js";
|
|
5
5
|
import { validateAccess } from "../permissions/modules/validate-access/validate-access.js";
|
|
6
6
|
import { ItemsService } from "./items.js";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { hasCustomRule, isRecommendedAppPermission } from "../license/entitlements/lib/custom-permission-rules-enabled.js";
|
|
8
|
+
import { getEntitlementManager } from "../license/entitlements/manager.js";
|
|
9
|
+
import "../license/index.js";
|
|
10
|
+
import { ForbiddenError, ResourceRestrictedError } from "@directus/errors";
|
|
11
|
+
import { omit, uniq } from "lodash-es";
|
|
9
12
|
|
|
10
13
|
//#region src/services/permissions.ts
|
|
11
14
|
var PermissionsService = class extends ItemsService {
|
|
@@ -14,17 +17,41 @@ var PermissionsService = class extends ItemsService {
|
|
|
14
17
|
}
|
|
15
18
|
async clearCaches(opts) {
|
|
16
19
|
await clearSystemCache({ autoPurgeCache: opts?.autoPurgeCache });
|
|
20
|
+
await getEntitlementManager().clearCache("custom_permission_rules_enabled");
|
|
17
21
|
if (this.cache && opts?.autoPurgeCache !== false) await this.cache.clear();
|
|
18
22
|
}
|
|
19
23
|
async readByQuery(query, opts) {
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
if (getEntitlementManager().isEntitled("custom_permission_rules_enabled")) {
|
|
25
|
+
const result = await super.readByQuery(query, opts);
|
|
26
|
+
return withAppMinimalPermissions(this.accountability, result, query.filter);
|
|
27
|
+
}
|
|
28
|
+
const requiredFields = [
|
|
29
|
+
"fields",
|
|
30
|
+
"permissions",
|
|
31
|
+
"validation",
|
|
32
|
+
"presets"
|
|
33
|
+
];
|
|
34
|
+
let extraFields = [];
|
|
35
|
+
if (query.fields && !query.fields.includes("*")) {
|
|
36
|
+
extraFields = requiredFields.filter((f) => !query.fields?.includes(f));
|
|
37
|
+
query.fields = [...query.fields, ...extraFields];
|
|
38
|
+
}
|
|
39
|
+
const filteredPermissions = (await super.readByQuery(query, opts)).filter((p) => !hasCustomRule(p) || isRecommendedAppPermission(p));
|
|
40
|
+
const mappedPermissions = extraFields.length > 0 ? filteredPermissions.map((p) => omit(p, extraFields)) : filteredPermissions;
|
|
41
|
+
return withAppMinimalPermissions(this.accountability, mappedPermissions, query.filter);
|
|
22
42
|
}
|
|
23
43
|
async createOne(data, opts) {
|
|
44
|
+
if (hasCustomRule(data) && !isRecommendedAppPermission(data)) await getEntitlementManager().assert("custom_permission_rules_enabled", { knex: this.knex });
|
|
24
45
|
const res = await super.createOne(data, opts);
|
|
25
46
|
await this.clearCaches(opts);
|
|
26
47
|
return res;
|
|
27
48
|
}
|
|
49
|
+
async updateMany(keys, data, opts) {
|
|
50
|
+
if (hasCustomRule(data) && !isRecommendedAppPermission(data)) await getEntitlementManager().assert("custom_permission_rules_enabled", { knex: this.knex });
|
|
51
|
+
const res = await super.updateMany(keys, data, opts);
|
|
52
|
+
await this.clearCaches(opts);
|
|
53
|
+
return res;
|
|
54
|
+
}
|
|
28
55
|
async createMany(data, opts) {
|
|
29
56
|
const res = await super.createMany(data, opts);
|
|
30
57
|
await this.clearCaches(opts);
|
|
@@ -35,11 +62,6 @@ var PermissionsService = class extends ItemsService {
|
|
|
35
62
|
await this.clearCaches(opts);
|
|
36
63
|
return res;
|
|
37
64
|
}
|
|
38
|
-
async updateMany(keys, data, opts) {
|
|
39
|
-
const res = await super.updateMany(keys, data, opts);
|
|
40
|
-
await this.clearCaches(opts);
|
|
41
|
-
return res;
|
|
42
|
-
}
|
|
43
65
|
async upsertMany(payloads, opts) {
|
|
44
66
|
const res = await super.upsertMany(payloads, opts);
|
|
45
67
|
await this.clearCaches(opts);
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { ItemsService } from "./items.js";
|
|
2
|
+
import { getHistoryFilterQuery } from "../utils/get-history-filter-query.js";
|
|
2
3
|
import { ForbiddenError, InvalidPayloadError } from "@directus/errors";
|
|
3
4
|
|
|
4
5
|
//#region src/services/revisions.ts
|
|
5
6
|
var RevisionsService = class extends ItemsService {
|
|
7
|
+
queryCache = /* @__PURE__ */ new WeakMap();
|
|
6
8
|
constructor(options) {
|
|
7
9
|
super("directus_revisions", options);
|
|
8
10
|
}
|
|
@@ -37,6 +39,19 @@ var RevisionsService = class extends ItemsService {
|
|
|
37
39
|
async updateMany(keys, data, opts) {
|
|
38
40
|
return super.updateMany(keys, data, this.setDefaultOptions(opts));
|
|
39
41
|
}
|
|
42
|
+
async readByQuery(query, opts) {
|
|
43
|
+
if (this.accountability === null) return super.readByQuery(query, opts);
|
|
44
|
+
const historyQuery = this.getLimitedHistoryQuery(query);
|
|
45
|
+
return super.readByQuery(historyQuery, opts);
|
|
46
|
+
}
|
|
47
|
+
getLimitedHistoryQuery(query) {
|
|
48
|
+
let cachedQuery = this.queryCache.get(query);
|
|
49
|
+
if (!cachedQuery) {
|
|
50
|
+
cachedQuery = getHistoryFilterQuery(query, "revision_historical_timeframe", (sinceDate) => ({ activity: { timestamp: { _gte: sinceDate.toISOString() } } }));
|
|
51
|
+
this.queryCache.set(query, cachedQuery);
|
|
52
|
+
}
|
|
53
|
+
return cachedQuery;
|
|
54
|
+
}
|
|
40
55
|
};
|
|
41
56
|
|
|
42
57
|
//#endregion
|