@directus/api 36.0.0-rc.0 → 36.0.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.
- package/dist/ai/chat/lib/create-ui-stream.js +8 -17
- package/dist/ai/chat/utils/format-context.js +2 -2
- package/dist/ai/chat/utils/prompt-caching.js +85 -0
- package/dist/app.js +2 -2
- package/dist/controllers/server.js +3 -4
- package/dist/database/errors/dialects/postgres.js +2 -1
- package/dist/database/run-ast/utils/merge-with-parent-items.js +5 -3
- package/dist/license/entitlements/lib/custom-permission-rules-enabled.js +2 -7
- package/dist/license/entitlements/lib/seats.js +42 -18
- package/dist/license/entitlements/lib/sso-enabled.js +2 -1
- package/dist/services/graphql/resolvers/system.js +9 -9
- package/dist/services/graphql/schema/get-types.js +5 -5
- package/dist/services/permissions.js +2 -2
- package/dist/services/server.js +45 -64
- package/dist/services/users.js +10 -0
- package/dist/services/versions.js +26 -4
- package/dist/utils/create-admin.js +3 -3
- package/dist/utils/get-cache-key.js +1 -0
- package/dist/utils/is-unauthenticated.js +15 -0
- package/dist/utils/sanitize-query.js +1 -1
- package/dist/utils/store.js +1 -1
- package/dist/utils/versioning/handle-version.js +2 -1
- package/package.json +29 -29
|
@@ -6,6 +6,7 @@ import "../../providers/index.js";
|
|
|
6
6
|
import { getAITelemetryConfig } from "../../telemetry/index.js";
|
|
7
7
|
import { SYSTEM_PROMPT } from "../constants/system-prompt.js";
|
|
8
8
|
import { formatContextForSystemPrompt } from "../utils/format-context.js";
|
|
9
|
+
import { applyAnthropicConversationCaching, buildCacheAwareSystemPrompt, formatUsageWithCacheTokens, sortToolsByName } from "../utils/prompt-caching.js";
|
|
9
10
|
import { transformFilePartsForProvider } from "./transform-file-parts.js";
|
|
10
11
|
import { ServiceUnavailableError } from "@directus/errors";
|
|
11
12
|
import { convertToModelMessages, stepCountIs, streamText, wrapLanguageModel } from "ai";
|
|
@@ -27,35 +28,25 @@ const createUiStream = async (messages, { provider, model, tools, aiSettings, sy
|
|
|
27
28
|
model: languageModel,
|
|
28
29
|
middleware: devToolsMiddleware
|
|
29
30
|
});
|
|
30
|
-
const
|
|
31
|
-
const finalTools = applyAnthropicToolSearch(provider, model, tools);
|
|
31
|
+
const streamSystemPrompt = buildCacheAwareSystemPrompt(provider, provider === "anthropic" || !contextBlock ? baseSystemPrompt : baseSystemPrompt + contextBlock);
|
|
32
|
+
const finalTools = sortToolsByName(applyAnthropicToolSearch(provider, model, tools));
|
|
32
33
|
const telemetryConfig = getAITelemetryConfig({
|
|
33
34
|
provider,
|
|
34
35
|
model,
|
|
35
36
|
userId,
|
|
36
37
|
role
|
|
37
38
|
});
|
|
39
|
+
const streamMessages = applyAnthropicConversationCaching(provider, await convertToModelMessages(transformFilePartsForProvider(messages)), contextBlock);
|
|
38
40
|
return streamText({
|
|
39
|
-
system:
|
|
41
|
+
system: streamSystemPrompt,
|
|
40
42
|
model: languageModel,
|
|
41
|
-
messages:
|
|
43
|
+
messages: streamMessages,
|
|
42
44
|
stopWhen: [stepCountIs(10)],
|
|
43
45
|
providerOptions,
|
|
44
46
|
tools: finalTools,
|
|
45
47
|
...telemetryConfig ? { experimental_telemetry: telemetryConfig } : {},
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
return {};
|
|
49
|
-
},
|
|
50
|
-
onFinish({ usage }) {
|
|
51
|
-
if (onUsage) {
|
|
52
|
-
const { inputTokens, outputTokens, totalTokens } = usage;
|
|
53
|
-
onUsage({
|
|
54
|
-
inputTokens,
|
|
55
|
-
outputTokens,
|
|
56
|
-
totalTokens
|
|
57
|
-
});
|
|
58
|
-
}
|
|
48
|
+
onFinish(result) {
|
|
49
|
+
if (onUsage) onUsage(formatUsageWithCacheTokens(result));
|
|
59
50
|
}
|
|
60
51
|
});
|
|
61
52
|
};
|
|
@@ -70,8 +70,6 @@ ${promptBlocks}
|
|
|
70
70
|
</custom_instructions>`);
|
|
71
71
|
}
|
|
72
72
|
const sections = [];
|
|
73
|
-
const now = /* @__PURE__ */ new Date();
|
|
74
|
-
sections.push(`## Current Date\n${now.toISOString().split("T")[0]}`);
|
|
75
73
|
if (context.page) {
|
|
76
74
|
const page = context.page;
|
|
77
75
|
const pageLines = [`Path: ${escapeAngleBrackets(String(page.path))}`];
|
|
@@ -89,6 +87,8 @@ Use the items tool to fetch additional fields or update items when asked.
|
|
|
89
87
|
|
|
90
88
|
${itemLines}`);
|
|
91
89
|
}
|
|
90
|
+
const now = /* @__PURE__ */ new Date();
|
|
91
|
+
sections.push(`## Current Date\n${now.toISOString().split("T")[0]}`);
|
|
92
92
|
if (sections.length > 0) parts.push(`<user_context>\n${sections.join("\n\n")}\n</user_context>`);
|
|
93
93
|
if (groups.visualElements.length > 0) {
|
|
94
94
|
const elementLines = groups.visualElements.map(formatVisualElement).join("\n\n");
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//#region src/ai/chat/utils/prompt-caching.ts
|
|
2
|
+
function buildCacheAwareSystemPrompt(provider, content) {
|
|
3
|
+
if (provider !== "anthropic") return content;
|
|
4
|
+
return {
|
|
5
|
+
role: "system",
|
|
6
|
+
content,
|
|
7
|
+
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } }
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* For Anthropic, place a cache breakpoint on the last existing message so the conversation
|
|
12
|
+
* prefix (tools + system + history) caches as it grows, and append the per-request page
|
|
13
|
+
* context as a new user message after the breakpoint so it stays out of the cached prefix.
|
|
14
|
+
*
|
|
15
|
+
* Context is only appended after a real user turn. On multi-step continuations the last
|
|
16
|
+
* message is an assistant or tool result; appending a user message there would make the
|
|
17
|
+
* model respond to the context instead of synthesizing the tool output. The model still
|
|
18
|
+
* sees context from the originating user turn earlier in the conversation.
|
|
19
|
+
*
|
|
20
|
+
* Other providers either auto-cache (Google/OpenAI) or don't support this, so we keep the
|
|
21
|
+
* context inside the system prompt for them (handled upstream).
|
|
22
|
+
*/
|
|
23
|
+
function applyAnthropicConversationCaching(provider, messages, contextBlock) {
|
|
24
|
+
if (provider !== "anthropic" || messages.length === 0) return messages;
|
|
25
|
+
const lastIndex = messages.length - 1;
|
|
26
|
+
const messagesWithCache = messages.map((message, index) => index === lastIndex ? {
|
|
27
|
+
...message,
|
|
28
|
+
providerOptions: {
|
|
29
|
+
...message.providerOptions,
|
|
30
|
+
anthropic: {
|
|
31
|
+
...message.providerOptions?.["anthropic"],
|
|
32
|
+
cacheControl: { type: "ephemeral" }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} : message);
|
|
36
|
+
if (!contextBlock || messages[lastIndex]?.role !== "user") return messagesWithCache;
|
|
37
|
+
return [...messagesWithCache, {
|
|
38
|
+
role: "user",
|
|
39
|
+
content: contextBlock
|
|
40
|
+
}];
|
|
41
|
+
}
|
|
42
|
+
function sortToolsByName(tools) {
|
|
43
|
+
return Object.fromEntries(Object.entries(tools).sort(([a], [b]) => {
|
|
44
|
+
if (a < b) return -1;
|
|
45
|
+
if (a > b) return 1;
|
|
46
|
+
return 0;
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
function formatUsageWithCacheTokens(result) {
|
|
50
|
+
const usage = result.totalUsage ?? result.usage;
|
|
51
|
+
const providerMetadata = result.steps?.map((step) => step.providerMetadata) ?? [result.providerMetadata];
|
|
52
|
+
const { inputTokens, outputTokens, totalTokens } = usage;
|
|
53
|
+
return {
|
|
54
|
+
inputTokens,
|
|
55
|
+
outputTokens,
|
|
56
|
+
totalTokens,
|
|
57
|
+
...getCacheTokenUsage(usage, providerMetadata)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function getCacheTokenUsage(usage, providerMetadata) {
|
|
61
|
+
const cacheReadTokens = usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens ?? sumNumbers([...providerMetadata.map((metadata) => getProviderMetadataNumber(metadata, "anthropic", "cacheReadInputTokens")), ...providerMetadata.map(getGoogleCachedContentTokenCount)]);
|
|
62
|
+
const cacheCreationTokens = usage.inputTokenDetails?.cacheWriteTokens ?? sumNumbers(providerMetadata.map((metadata) => getProviderMetadataNumber(metadata, "anthropic", "cacheCreationInputTokens")));
|
|
63
|
+
return {
|
|
64
|
+
...cacheReadTokens !== void 0 ? { cacheReadTokens } : {},
|
|
65
|
+
...cacheCreationTokens !== void 0 ? { cacheCreationTokens } : {}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function getProviderMetadataNumber(providerMetadata, providerName, fieldName) {
|
|
69
|
+
const value = providerMetadata?.[providerName]?.[fieldName];
|
|
70
|
+
return typeof value === "number" ? value : void 0;
|
|
71
|
+
}
|
|
72
|
+
function getGoogleCachedContentTokenCount(providerMetadata) {
|
|
73
|
+
const usageMetadata = providerMetadata?.["google"]?.["usageMetadata"];
|
|
74
|
+
if (!usageMetadata || typeof usageMetadata !== "object" || Array.isArray(usageMetadata)) return;
|
|
75
|
+
const cachedContentTokenCount = usageMetadata["cachedContentTokenCount"];
|
|
76
|
+
return typeof cachedContentTokenCount === "number" ? cachedContentTokenCount : void 0;
|
|
77
|
+
}
|
|
78
|
+
function sumNumbers(values) {
|
|
79
|
+
const definedValues = values.filter((value) => typeof value === "number");
|
|
80
|
+
if (definedValues.length === 0) return;
|
|
81
|
+
return definedValues.reduce((sum, value) => sum + value, 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
85
|
+
export { applyAnthropicConversationCaching, buildCacheAwareSystemPrompt, formatUsageWithCacheTokens, sortToolsByName };
|
package/dist/app.js
CHANGED
|
@@ -11,8 +11,6 @@ import { getFlowManager } from "./flows.js";
|
|
|
11
11
|
import { getExtensionManager } from "./extensions/index.js";
|
|
12
12
|
import { registerAuthProviders } from "./auth.js";
|
|
13
13
|
import { ensureDeploymentWebhooks, registerDeploymentDrivers } from "./deployment.js";
|
|
14
|
-
import rate_limiter_global_default from "./middleware/rate-limiter-global.js";
|
|
15
|
-
import rate_limiter_ip_default from "./middleware/rate-limiter-ip.js";
|
|
16
14
|
import { getLicenseManager } from "./license/manager.js";
|
|
17
15
|
import "./license/index.js";
|
|
18
16
|
import { aiRouter } from "./ai/chat/router.js";
|
|
@@ -63,6 +61,8 @@ import cors_default from "./middleware/cors.js";
|
|
|
63
61
|
import { errorHandler } from "./middleware/error-handler.js";
|
|
64
62
|
import extract_token_default from "./middleware/extract-token.js";
|
|
65
63
|
import mcp_oauth_guard_default from "./middleware/mcp-oauth-guard.js";
|
|
64
|
+
import rate_limiter_global_default from "./middleware/rate-limiter-global.js";
|
|
65
|
+
import rate_limiter_ip_default from "./middleware/rate-limiter-ip.js";
|
|
66
66
|
import request_counter_default from "./middleware/request-counter.js";
|
|
67
67
|
import sanitize_query_default from "./middleware/sanitize-query.js";
|
|
68
68
|
import schema_default$1 from "./middleware/schema.js";
|
|
@@ -8,6 +8,7 @@ import { getLicenseManager } from "../license/manager.js";
|
|
|
8
8
|
import { createAdmin } from "../utils/create-admin.js";
|
|
9
9
|
import { useEnv } from "@directus/env";
|
|
10
10
|
import { ErrorCode, ForbiddenError, InvalidPayloadError, RouteNotFoundError, isDirectusError } from "@directus/errors";
|
|
11
|
+
import { toBoolean } from "@directus/utils";
|
|
11
12
|
import { Router } from "express";
|
|
12
13
|
import { fromZodError } from "zod-validation-error";
|
|
13
14
|
import z from "zod";
|
|
@@ -49,7 +50,7 @@ router.get("/info", async_handler_default(async (req, res, next) => {
|
|
|
49
50
|
res.locals["payload"] = { data };
|
|
50
51
|
return next();
|
|
51
52
|
}), respond);
|
|
52
|
-
router.get("/health", async_handler_default(async (req, res, next) => {
|
|
53
|
+
if (toBoolean(env["HEALTHCHECK_ENABLED"]) !== false) router.get("/health", async_handler_default(async (req, res, next) => {
|
|
53
54
|
const data = await new ServerService({
|
|
54
55
|
accountability: req.accountability,
|
|
55
56
|
schema: req.schema
|
|
@@ -85,6 +86,7 @@ router.post("/setup", async_handler_default(async (req, _res, next) => {
|
|
|
85
86
|
if (error) throw new InvalidPayloadError({ reason: fromZodError(error).message });
|
|
86
87
|
const licenseManager = getLicenseManager();
|
|
87
88
|
try {
|
|
89
|
+
if (data.license_key) await licenseManager.activate(data.license_key);
|
|
88
90
|
await createAdmin(req.schema, {
|
|
89
91
|
email: data.admin.email,
|
|
90
92
|
password: data.admin.password,
|
|
@@ -92,9 +94,6 @@ router.post("/setup", async_handler_default(async (req, _res, next) => {
|
|
|
92
94
|
last_name: data.admin.last_name
|
|
93
95
|
});
|
|
94
96
|
const settingsService = new SettingsService({ schema: req.schema });
|
|
95
|
-
if (data.license_key) try {
|
|
96
|
-
await licenseManager.activate(data.license_key);
|
|
97
|
-
} catch {}
|
|
98
97
|
if (data.owner) settingsService.setOwner(data.owner);
|
|
99
98
|
} catch (error$1) {
|
|
100
99
|
if (isDirectusError(error$1, ErrorCode.Forbidden)) return next();
|
|
@@ -34,7 +34,8 @@ function extractError(error, data) {
|
|
|
34
34
|
const matches = error.message.match(/"(.*?)"/g);
|
|
35
35
|
if (!matches) return error;
|
|
36
36
|
const collection = matches[0].slice(1, -1);
|
|
37
|
-
const
|
|
37
|
+
const fields = Object.keys(data);
|
|
38
|
+
const field = fields.length === 1 ? fields[0] : null;
|
|
38
39
|
return new ValueOutOfRangeError({
|
|
39
40
|
collection,
|
|
40
41
|
field,
|
|
@@ -9,13 +9,13 @@ function mergeWithParentItems(schema, nestedItem, parentItem, nestedNode, fieldA
|
|
|
9
9
|
const parentItems = clone(toArray(parentItem));
|
|
10
10
|
if (nestedNode.type === "m2o") {
|
|
11
11
|
const parentsByForeignKey = /* @__PURE__ */ new Map();
|
|
12
|
+
const nestedPrimaryKeyField = schema.collections[nestedNode.relation.related_collection].primary;
|
|
12
13
|
for (const parentItem$1 of parentItems) {
|
|
13
|
-
const relationKey = parentItem$1[nestedNode.relation.field];
|
|
14
|
+
const relationKey = parentItem$1[nestedNode.relation.field]?.[nestedPrimaryKeyField] ?? parentItem$1[nestedNode.relation.field];
|
|
14
15
|
if (!parentsByForeignKey.has(relationKey)) parentsByForeignKey.set(relationKey, []);
|
|
15
16
|
parentItem$1[nestedNode.fieldKey] = null;
|
|
16
17
|
parentsByForeignKey.get(relationKey).push(parentItem$1);
|
|
17
18
|
}
|
|
18
|
-
const nestedPrimaryKeyField = schema.collections[nestedNode.relation.related_collection].primary;
|
|
19
19
|
for (const nestedItem$1 of nestedItems) {
|
|
20
20
|
const nestedPK = nestedItem$1[nestedPrimaryKeyField];
|
|
21
21
|
if (nestedPK === null) continue;
|
|
@@ -84,8 +84,10 @@ function mergeWithParentItems(schema, nestedItem, parentItem, nestedNode, fieldA
|
|
|
84
84
|
parentItem$1[nestedNode.fieldKey] = null;
|
|
85
85
|
continue;
|
|
86
86
|
}
|
|
87
|
+
const relatedPrimaryKeyField = schema.collections[relatedCollection].primary;
|
|
88
|
+
const foreignKey = parentItem$1[nestedNode.relation.field]?.[relatedPrimaryKeyField] ?? parentItem$1[nestedNode.relation.field];
|
|
87
89
|
const itemChild = nestedItem[relatedCollection].find((nestedItem$1) => {
|
|
88
|
-
return nestedItem$1[nestedNode.relatedKey[relatedCollection]] ==
|
|
90
|
+
return nestedItem$1[nestedNode.relatedKey[relatedCollection]] == foreignKey;
|
|
89
91
|
});
|
|
90
92
|
parentItem$1[nestedNode.fieldKey] = itemChild || null;
|
|
91
93
|
}
|
|
@@ -3,7 +3,7 @@ import { ItemsService } from "../../../services/items.js";
|
|
|
3
3
|
import { getSchema } from "../../../utils/get-schema.js";
|
|
4
4
|
import "../../../services/index.js";
|
|
5
5
|
import { isEqual } from "lodash-es";
|
|
6
|
-
import {
|
|
6
|
+
import { appRecommendedPermissions } from "@directus/system-data";
|
|
7
7
|
|
|
8
8
|
//#region src/license/entitlements/lib/custom-permission-rules-enabled.ts
|
|
9
9
|
function hasCustomRule(permission) {
|
|
@@ -16,11 +16,6 @@ function isRecommendedAppPermission(permission) {
|
|
|
16
16
|
if (!foundPermission) return false;
|
|
17
17
|
return isEqual(foundPermission.fields ?? null, permission.fields ?? null) && isEqual(foundPermission.permissions ?? null, permission.permissions ?? null);
|
|
18
18
|
}
|
|
19
|
-
function isMinimumAppPermission(permission) {
|
|
20
|
-
const foundPermission = appAccessMinimalPermissions.find((p) => p.action === permission.action && p.collection === permission.collection);
|
|
21
|
-
if (!foundPermission) return false;
|
|
22
|
-
return isEqual(foundPermission.fields ?? null, permission.fields ?? null) && isEqual(foundPermission.permissions ?? null, permission.permissions ?? null) && isEqual(foundPermission.validation ?? null, permission.validation ?? null) && isEqual(foundPermission.presets ?? null, permission.presets ?? null);
|
|
23
|
-
}
|
|
24
19
|
async function checkCustomPermissionRules(opts) {
|
|
25
20
|
const knex = opts?.knex ?? database_default();
|
|
26
21
|
return (await new ItemsService("directus_permissions", {
|
|
@@ -38,4 +33,4 @@ async function checkCustomPermissionRules(opts) {
|
|
|
38
33
|
}
|
|
39
34
|
|
|
40
35
|
//#endregion
|
|
41
|
-
export { checkCustomPermissionRules, hasCustomRule,
|
|
36
|
+
export { checkCustomPermissionRules, hasCustomRule, isRecommendedAppPermission };
|
|
@@ -8,10 +8,49 @@ import { toBoolean } from "@directus/utils";
|
|
|
8
8
|
import { USER_INACTIVE_LICENSE_STATUS } from "@directus/constants";
|
|
9
9
|
|
|
10
10
|
//#region src/license/entitlements/lib/seats.ts
|
|
11
|
+
/**
|
|
12
|
+
* Group access rows to the admin/app users and roles that occupy a seat.
|
|
13
|
+
*/
|
|
14
|
+
function getSeatUsersAndRoles(accessRows) {
|
|
15
|
+
const adminRoles = /* @__PURE__ */ new Set();
|
|
16
|
+
const appRoles = /* @__PURE__ */ new Set();
|
|
17
|
+
const adminUsers = /* @__PURE__ */ new Set();
|
|
18
|
+
const appUsers = /* @__PURE__ */ new Set();
|
|
19
|
+
const appUsersByRole = /* @__PURE__ */ new Map();
|
|
20
|
+
for (const accessRow of accessRows) {
|
|
21
|
+
const { admin_access, app_access } = accessRow["policy"] || {};
|
|
22
|
+
const isAdmin = toBoolean(admin_access);
|
|
23
|
+
const isApp = !isAdmin && toBoolean(app_access);
|
|
24
|
+
if (!isAdmin && !isApp) continue;
|
|
25
|
+
if (accessRow["user"] && accessRow["user"].status === "active") {
|
|
26
|
+
const { id, role } = accessRow["user"];
|
|
27
|
+
if (isAdmin) {
|
|
28
|
+
adminUsers.add(id);
|
|
29
|
+
appUsers.delete(id);
|
|
30
|
+
} else if (adminUsers.has(id) === false && (!role || adminRoles.has(role) === false)) {
|
|
31
|
+
appUsers.add(id);
|
|
32
|
+
if (role) {
|
|
33
|
+
const roleUsers = appUsersByRole.get(role) ?? /* @__PURE__ */ new Set();
|
|
34
|
+
appUsersByRole.set(role, roleUsers.add(id));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (accessRow["role"]) if (isAdmin) {
|
|
39
|
+
adminRoles.add(accessRow["role"]);
|
|
40
|
+
for (const id of appUsersByRole.get(accessRow["role"]) ?? []) appUsers.delete(id);
|
|
41
|
+
} else appRoles.add(accessRow["role"]);
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
adminUsers,
|
|
45
|
+
appUsers,
|
|
46
|
+
adminRoles,
|
|
47
|
+
appRoles
|
|
48
|
+
};
|
|
49
|
+
}
|
|
11
50
|
async function getActiveSeats(opts) {
|
|
12
51
|
const knex = opts?.knex ?? database_default();
|
|
13
52
|
const schema = await getSchema({ database: knex });
|
|
14
|
-
const
|
|
53
|
+
const { adminUsers, appUsers, adminRoles, appRoles } = getSeatUsersAndRoles(await new AccessService({
|
|
15
54
|
schema,
|
|
16
55
|
knex
|
|
17
56
|
}).readByQuery({
|
|
@@ -24,22 +63,7 @@ async function getActiveSeats(opts) {
|
|
|
24
63
|
"policy.admin_access"
|
|
25
64
|
],
|
|
26
65
|
limit: -1
|
|
27
|
-
});
|
|
28
|
-
const adminRoles = /* @__PURE__ */ new Set();
|
|
29
|
-
const appRoles = /* @__PURE__ */ new Set();
|
|
30
|
-
const adminUsers = /* @__PURE__ */ new Set();
|
|
31
|
-
const appUsers = /* @__PURE__ */ new Set();
|
|
32
|
-
for (const accessRow of accessRows) {
|
|
33
|
-
const isAdmin = toBoolean(accessRow["policy"]?.["admin_access"]);
|
|
34
|
-
const isApp = !isAdmin && toBoolean(accessRow["policy"]?.["app_access"]);
|
|
35
|
-
if (!isAdmin && !isApp) continue;
|
|
36
|
-
if (accessRow["user"] && accessRow["user"].status === "active") {
|
|
37
|
-
if (isAdmin) adminUsers.add(accessRow["user"].id);
|
|
38
|
-
else if (adminUsers.has(accessRow["user"].id) === false && adminRoles.has(accessRow["user"]?.role) === false) appUsers.add(accessRow["user"].id);
|
|
39
|
-
}
|
|
40
|
-
if (accessRow["role"]) if (isAdmin) adminRoles.add(accessRow["role"]);
|
|
41
|
-
else appRoles.add(accessRow["role"]);
|
|
42
|
-
}
|
|
66
|
+
}));
|
|
43
67
|
const { adminRoles: allAdminRoles, appRoles: allAppRoles } = await fetchAccessRoles({
|
|
44
68
|
adminRoles,
|
|
45
69
|
appRoles
|
|
@@ -100,4 +124,4 @@ async function resolveSeats(seats, ctx) {
|
|
|
100
124
|
}
|
|
101
125
|
|
|
102
126
|
//#endregion
|
|
103
|
-
export { countActiveSeats, getActiveSeats, resolveSeats };
|
|
127
|
+
export { countActiveSeats, getActiveSeats, getSeatUsersAndRoles, resolveSeats };
|
|
@@ -3,6 +3,7 @@ import database_default from "../../../database/index.js";
|
|
|
3
3
|
import { getSchema } from "../../../utils/get-schema.js";
|
|
4
4
|
import { UsersService } from "../../../services/users.js";
|
|
5
5
|
import "../../../services/index.js";
|
|
6
|
+
import { isObject } from "@directus/utils";
|
|
6
7
|
import { USER_INACTIVE_LICENSE_STATUS } from "@directus/constants";
|
|
7
8
|
|
|
8
9
|
//#region src/license/entitlements/lib/sso-enabled.ts
|
|
@@ -33,7 +34,7 @@ async function resolveSSOUsers(resolution, ctx) {
|
|
|
33
34
|
_neq: DEFAULT_AUTH_PROVIDER,
|
|
34
35
|
_nnull: true
|
|
35
36
|
} }, { id: { _neq: adminId } }] } }, { status: USER_INACTIVE_LICENSE_STATUS });
|
|
36
|
-
if (
|
|
37
|
+
if (isObject(resolution) && Object.keys(resolution.admin ?? {}).length) {
|
|
37
38
|
const payload = { provider: DEFAULT_AUTH_PROVIDER };
|
|
38
39
|
if (resolution.admin.email?.length) payload["email"] = resolution.admin.email;
|
|
39
40
|
if (resolution.admin.password?.length) payload["password"] = resolution.admin.password;
|
|
@@ -160,17 +160,17 @@ function injectSystemResolvers(gql, schemaComposer, { CreateCollectionTypes, Rea
|
|
|
160
160
|
schema: gql.schema
|
|
161
161
|
}).serverInfo();
|
|
162
162
|
}, "server_info")
|
|
163
|
-
},
|
|
164
|
-
server_health: {
|
|
165
|
-
type: GraphQLJSON,
|
|
166
|
-
resolve: dedupeResolver(async () => {
|
|
167
|
-
return await new ServerService({
|
|
168
|
-
accountability: gql.accountability,
|
|
169
|
-
schema: gql.schema
|
|
170
|
-
}).health();
|
|
171
|
-
}, "server_health")
|
|
172
163
|
}
|
|
173
164
|
});
|
|
165
|
+
if (toBoolean(env["HEALTHCHECK_ENABLED"]) !== false) schemaComposer.Query.addFields({ server_health: {
|
|
166
|
+
type: GraphQLJSON,
|
|
167
|
+
resolve: dedupeResolver(async () => {
|
|
168
|
+
return await new ServerService({
|
|
169
|
+
accountability: gql.accountability,
|
|
170
|
+
schema: gql.schema
|
|
171
|
+
}).health();
|
|
172
|
+
}, "server_health")
|
|
173
|
+
} });
|
|
174
174
|
if ("directus_collections" in schema.read.collections) {
|
|
175
175
|
const Collection = getCollectionType(schemaComposer, schema, "read");
|
|
176
176
|
schemaComposer.Query.addFields({
|
|
@@ -119,26 +119,26 @@ function getTypes(schemaComposer, scope, schema, inconsistentFields, action) {
|
|
|
119
119
|
CollectionTypes[relation.collection]?.addFields({ [relation.field]: {
|
|
120
120
|
type: CollectionTypes[relation.related_collection],
|
|
121
121
|
resolve: (obj, _, __, info) => {
|
|
122
|
-
return obj[info?.path?.key ?? relation.field];
|
|
122
|
+
return obj[info?.path?.key] ?? obj[info?.fieldName ?? relation.field];
|
|
123
123
|
}
|
|
124
124
|
} });
|
|
125
125
|
VersionTypes[relation.collection]?.addFields({ [relation.field]: {
|
|
126
126
|
type: GraphQLJSON,
|
|
127
127
|
resolve: (obj, _, __, info) => {
|
|
128
|
-
return obj[info?.path?.key ?? relation.field];
|
|
128
|
+
return obj[info?.path?.key] ?? obj[info?.fieldName ?? relation.field];
|
|
129
129
|
}
|
|
130
130
|
} });
|
|
131
131
|
if (relation.meta?.one_field) {
|
|
132
132
|
CollectionTypes[relation.related_collection]?.addFields({ [relation.meta.one_field]: {
|
|
133
133
|
type: [CollectionTypes[relation.collection]],
|
|
134
134
|
resolve: (obj, _, __, info) => {
|
|
135
|
-
return obj[info?.path?.key ?? relation.meta.one_field];
|
|
135
|
+
return obj[info?.path?.key] ?? obj[info?.fieldName ?? relation.meta.one_field];
|
|
136
136
|
}
|
|
137
137
|
} });
|
|
138
138
|
if (scope === "items") VersionTypes[relation.related_collection]?.addFields({ [relation.meta.one_field]: {
|
|
139
139
|
type: GraphQLJSON,
|
|
140
140
|
resolve: (obj, _, __, info) => {
|
|
141
|
-
return obj[info?.path?.key ?? relation.meta.one_field];
|
|
141
|
+
return obj[info?.path?.key] ?? obj[info?.fieldName ?? relation.meta.one_field];
|
|
142
142
|
}
|
|
143
143
|
} });
|
|
144
144
|
}
|
|
@@ -160,7 +160,7 @@ function getTypes(schemaComposer, scope, schema, inconsistentFields, action) {
|
|
|
160
160
|
}
|
|
161
161
|
}),
|
|
162
162
|
resolve: (obj, _, __, info) => {
|
|
163
|
-
return obj[info?.path?.key ?? relation.field];
|
|
163
|
+
return obj[info?.path?.key] ?? obj[info?.fieldName ?? relation.field];
|
|
164
164
|
}
|
|
165
165
|
} });
|
|
166
166
|
return {
|
|
@@ -41,13 +41,13 @@ var PermissionsService = class extends ItemsService {
|
|
|
41
41
|
return withAppMinimalPermissions(this.accountability, mappedPermissions, query.filter);
|
|
42
42
|
}
|
|
43
43
|
async createOne(data, opts) {
|
|
44
|
-
if (hasCustomRule(data) && !isRecommendedAppPermission(data))
|
|
44
|
+
if (!getEntitlementManager().isEntitled("custom_permission_rules_enabled") && hasCustomRule(data) && !isRecommendedAppPermission(data)) throw new ResourceRestrictedError({ category: "custom_permission_rules_enabled" });
|
|
45
45
|
const res = await super.createOne(data, opts);
|
|
46
46
|
await this.clearCaches(opts);
|
|
47
47
|
return res;
|
|
48
48
|
}
|
|
49
49
|
async updateMany(keys, data, opts) {
|
|
50
|
-
if (hasCustomRule(data) && !isRecommendedAppPermission(data))
|
|
50
|
+
if (!getEntitlementManager().isEntitled("custom_permission_rules_enabled") && hasCustomRule(data) && !isRecommendedAppPermission(data)) throw new ResourceRestrictedError({ category: "custom_permission_rules_enabled" });
|
|
51
51
|
const res = await super.updateMany(keys, data, opts);
|
|
52
52
|
await this.clearCaches(opts);
|
|
53
53
|
return res;
|
package/dist/services/server.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
|
+
import { useRedis } from "../redis/lib/use-redis.js";
|
|
2
|
+
import { redisConfigAvailable } from "../redis/utils/redis-config-available.js";
|
|
3
|
+
import "../redis/index.js";
|
|
1
4
|
import { useLogger } from "../logger/index.js";
|
|
5
|
+
import { getMilliseconds } from "../utils/get-milliseconds.js";
|
|
2
6
|
import { FILE_UPLOADS, RESUMABLE_UPLOADS } from "../constants.js";
|
|
3
|
-
import { getCache } from "../cache.js";
|
|
4
7
|
import { getStorage } from "../storage/index.js";
|
|
5
8
|
import database_default, { hasDatabaseConnection } from "../database/index.js";
|
|
9
|
+
import { useStore } from "../utils/store.js";
|
|
6
10
|
import getMailer from "../mailer.js";
|
|
7
11
|
import { getEntitlementManager } from "../license/entitlements/manager.js";
|
|
8
12
|
import { SettingsService } from "./settings.js";
|
|
9
|
-
import { rateLimiterGlobal } from "../middleware/rate-limiter-global.js";
|
|
10
|
-
import { rateLimiter } from "../middleware/rate-limiter-ip.js";
|
|
11
13
|
import { getAllowedLogLevels } from "../utils/get-allowed-log-levels.js";
|
|
12
14
|
import { SERVER_ONLINE } from "../server.js";
|
|
15
|
+
import { isUnauthenticated } from "../utils/is-unauthenticated.js";
|
|
13
16
|
import { getLicenseManager } from "../license/manager.js";
|
|
14
17
|
import "../license/index.js";
|
|
15
18
|
import { useEnv } from "@directus/env";
|
|
19
|
+
import { ForbiddenError } from "@directus/errors";
|
|
16
20
|
import { toArray, toBoolean } from "@directus/utils";
|
|
17
21
|
import { merge } from "lodash-es";
|
|
22
|
+
import { createKv } from "@directus/memory";
|
|
18
23
|
import { performance } from "perf_hooks";
|
|
19
24
|
import { Readable } from "node:stream";
|
|
20
25
|
import { version } from "directus/version";
|
|
@@ -22,6 +27,8 @@ import { version } from "directus/version";
|
|
|
22
27
|
//#region src/services/server.ts
|
|
23
28
|
const env = useEnv();
|
|
24
29
|
const logger = useLogger();
|
|
30
|
+
const HEALTHCHECK_CACHE_TTL = getMilliseconds(env["HEALTHCHECK_CACHE_TTL"], 3e5);
|
|
31
|
+
const store = useStore(env["HEALTHCHECK_NAMESPACE"] ?? "directus:healthcheck", { ttl: HEALTHCHECK_CACHE_TTL });
|
|
25
32
|
var ServerService = class {
|
|
26
33
|
knex;
|
|
27
34
|
accountability;
|
|
@@ -121,17 +128,25 @@ var ServerService = class {
|
|
|
121
128
|
return info;
|
|
122
129
|
}
|
|
123
130
|
async health() {
|
|
131
|
+
if (isUnauthenticated(this.accountability)) throw new ForbiddenError();
|
|
132
|
+
const healthResult = await store(async (store$1) => {
|
|
133
|
+
try {
|
|
134
|
+
return await store$1.get("health");
|
|
135
|
+
} catch (err) {
|
|
136
|
+
logger.warn(err, "Failed to read health check cache");
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
if (healthResult) return this.accountability?.admin === true ? healthResult : { status: healthResult["status"] };
|
|
124
140
|
const { nanoid } = await import("nanoid");
|
|
125
141
|
const checkID = nanoid(5);
|
|
142
|
+
const enabledServices = toArray(env["HEALTHCHECK_SERVICES"]);
|
|
126
143
|
const data = {
|
|
127
144
|
status: "ok",
|
|
128
145
|
releaseId: version,
|
|
129
146
|
serviceId: env["PUBLIC_URL"],
|
|
130
147
|
checks: merge(...await Promise.all([
|
|
131
148
|
testDatabase(),
|
|
132
|
-
|
|
133
|
-
testRateLimiter(),
|
|
134
|
-
testRateLimiterGlobal(),
|
|
149
|
+
testRedis(),
|
|
135
150
|
testStorage(),
|
|
136
151
|
testEmail()
|
|
137
152
|
]))
|
|
@@ -152,9 +167,14 @@ var ServerService = class {
|
|
|
152
167
|
}
|
|
153
168
|
if (data.status === "error") break;
|
|
154
169
|
}
|
|
155
|
-
|
|
156
|
-
|
|
170
|
+
await store(async (store$1) => {
|
|
171
|
+
await store$1.set("health", data).catch((err) => {
|
|
172
|
+
logger.warn(err, "Failed to write health check cache");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
return this.accountability?.admin === true ? data : { status: data.status };
|
|
157
176
|
async function testDatabase() {
|
|
177
|
+
if (enabledServices.includes("database") === false) return {};
|
|
158
178
|
const database = database_default();
|
|
159
179
|
const client = env["DB_CLIENT"];
|
|
160
180
|
const checks = {};
|
|
@@ -186,10 +206,15 @@ var ServerService = class {
|
|
|
186
206
|
}];
|
|
187
207
|
return checks;
|
|
188
208
|
}
|
|
189
|
-
async function
|
|
190
|
-
if (
|
|
191
|
-
const
|
|
192
|
-
|
|
209
|
+
async function testRedis() {
|
|
210
|
+
if (enabledServices.includes("redis") === false || redisConfigAvailable() !== true) return {};
|
|
211
|
+
const redis = createKv({
|
|
212
|
+
type: "redis",
|
|
213
|
+
redis: useRedis(),
|
|
214
|
+
namespace: env["HEALTHCHECK_NAMESPACE"] ?? "directus:healthcheck",
|
|
215
|
+
ttl: HEALTHCHECK_CACHE_TTL
|
|
216
|
+
});
|
|
217
|
+
const checks = { "redis:responseTime": [{
|
|
193
218
|
status: "ok",
|
|
194
219
|
componentType: "cache",
|
|
195
220
|
observedValue: 0,
|
|
@@ -198,65 +223,20 @@ var ServerService = class {
|
|
|
198
223
|
}] };
|
|
199
224
|
const startTime = performance.now();
|
|
200
225
|
try {
|
|
201
|
-
await
|
|
202
|
-
await
|
|
203
|
-
} catch (err) {
|
|
204
|
-
checks["cache:responseTime"][0].status = "error";
|
|
205
|
-
checks["cache:responseTime"][0].output = err;
|
|
206
|
-
} finally {
|
|
207
|
-
const endTime = performance.now();
|
|
208
|
-
checks["cache:responseTime"][0].observedValue = +(endTime - startTime).toFixed(3);
|
|
209
|
-
if (checks["cache:responseTime"][0].observedValue > checks["cache:responseTime"][0].threshold && checks["cache:responseTime"][0].status !== "error") checks["cache:responseTime"][0].status = "warn";
|
|
210
|
-
}
|
|
211
|
-
return checks;
|
|
212
|
-
}
|
|
213
|
-
async function testRateLimiter() {
|
|
214
|
-
if (env["RATE_LIMITER_ENABLED"] !== true) return {};
|
|
215
|
-
const checks = { "rateLimiter:responseTime": [{
|
|
216
|
-
status: "ok",
|
|
217
|
-
componentType: "ratelimiter",
|
|
218
|
-
observedValue: 0,
|
|
219
|
-
observedUnit: "ms",
|
|
220
|
-
threshold: env["RATE_LIMITER_HEALTHCHECK_THRESHOLD"] ? +env["RATE_LIMITER_HEALTHCHECK_THRESHOLD"] : 150
|
|
221
|
-
}] };
|
|
222
|
-
const startTime = performance.now();
|
|
223
|
-
try {
|
|
224
|
-
await rateLimiter.consume(`directus-health-${checkID}`, 1);
|
|
225
|
-
await rateLimiter.delete(`directus-health-${checkID}`);
|
|
226
|
-
} catch (err) {
|
|
227
|
-
checks["rateLimiter:responseTime"][0].status = "error";
|
|
228
|
-
checks["rateLimiter:responseTime"][0].output = err;
|
|
229
|
-
} finally {
|
|
230
|
-
const endTime = performance.now();
|
|
231
|
-
checks["rateLimiter:responseTime"][0].observedValue = +(endTime - startTime).toFixed(3);
|
|
232
|
-
if (checks["rateLimiter:responseTime"][0].observedValue > checks["rateLimiter:responseTime"][0].threshold && checks["rateLimiter:responseTime"][0].status !== "error") checks["rateLimiter:responseTime"][0].status = "warn";
|
|
233
|
-
}
|
|
234
|
-
return checks;
|
|
235
|
-
}
|
|
236
|
-
async function testRateLimiterGlobal() {
|
|
237
|
-
if (env["RATE_LIMITER_GLOBAL_ENABLED"] !== true) return {};
|
|
238
|
-
const checks = { "rateLimiterGlobal:responseTime": [{
|
|
239
|
-
status: "ok",
|
|
240
|
-
componentType: "ratelimiter",
|
|
241
|
-
observedValue: 0,
|
|
242
|
-
observedUnit: "ms",
|
|
243
|
-
threshold: env["RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD"] ? +env["RATE_LIMITER_GLOBAL_HEALTHCHECK_THRESHOLD"] : 150
|
|
244
|
-
}] };
|
|
245
|
-
const startTime = performance.now();
|
|
246
|
-
try {
|
|
247
|
-
await rateLimiterGlobal.consume(`directus-health-${checkID}`, 1);
|
|
248
|
-
await rateLimiterGlobal.delete(`directus-health-${checkID}`);
|
|
226
|
+
await redis.set(`directus-health-${checkID}`, 1);
|
|
227
|
+
await redis.delete(`directus-health-${checkID}`);
|
|
249
228
|
} catch (err) {
|
|
250
|
-
checks["
|
|
251
|
-
checks["
|
|
229
|
+
checks["redis:responseTime"][0].status = "error";
|
|
230
|
+
checks["redis:responseTime"][0].output = err;
|
|
252
231
|
} finally {
|
|
253
232
|
const endTime = performance.now();
|
|
254
|
-
checks["
|
|
255
|
-
if (checks["
|
|
233
|
+
checks["redis:responseTime"][0].observedValue = +(endTime - startTime).toFixed(3);
|
|
234
|
+
if (checks["redis:responseTime"][0].observedValue > checks["redis:responseTime"][0].threshold && checks["redis:responseTime"][0].status !== "error") checks["redis:responseTime"][0].status = "warn";
|
|
256
235
|
}
|
|
257
236
|
return checks;
|
|
258
237
|
}
|
|
259
238
|
async function testStorage() {
|
|
239
|
+
if (enabledServices.includes("storage") === false) return {};
|
|
260
240
|
const storage = await getStorage();
|
|
261
241
|
const checks = {};
|
|
262
242
|
for (const location of toArray(env["STORAGE_LOCATIONS"])) {
|
|
@@ -284,6 +264,7 @@ var ServerService = class {
|
|
|
284
264
|
return checks;
|
|
285
265
|
}
|
|
286
266
|
async function testEmail() {
|
|
267
|
+
if (enabledServices.includes("email") === false || toBoolean(env["EMAIL_VERIFY_SETUP"]) === false) return {};
|
|
287
268
|
const checks = { "email:connection": [{
|
|
288
269
|
status: "ok",
|
|
289
270
|
componentType: "email"
|
package/dist/services/users.js
CHANGED
|
@@ -117,6 +117,12 @@ var UsersService = class UsersService extends ItemsService {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
/**
|
|
120
|
+
* Block setting a non-default auth provider when the current license isn't entitled to SSO
|
|
121
|
+
*/
|
|
122
|
+
checkProviderEntitlement(input) {
|
|
123
|
+
if ((Array.isArray(input) ? input : [input]).some((provider) => provider && provider !== DEFAULT_AUTH_PROVIDER) && !getEntitlementManager().isEntitled("sso_enabled")) throw new InvalidPayloadError({ reason: `Setting a custom "provider" isn't included in the current license` });
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
120
126
|
* Create a new user
|
|
121
127
|
*/
|
|
122
128
|
async createOne(data, opts = {}) {
|
|
@@ -126,6 +132,7 @@ var UsersService = class UsersService extends ItemsService {
|
|
|
126
132
|
await this.checkUniqueEmails([data["email"]]);
|
|
127
133
|
}
|
|
128
134
|
if ("password" in data) await this.checkPasswordPolicy([data["password"]]);
|
|
135
|
+
if ("provider" in data) this.checkProviderEntitlement(data["provider"]);
|
|
129
136
|
} catch (err) {
|
|
130
137
|
opts.preMutationError = err;
|
|
131
138
|
}
|
|
@@ -141,6 +148,7 @@ var UsersService = class UsersService extends ItemsService {
|
|
|
141
148
|
async createMany(data, opts = {}) {
|
|
142
149
|
const emails = data.map((payload) => payload["email"]).filter((email) => email);
|
|
143
150
|
const passwords = data.map((payload) => payload["password"]).filter((password) => password);
|
|
151
|
+
const providers = data.map((payload) => payload["provider"]).filter((provider) => provider);
|
|
144
152
|
const someActive = data.some((payload) => !("status" in payload) || payload["status"] === "active");
|
|
145
153
|
try {
|
|
146
154
|
if (emails.length) {
|
|
@@ -148,6 +156,7 @@ var UsersService = class UsersService extends ItemsService {
|
|
|
148
156
|
await this.checkUniqueEmails(emails);
|
|
149
157
|
}
|
|
150
158
|
if (passwords.length) await this.checkPasswordPolicy(passwords);
|
|
159
|
+
if (providers.length) this.checkProviderEntitlement(providers);
|
|
151
160
|
} catch (err) {
|
|
152
161
|
opts.preMutationError = err;
|
|
153
162
|
}
|
|
@@ -179,6 +188,7 @@ var UsersService = class UsersService extends ItemsService {
|
|
|
179
188
|
if (data["tfa_secret"] !== void 0) throw new InvalidPayloadError({ reason: `You can't change the "tfa_secret" value manually` });
|
|
180
189
|
if (data["provider"] !== void 0) {
|
|
181
190
|
if (this.accountability && this.accountability.admin !== true) throw new InvalidPayloadError({ reason: `You can't change the "provider" value manually` });
|
|
191
|
+
this.checkProviderEntitlement(data["provider"]);
|
|
182
192
|
data["auth_data"] = null;
|
|
183
193
|
}
|
|
184
194
|
if (data["external_identifier"] !== void 0) {
|
|
@@ -55,7 +55,9 @@ var VersionsService = class VersionsService extends ItemsService {
|
|
|
55
55
|
knex: this.knex,
|
|
56
56
|
schema: this.schema
|
|
57
57
|
}).readOne(data["collection"])).meta?.versioning) throw new UnprocessableContentError({ reason: `Content Versioning is not enabled for collection "${data["collection"]}"` });
|
|
58
|
-
|
|
58
|
+
const isSingleton = !!this.schema.collections[data["collection"]]?.singleton;
|
|
59
|
+
if (itemLess) if (isSingleton) await this.assertSingletonEmpty(data["collection"]);
|
|
60
|
+
else return;
|
|
59
61
|
if ((await new VersionsService({
|
|
60
62
|
knex: this.knex,
|
|
61
63
|
schema: this.schema
|
|
@@ -64,9 +66,9 @@ var VersionsService = class VersionsService extends ItemsService {
|
|
|
64
66
|
filter: {
|
|
65
67
|
key: { _eq: data["key"] },
|
|
66
68
|
collection: { _eq: data["collection"] },
|
|
67
|
-
item: { _eq: data["item"] }
|
|
69
|
+
item: itemLess ? { _null: true } : { _eq: data["item"] }
|
|
68
70
|
}
|
|
69
|
-
}))[0]["count"] > 0) throw new UnprocessableContentError({ reason: `Version "${data["key"]}" already exists for item "${data["item"]}" in collection "${data["collection"]}"` });
|
|
71
|
+
}))[0]["count"] > 0) throw new UnprocessableContentError({ reason: itemLess ? `Singleton collection "${data["collection"]}" already has an item-less version` : `Version "${data["key"]}" already exists for item "${data["item"]}" in collection "${data["collection"]}"` });
|
|
70
72
|
}
|
|
71
73
|
async getMainItem(collection, item, query) {
|
|
72
74
|
return await new ItemsService(collection, {
|
|
@@ -114,6 +116,7 @@ var VersionsService = class VersionsService extends ItemsService {
|
|
|
114
116
|
if (!Array.isArray(data)) throw new InvalidPayloadError({ reason: "Input should be an array of items" });
|
|
115
117
|
const keyCombos = /* @__PURE__ */ new Set();
|
|
116
118
|
for (const item of data) {
|
|
119
|
+
if (isNil(item["item"])) continue;
|
|
117
120
|
const keyCombo = `${item["key"]}-${item["collection"]}-${item["item"]}`;
|
|
118
121
|
if (keyCombos.has(keyCombo)) throw new UnprocessableContentError({ reason: `Cannot create multiple versions on "${item["item"]}" in collection "${item["collection"]}" with the same key "${item["key"]}"` });
|
|
119
122
|
keyCombos.add(keyCombo);
|
|
@@ -139,7 +142,20 @@ var VersionsService = class VersionsService extends ItemsService {
|
|
|
139
142
|
const item = "item" in data ? data["item"] : existingVersion.item;
|
|
140
143
|
const key = "key" in data ? data["key"] : existingVersion.key;
|
|
141
144
|
if (key !== VERSION_KEY_DRAFT && item === null) throw new InvalidPayloadError({ reason: `"key" must be "${VERSION_KEY_DRAFT}" for versions not linked to an item` });
|
|
142
|
-
if (item === null)
|
|
145
|
+
if (item === null) {
|
|
146
|
+
if (this.schema.collections[collection]?.singleton) {
|
|
147
|
+
await this.assertSingletonEmpty(collection);
|
|
148
|
+
if ((await super.readByQuery({
|
|
149
|
+
aggregate: { count: ["*"] },
|
|
150
|
+
filter: {
|
|
151
|
+
id: { _neq: pk },
|
|
152
|
+
collection: { _eq: collection },
|
|
153
|
+
item: { _null: true }
|
|
154
|
+
}
|
|
155
|
+
}))[0]["count"] > 0) throw new UnprocessableContentError({ reason: `Singleton collection "${collection}" already has an item-less version` });
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
143
159
|
const keyCombo = `${key}-${collection}-${item}`;
|
|
144
160
|
if (keyCombos.has(keyCombo)) throw new UnprocessableContentError({ reason: `Cannot update multiple versions on "${item}" in collection "${collection}" to the same key "${key}"` });
|
|
145
161
|
keyCombos.add(keyCombo);
|
|
@@ -282,6 +298,7 @@ var VersionsService = class VersionsService extends ItemsService {
|
|
|
282
298
|
let updatedItemKey;
|
|
283
299
|
if (item) updatedItemKey = await itemsService.updateOne(item, payloadAfterHooks, { overwriteDefaults: defaultOverwrites });
|
|
284
300
|
else {
|
|
301
|
+
await this.assertSingletonEmpty(collection);
|
|
285
302
|
updatedItemKey = await itemsService.createOne(payloadAfterHooks, { overwriteDefaults: defaultOverwrites });
|
|
286
303
|
await this.updateOne(version, { item: String(updatedItemKey) });
|
|
287
304
|
}
|
|
@@ -297,6 +314,11 @@ var VersionsService = class VersionsService extends ItemsService {
|
|
|
297
314
|
});
|
|
298
315
|
return updatedItemKey;
|
|
299
316
|
}
|
|
317
|
+
async assertSingletonEmpty(collection) {
|
|
318
|
+
const collectionMeta = this.schema.collections[collection];
|
|
319
|
+
if (!collectionMeta?.singleton) return;
|
|
320
|
+
if (await this.knex(collection).first(collectionMeta.primary)) throw new UnprocessableContentError({ reason: `Singleton collection "${collection}" already contains an item` });
|
|
321
|
+
}
|
|
300
322
|
mapDelta(version) {
|
|
301
323
|
const delta = version.delta ?? {};
|
|
302
324
|
delta[this.schema.collections[version.collection].primary] = version.item;
|
|
@@ -27,6 +27,9 @@ const defaultAdminPolicy = {
|
|
|
27
27
|
async function createAdmin(schema, admin) {
|
|
28
28
|
const logger = useLogger();
|
|
29
29
|
const env = useEnv();
|
|
30
|
+
const adminEmail = admin?.email ?? env["ADMIN_EMAIL"];
|
|
31
|
+
const adminPassword = admin?.password ?? env["ADMIN_PASSWORD"];
|
|
32
|
+
if (!adminEmail || !adminPassword) return;
|
|
30
33
|
logger.info("Setting up first admin role...");
|
|
31
34
|
const accessService = new AccessService({ schema });
|
|
32
35
|
const policiesService = new PoliciesService({ schema });
|
|
@@ -37,9 +40,6 @@ async function createAdmin(schema, admin) {
|
|
|
37
40
|
role
|
|
38
41
|
});
|
|
39
42
|
const usersService = new UsersService({ schema });
|
|
40
|
-
const adminEmail = admin?.email ?? env["ADMIN_EMAIL"];
|
|
41
|
-
const adminPassword = admin?.password ?? env["ADMIN_PASSWORD"];
|
|
42
|
-
if (!adminEmail || !adminPassword) return;
|
|
43
43
|
const token = env["ADMIN_TOKEN"] ?? null;
|
|
44
44
|
logger.info("Adding first admin user...");
|
|
45
45
|
await usersService.createOne({
|
|
@@ -34,6 +34,7 @@ async function getCacheKey(req) {
|
|
|
34
34
|
path,
|
|
35
35
|
query: isGraphQl ? getGraphqlQueryAndVariables(req) : req.sanitizedQuery,
|
|
36
36
|
...flowTriggerQuery && { rawQuery: flowTriggerQuery },
|
|
37
|
+
...req.accountability?.share && { share: req.accountability.share },
|
|
37
38
|
...includeIp && { ip: req.accountability.ip }
|
|
38
39
|
});
|
|
39
40
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region src/utils/is-unauthenticated.ts
|
|
2
|
+
/**
|
|
3
|
+
* Checks if the given accountability is unauthenticated
|
|
4
|
+
*
|
|
5
|
+
* @param accountability
|
|
6
|
+
* @returns True if the user is unauthenticated, false otherwise.
|
|
7
|
+
*/
|
|
8
|
+
function isUnauthenticated(accountability) {
|
|
9
|
+
if (accountability === null) return false;
|
|
10
|
+
if (accountability === void 0) return true;
|
|
11
|
+
return accountability?.role === null && accountability?.user === null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { isUnauthenticated };
|
|
@@ -143,7 +143,7 @@ async function sanitizeDeep(deep, schema, accountability) {
|
|
|
143
143
|
for (const [key, value] of Object.entries(level)) {
|
|
144
144
|
if (!key) break;
|
|
145
145
|
if (key.startsWith("_")) subQuery[key.substring(1)] = value;
|
|
146
|
-
else if (isPlainObject(value)) parse(value, [...path, key]);
|
|
146
|
+
else if (isPlainObject(value)) await parse(value, [...path, key]);
|
|
147
147
|
}
|
|
148
148
|
if (Object.keys(subQuery).length > 0) {
|
|
149
149
|
const parsedSubQuery = await sanitizeQuery(subQuery, schema, accountability);
|
package/dist/utils/store.js
CHANGED
|
@@ -13,7 +13,7 @@ function useStore(namespace, options) {
|
|
|
13
13
|
namespace,
|
|
14
14
|
redis: useRedis()
|
|
15
15
|
};
|
|
16
|
-
if (
|
|
16
|
+
if (options?.ttl) config.ttl = options?.ttl;
|
|
17
17
|
const store = createCache(config);
|
|
18
18
|
return (callback) => store.usingLock(`lock`, async () => {
|
|
19
19
|
return await callback({
|
|
@@ -144,7 +144,8 @@ async function handleVersion(self, key, query, opts) {
|
|
|
144
144
|
return result;
|
|
145
145
|
});
|
|
146
146
|
const env = useEnv();
|
|
147
|
-
|
|
147
|
+
const effectiveLimit = query.limit ?? Number(env["QUERY_LIMIT_DEFAULT"]);
|
|
148
|
+
if (effectiveLimit === -1 || results.length < effectiveLimit) results.push(...itemlessErrors.map((errorMeta) => {
|
|
148
149
|
let item = { $meta: errorMeta };
|
|
149
150
|
if (errorMeta.error) item = Object.assign({}, defaultItem, item, pick(errorMeta.delta, requestedFields));
|
|
150
151
|
return item;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directus/api",
|
|
3
|
-
"version": "36.0.0
|
|
3
|
+
"version": "36.0.0",
|
|
4
4
|
"description": "Directus is a real-time API and App dashboard for managing SQL database content",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"directus",
|
|
@@ -166,30 +166,30 @@
|
|
|
166
166
|
"ws": "8.18.3",
|
|
167
167
|
"zod": "4.1.12",
|
|
168
168
|
"zod-validation-error": "4.0.2",
|
|
169
|
-
"@directus/app": "16.0.0
|
|
170
|
-
"@directus/
|
|
171
|
-
"@directus/
|
|
172
|
-
"@directus/
|
|
173
|
-
"@directus/
|
|
174
|
-
"@directus/extensions-registry": "4.0.0
|
|
175
|
-
"@directus/
|
|
176
|
-
"@directus/
|
|
177
|
-
"@directus/memory": "4.0.0
|
|
178
|
-
"@directus/
|
|
179
|
-
"@directus/
|
|
180
|
-
"@directus/
|
|
181
|
-
"@directus/storage": "13.0.0
|
|
182
|
-
"@directus/
|
|
183
|
-
"@directus/storage-driver-
|
|
184
|
-
"@directus/storage-driver-
|
|
185
|
-
"@directus/storage-driver-
|
|
186
|
-
"@directus/
|
|
187
|
-
"@directus/storage-driver-s3": "13.0.0
|
|
188
|
-
"@directus/
|
|
189
|
-
"@directus/
|
|
190
|
-
"@directus/
|
|
191
|
-
"@directus/validation": "3.0.0
|
|
192
|
-
"directus": "12.0.0
|
|
169
|
+
"@directus/app": "16.0.0",
|
|
170
|
+
"@directus/ai": "1.3.2",
|
|
171
|
+
"@directus/env": "6.0.0",
|
|
172
|
+
"@directus/constants": "14.4.0",
|
|
173
|
+
"@directus/errors": "2.4.0",
|
|
174
|
+
"@directus/extensions-registry": "4.0.0",
|
|
175
|
+
"@directus/extensions": "4.0.0",
|
|
176
|
+
"@directus/format-title": "13.0.0",
|
|
177
|
+
"@directus/memory": "4.0.0",
|
|
178
|
+
"@directus/extensions-sdk": "18.0.0",
|
|
179
|
+
"@directus/schema": "14.0.0",
|
|
180
|
+
"@directus/pressure": "4.0.0",
|
|
181
|
+
"@directus/storage": "13.0.0",
|
|
182
|
+
"@directus/storage-driver-azure": "13.0.0",
|
|
183
|
+
"@directus/storage-driver-local": "13.0.0",
|
|
184
|
+
"@directus/storage-driver-gcs": "13.0.0",
|
|
185
|
+
"@directus/storage-driver-cloudinary": "13.0.0",
|
|
186
|
+
"@directus/specs": "14.0.0",
|
|
187
|
+
"@directus/storage-driver-s3": "13.0.0",
|
|
188
|
+
"@directus/system-data": "4.5.0",
|
|
189
|
+
"@directus/storage-driver-supabase": "4.0.0",
|
|
190
|
+
"@directus/utils": "13.5.0",
|
|
191
|
+
"@directus/validation": "3.0.0",
|
|
192
|
+
"directus": "12.0.0"
|
|
193
193
|
},
|
|
194
194
|
"devDependencies": {
|
|
195
195
|
"@directus/tsconfig": "4.0.0",
|
|
@@ -224,15 +224,15 @@
|
|
|
224
224
|
"@types/stream-json": "1.7.8",
|
|
225
225
|
"@types/wellknown": "0.5.8",
|
|
226
226
|
"@types/ws": "8.18.1",
|
|
227
|
-
"@vitest/coverage-v8": "3.2.
|
|
227
|
+
"@vitest/coverage-v8": "3.2.6",
|
|
228
228
|
"copyfiles": "2.4.1",
|
|
229
229
|
"form-data": "4.0.4",
|
|
230
230
|
"get-port": "7.1.0",
|
|
231
231
|
"knex-mock-client": "3.0.2",
|
|
232
232
|
"typescript": "5.9.3",
|
|
233
|
-
"vitest": "3.2.
|
|
234
|
-
"@directus/schema-builder": "1.0.0
|
|
235
|
-
"@directus/types": "16.0.0
|
|
233
|
+
"vitest": "3.2.6",
|
|
234
|
+
"@directus/schema-builder": "1.0.0",
|
|
235
|
+
"@directus/types": "16.0.0"
|
|
236
236
|
},
|
|
237
237
|
"optionalDependencies": {
|
|
238
238
|
"@keyv/redis": "3.0.1",
|