Compare commits

..
Author SHA1 Message Date
Philipinho 83faeefdbc fix(ai): pass AI_VECTOR_DRIVER through the client build config 2026-08-17 20:33:32 +01:00
Philipinho 7b8ade0968 feat(ai): pre-warm the vector namespace 2026-08-17 20:09:53 +01:00
Philipinho 8c4ecfaaf2 sync 2026-08-17 18:58:40 +01:00
Philipinho adbe671656 fix(ai): retry the page moved-to-space vector patch job 2026-08-17 18:52:33 +01:00
Philipinho e0d0fb15c9 fix(ai): filter search hits by the page's current space at query time 2026-08-17 18:50:21 +01:00
Philipinho 66fd84e893 fix(ai): store real embedding dimensions instead of serialized vector length 2026-08-17 18:31:50 +01:00
Philipinho 6c9836925c sync 2026-08-17 18:21:08 +01:00
Philipinho cb373c9c05 fix(ai): skip full re-embed when ai search is re-enabled within the delete grace window 2026-08-17 15:02:21 +01:00
Philipinho 38fddf9b5d fix(ai): collapse blank-line runs in extracted page text 2026-08-17 14:42:43 +01:00
Philipinho 7a2411e147 fix(ai): harden turbopuffer misconfiguration and reset failure paths 2026-08-17 01:48:54 +01:00
Philipinho 157e847550 feat(ai): warm the vector namespace cache on session start 2026-08-17 01:17:31 +01:00
Philipinho 192aadfbb4 feat(ai): rebuild turbopuffer namespaces when the embedding model changes 2026-08-17 01:06:38 +01:00
Philipinho c0e4f4cfb3 feat(ai): add turbopuffer vector driver 2026-08-17 00:53:12 +01:00
Philipinho 708e08037f refactor(ai): route vector reads and writes through the vector driver 2026-08-17 00:44:53 +01:00
Philipinho 7003c392e0 feat(ai): add pgvector driver behind the vector driver interface 2026-08-17 00:03:58 +01:00
Philipinho 738120172d feat(ai): add vector driver interface and turbopuffer request helpers 2026-08-16 23:57:48 +01:00
Philipinho d5ad7d5181 feat(ai): carry workspace and target space ids on vector lifecycle events 2026-08-16 23:37:38 +01:00
Philipinho fc34f1a7f5 feat(ai): add AI_VECTOR_DRIVER and turbopuffer configuration 2026-08-16 23:34:21 +01:00
62 changed files with 241 additions and 1763 deletions
@@ -706,8 +706,9 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.", "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.", "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"MCP Server URL": "MCP Server URL", "MCP Server URL": "MCP Server URL",
"Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.": "Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.", "Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.",
"Supported tools": "Supported tools", "Supported tools": "Supported tools",
"Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
"MCP server URL:": "MCP server URL:", "MCP server URL:": "MCP server URL:",
"Learn more": "Learn more", "Learn more": "Learn more",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.", "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
@@ -1301,36 +1302,5 @@
"Error loading attachments.": "Error loading attachments.", "Error loading attachments.": "Error loading attachments.",
"No attachments on this page yet.": "No attachments on this page yet.", "No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}", "Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}", "Download {{name}}": "Download {{name}}"
"Access revoked": "Access revoked",
"Authorize application": "Authorize application",
"{{name}} wants to access {{workspace}}": "{{name}} wants to access {{workspace}}",
"Not you? Switch account": "Not you? Switch account",
"This application will be able to:": "This application will be able to:",
"Write": "Write",
"Invalid authorization request": "Invalid authorization request",
"Authorize": "Authorize",
"Application": "Application",
"Permissions": "Permissions",
"Authorized": "Authorized",
"Revoke access": "Revoke access",
"Revoke access for {{name}}": "Revoke access for {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
"Something went wrong. Please try again.": "Something went wrong. Please try again.",
"Trusted applications": "Trusted applications",
"Remove {{name}}": "Remove {{name}}",
"This origin is already trusted.": "This origin is already trusted.",
"Trusted application name": "Trusted application name",
"Trusted application origin": "Trusted application origin",
"I recognize this application and want to continue": "I recognize this application and want to continue",
"You will be redirected to": "You will be redirected to",
"View content without making changes.": "View content without making changes.",
"Create and modify content.": "Create and modify content.",
"This application is not on your workspace's trusted list. Authorize only if you recognize it.": "This application is not on your workspace's trusted list. Authorize only if you recognize it.",
"Applications with these callback origins are trusted. Members will not see a warning when authorizing them.": "Applications with these callback origins are trusted. Members will not see a warning when authorizing them.",
"Enter the app's callback origin, e.g. https://app.yourcompany.com": "Enter the app's callback origin, e.g. https://app.yourcompany.com",
"Applications and AI assistants you have authorized to access your account.": "Applications and AI assistants you have authorized to access your account.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.",
"Authorized apps": "Authorized apps",
"No authorized apps yet.": "No authorized apps yet."
} }
-6
View File
@@ -47,7 +47,6 @@ import FavoritesPage from "@/pages/favorites/favorites-page";
import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx"; import VerifyEmail from "@/ee/pages/verify-email.tsx";
import LabelPage from "@/pages/label/label-page"; import LabelPage from "@/pages/label/label-page";
import OAuthConsent from "@/ee/oauth/pages/oauth-consent.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -64,7 +63,6 @@ export default function App() {
<Route path={"/password-reset"} element={<PasswordReset />} /> <Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} /> <Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} /> <Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
<Route path={"/oauth/consent"} element={<OAuthConsent />} />
{!isCloud() && ( {!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} /> <Route path={"/setup/register"} element={<SetupWorkspace />} />
@@ -118,10 +116,6 @@ export default function App() {
element={<AccountPreferences />} element={<AccountPreferences />}
/> />
<Route path={"account/api-keys"} element={<UserApiKeys />} /> <Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route
path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />}
/>
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} /> <Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@@ -1,9 +1,7 @@
import { import {
Anchor, Anchor,
Button,
Group, Group,
List, List,
Table,
Text, Text,
Switch, Switch,
TextInput, TextInput,
@@ -17,33 +15,14 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react"; import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { ITrustedOAuthClient } from "@/features/workspace/types/workspace.types.ts";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getAppUrl } from "@/lib/config.ts"; import { getAppUrl } from "@/lib/config.ts";
import { import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
IconCheck,
IconCopy,
IconInfoCircle,
IconTrash,
} from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx"; import { CopyButton } from "@/components/common/copy-button.tsx";
// Mirrors the server rule: an exact https origin, tolerating only a trailing slash.
function parseTrustedOrigin(value: string): string | null {
const input = value.trim().toLowerCase();
try {
const url = new URL(input);
if (url.protocol !== "https:") return null;
if (input !== url.origin && input !== `${url.origin}/`) return null;
return url.origin;
} catch {
return null;
}
}
export default function McpSettings() { export default function McpSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom); const [workspace, setWorkspace] = useAtom(workspaceAtom);
@@ -51,14 +30,7 @@ export default function McpSettings() {
const hasAccess = useHasFeature(Feature.MCP); const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel(); const upgradeLabel = useUpgradeLabel();
const [newClientName, setNewClientName] = useState("");
const [newClientOrigin, setNewClientOrigin] = useState("");
const mcpUrl = `${getAppUrl()}/mcp`; const mcpUrl = `${getAppUrl()}/mcp`;
const storedTrustedClients = workspace?.trustedOauthClients;
const trustedClients = Array.isArray(storedTrustedClients)
? storedTrustedClients
: [];
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked; const value = event.currentTarget.checked;
@@ -74,51 +46,6 @@ export default function McpSettings() {
} }
}; };
const saveTrustedClients = async (next: ITrustedOAuthClient[]) => {
try {
const updatedWorkspace = await updateWorkspace({
trustedOauthClients: next,
});
setWorkspace(updatedWorkspace);
return true;
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
return false;
}
};
const handleAddTrustedClient = async () => {
const name = newClientName.trim();
const origin = parseTrustedOrigin(newClientOrigin);
if (!origin) {
notifications.show({
message: t("Enter the app's callback origin, e.g. https://app.yourcompany.com"),
color: "red",
});
return;
}
if (trustedClients.some((client) => client.origin.toLowerCase() === origin)) {
notifications.show({
message: t("This origin is already trusted."),
color: "red",
});
return;
}
if (await saveTrustedClients([...trustedClients, { origin, name }])) {
setNewClientName("");
setNewClientOrigin("");
}
};
const handleRemoveTrustedClient = (origin: string) => {
void saveTrustedClients(
trustedClients.filter((client) => client.origin !== origin),
);
};
return ( return (
<Stack gap="lg"> <Stack gap="lg">
{!hasAccess && ( {!hasAccess && (
@@ -181,7 +108,7 @@ export default function McpSettings() {
</Group> </Group>
<Text size="sm" c="dimmed" mt="xs"> <Text size="sm" c="dimmed" mt="xs">
{t( {t(
"Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.", "Use your API key for authentication. You can manage API keys in your account settings.",
)} )}
</Text> </Text>
@@ -222,81 +149,6 @@ export default function McpSettings() {
</List.Item> </List.Item>
</List> </List>
</div> </div>
<div>
<Text size="sm" fw={500} mt="md" mb={4}>
{t("Trusted applications")}
</Text>
<Text size="sm" c="dimmed" mb="xs">
{t(
"Applications with these callback origins are trusted. Members will not see a warning when authorizing them.",
)}
</Text>
{trustedClients.length > 0 && (
<Table verticalSpacing="xs" mb="xs">
<Table.Tbody>
{trustedClients.map((client) => (
<Table.Tr key={client.origin}>
<Table.Td>
<Text size="sm" fw={500}>
{client.name}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{client.origin}
</Text>
</Table.Td>
<Table.Td w={40}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Remove {{name}}", {
name: client.name,
})}
onClick={() =>
handleRemoveTrustedClient(client.origin)
}
>
<IconTrash size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group gap="xs">
<TextInput
value={newClientName}
onChange={(event) =>
setNewClientName(event.currentTarget.value)
}
placeholder={t("Name")}
aria-label={t("Trusted application name")}
maxLength={64}
style={{ flex: 1 }}
/>
<TextInput
value={newClientOrigin}
onChange={(event) =>
setNewClientOrigin(event.currentTarget.value)
}
placeholder="https://app.yourcompany.com"
aria-label={t("Trusted application origin")}
style={{ flex: 2 }}
/>
<Button
variant="default"
onClick={handleAddTrustedClient}
disabled={!newClientName.trim() || !newClientOrigin.trim()}
>
{t("Add")}
</Button>
</Group>
</div>
</div> </div>
)} )}
</Stack> </Stack>
@@ -15,6 +15,14 @@ export interface IAiSearchResponse {
}>; }>;
} }
export async function hintVectorCache(): Promise<void> {
try {
await api.post("/ai/vector-cache-hint");
} catch {
// best-effort cache hint
}
}
export async function aiAnswers( export async function aiAnswers(
params: IPageSearchParams, params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void, onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
@@ -1,5 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Anchor, Alert, Button, Group, Space, Tabs, Text } from "@mantine/core"; import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; import SettingsTitle from "@/components/settings/settings-title";
@@ -17,8 +17,6 @@ import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx"; import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { useLocation, useNavigate } from "react-router-dom";
import { AuthorizedAppsPanel } from "@/ee/oauth/components/authorized-apps-panel.tsx";
export default function UserApiKeys() { export default function UserApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -31,11 +29,6 @@ export default function UserApiKeys() {
const { data, isLoading } = useGetApiKeysQuery({ cursor }); const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const location = useLocation();
const navigate = useNavigate();
const activeTab = location.pathname.endsWith("/authorized-apps")
? "authorized-apps"
: "api-keys";
const mcpEnabled = workspace?.settings?.ai?.mcp === true; const mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true; const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin; const canCreate = !restrictToAdmins || isAdmin;
@@ -54,27 +47,26 @@ export default function UserApiKeys() {
setRevokeModalOpened(true); setRevokeModalOpened(true);
}; };
const handleTabChange = (value: string | null) => {
navigate(
value === "authorized-apps"
? "/settings/account/api-keys/authorized-apps"
: "/settings/account/api-keys",
);
};
return ( return (
<> <>
<DocumentTitle <DocumentTitle title={t("API keys")} />
title={activeTab === "authorized-apps" ? t("Authorized apps") : t("API keys")}
/>
<SettingsTitle title={t("API keys")} /> <SettingsTitle title={t("API keys")} />
{mcpEnabled && ( <Text size="sm" c="dimmed" mb="md">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
{mcpEnabled && canCreate && (
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}> <Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm"> <Text size="sm">
{t( {t(
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.", "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
)}{" "} )}{" "}
<Anchor <Anchor
href="https://docmost.com/docs/user-guide/mcp" href="https://docmost.com/docs/user-guide/mcp"
@@ -93,45 +85,19 @@ export default function UserApiKeys() {
</Alert> </Alert>
)} )}
<Tabs color="dark" value={activeTab} onChange={handleTabChange}> {canCreate ? (
<Tabs.List> <Group justify="flex-end" mb="md">
<Tabs.Tab fw={500} value="api-keys"> <Button onClick={() => setCreateModalOpened(true)}>
{t("API keys")}
</Tabs.Tab>
<Tabs.Tab fw={500} value="authorized-apps">
{t("Authorized apps")}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="api-keys" pt="md">
<Group justify="space-between" align="center" mb="md">
<Text size="sm" c="dimmed">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
{canCreate && (
<Button
onClick={() => setCreateModalOpened(true)}
style={{ flexShrink: 0 }}
>
{t("Create API Key")} {t("Create API Key")}
</Button> </Button>
)}
</Group> </Group>
) : restrictToAdmins ? (
{!canCreate && restrictToAdmins && (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}> <Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm"> <Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")} {t("API key creation is restricted to admins by your workspace administrator.")}
</Text> </Text>
</Alert> </Alert>
)} ) : null}
<ApiKeyTable <ApiKeyTable
apiKeys={data?.items || []} apiKeys={data?.items || []}
@@ -151,13 +117,6 @@ export default function UserApiKeys() {
/> />
)} )}
</Tabs.Panel>
<Tabs.Panel value="authorized-apps" pt="md">
<AuthorizedAppsPanel />
</Tabs.Panel>
</Tabs>
<CreateApiKeyModal <CreateApiKeyModal
opened={createModalOpened} opened={createModalOpened}
onClose={() => setCreateModalOpened(false)} onClose={() => setCreateModalOpened(false)}
-1
View File
@@ -22,5 +22,4 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
OAUTH: 'oauth',
} as const; } as const;
@@ -1,19 +0,0 @@
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { OAuthGrantsTable } from "@/ee/oauth/components/oauth-grants-table";
import { useOAuthGrantsQuery } from "@/ee/oauth/queries/oauth-query";
export function AuthorizedAppsPanel() {
const { t } = useTranslation();
const { data, isLoading } = useOAuthGrantsQuery();
return (
<>
<Text size="sm" c="dimmed" mb="md">
{t("Applications and AI assistants you have authorized to access your account.")}
</Text>
<OAuthGrantsTable grants={data || []} isLoading={isLoading} />
</>
);
}
@@ -1,178 +0,0 @@
import { ActionIcon, Badge, Group, Skeleton, Table, Text, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { useTranslation } from "react-i18next";
import NoTableResults from "@/components/common/no-table-results";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
import { useRevokeOAuthGrantMutation } from "@/ee/oauth/queries/oauth-query";
// Callback hosts identify the app; the full URL belongs on the consent screen.
function callbackHosts(redirectUris: string[] = []): string[] {
const hosts = redirectUris.map((uri) => {
try {
const url = new URL(uri);
return url.protocol === "http:" || url.protocol === "https:"
? url.host
: url.protocol;
} catch {
return uri;
}
});
return Array.from(new Set(hosts));
}
type OAuthGrantsTableProps = {
grants: IOAuthGrant[];
isLoading?: boolean;
};
function TableSkeleton() {
return (
<>
{Array.from({ length: 3 }).map((_, i) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Group gap={4}>
<Skeleton height={20} width={50} />
<Skeleton height={20} width={55} />
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={28} width={28} circle />
</Table.Td>
</Table.Tr>
))}
</>
);
}
export function OAuthGrantsTable({ grants, isLoading }: OAuthGrantsTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const revokeMutation = useRevokeOAuthGrantMutation();
const scopeMeta: Record<string, { color: string; label: string }> = {
read: { color: "blue", label: t("Read") },
write: { color: "orange", label: t("Write") },
};
const formatDate = (date: string | null) => {
if (!date) return t("Never");
return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
};
const openRevokeModal = (grant: IOAuthGrant) =>
modals.openConfirmModal({
title: t("Revoke access"),
centered: true,
children: (
<Text size="sm">
{t(
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
{ name: grant.clientName },
)}
</Text>
),
labels: { confirm: t("Revoke access"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => revokeMutation.mutate(grant.id),
});
return (
<Table.ScrollContainer minWidth={600}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Application")}</Table.Th>
<Table.Th>{t("Permissions")}</Table.Th>
<Table.Th>{t("Authorized")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : grants.length > 0 ? (
grants.map((grant) => (
<Table.Tr key={grant.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{grant.clientName}
</Text>
<Text
fz="xs"
c="dimmed"
title={grant.redirectUris?.join("\n")}
style={{ overflowWrap: "anywhere" }}
>
{callbackHosts(grant.redirectUris).join(", ")}
</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
{grant.scopes.map((scope) => {
const meta = scopeMeta[scope];
if (!meta) return null;
return (
<Badge
key={scope}
variant="light"
color={meta.color}
size="sm"
>
{meta.label}
</Badge>
);
})}
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Tooltip label={t("Revoke access")}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Revoke access for {{name}}", {
name: grant.clientName,
})}
onClick={() => openRevokeModal(grant)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={5} text={t("No authorized apps yet.")} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -1,353 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Alert,
Anchor,
Box,
Button,
Center,
Checkbox,
Container,
Divider,
Group,
Loader,
Paper,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconAlertTriangle,
IconEye,
IconInfoCircle,
IconPencil,
} from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { useLocation, useSearchParams } from "react-router-dom";
import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
import classes from "@/features/auth/components/auth.module.css";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
import { UserInfo } from "@/components/common/user-info.tsx";
import useCurrentUser from "@/features/user/hooks/use-current-user";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { logout } from "@/features/auth/services/auth-service";
import { ICurrentUser } from "@/features/user/types/user.types";
import APP_ROUTE from "@/lib/app-route.ts";
import {
approveOAuthAuthorization,
getOAuthAuthorizeInfo,
} from "@/ee/oauth/services/oauth-service";
import {
IApproveAuthorizationPayload,
IAuthorizeParams,
IOAuthAuthorizeInfo,
} from "@/ee/oauth/types/oauth.types";
function loginRedirectUrl(pathname: string, search: string): string {
return `${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(pathname + search)}`;
}
function errorStatus(error: any): number | undefined {
return error?.response?.status;
}
function errorText(error: any): string | undefined {
const data = error?.response?.data;
if (typeof data?.error_description === "string") return data.error_description;
if (Array.isArray(data?.message)) return data.message.join(", ");
return data?.message;
}
const OAUTH_PARAM_NAMES = [
"response_type",
"client_id",
"redirect_uri",
"state",
"code_challenge",
"code_challenge_method",
"scope",
"resource",
];
function pickOAuthParams(params: IAuthorizeParams): IAuthorizeParams {
const picked: IAuthorizeParams = {};
for (const name of OAUTH_PARAM_NAMES) {
if (params[name] !== undefined) {
picked[name] = params[name];
}
}
return picked;
}
export default function OAuthConsent() {
const { t } = useTranslation();
const location = useLocation();
const [searchParams] = useSearchParams();
const params = useMemo<IAuthorizeParams>(
() => Object.fromEntries(searchParams.entries()),
[searchParams],
);
const currentUserQuery = useCurrentUser();
const infoQuery = useQuery({
queryKey: ["oauth-authorize-info", params],
queryFn: () => getOAuthAuthorizeInfo(params),
});
const isUnauthenticated =
errorStatus(currentUserQuery.error) === 401 ||
errorStatus(infoQuery.error) === 401;
useEffect(() => {
if (isUnauthenticated) {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
}
}, [isUnauthenticated, location.pathname, location.search]);
const isLoading =
isUnauthenticated || currentUserQuery.isLoading || infoQuery.isLoading;
return (
<AuthLayout>
<DocumentTitle title={t("Authorize application")} />
<Container size={460} className={classes.container}>
<Box p="xl">
{isLoading ? (
<Center mih={200}>
<Loader />
</Center>
) : infoQuery.data && currentUserQuery.data ? (
<ConsentCard
info={infoQuery.data}
currentUser={currentUserQuery.data}
params={params}
/>
) : (
<InvalidRequestCard
description={errorText(infoQuery.error ?? currentUserQuery.error)}
/>
)}
</Box>
</Container>
</AuthLayout>
);
}
function InvalidRequestCard({ description }: { description?: string }) {
const { t } = useTranslation();
return (
<Stack align="center" gap="sm">
<ThemeIcon size={48} radius="xl" variant="light" color="red">
<IconAlertTriangle size={26} stroke={1.5} />
</ThemeIcon>
<Title order={3} ta="center" fw={600}>
{t("Invalid authorization request")}
</Title>
{description && (
<Text size="sm" c="dimmed" ta="center">
{description}
</Text>
)}
</Stack>
);
}
type ConsentCardProps = {
info: IOAuthAuthorizeInfo;
currentUser: ICurrentUser;
params: IAuthorizeParams;
};
function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
const { t } = useTranslation();
const location = useLocation();
const [, setCurrentUser] = useAtom(currentUserAtom);
const [approvedScopes, setApprovedScopes] = useState<string[]>(
info.scopes.filter((scope) => scope === "read" || scope === "write"),
);
const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null);
const [acknowledged, setAcknowledged] = useState(false);
const scopeRows = [
{
scope: "read",
icon: <IconEye size={16} stroke={1.5} />,
label: t("Read"),
description: t("View data in your workspace without making changes."),
},
{
scope: "write",
icon: <IconPencil size={16} stroke={1.5} />,
label: t("Write"),
description: t("Create and modify data in your workspace."),
},
];
function toggleScope(scope: string, checked: boolean) {
setApprovedScopes((prev) =>
checked ? [...prev, scope] : prev.filter((item) => item !== scope),
);
}
async function submitDecision(approved: boolean) {
setSubmitting(approved ? "approve" : "deny");
const oauthParams = pickOAuthParams(params);
const payload: IApproveAuthorizationPayload = approved
? { ...oauthParams, approved: true, approvedScopes }
: { ...oauthParams, approved: false };
try {
const res = await approveOAuthAuthorization(payload);
window.location.replace(res.redirectUrl);
} catch (err) {
if (errorStatus(err) === 401) {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
return;
}
setSubmitting(null);
notifications.show({
message: errorText(err) || t("Something went wrong. Please try again."),
color: "red",
});
}
}
async function switchAccount() {
if (submitting !== null) {
return;
}
setCurrentUser(RESET);
try {
await logout();
} finally {
window.location.replace(
loginRedirectUrl(location.pathname, location.search),
);
}
}
return (
<Stack gap="lg">
<Title order={3} ta="center" fw={600}>
{t("{{name}} wants to access {{workspace}}", {
name: info.clientName,
workspace: currentUser.workspace.name,
})}
</Title>
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<UserInfo user={currentUser.user} />
<Anchor
component="button"
type="button"
size="xs"
c="dimmed"
disabled={submitting !== null}
onClick={switchAccount}
style={{ whiteSpace: "nowrap" }}
>
{t("Not you? Switch account")}
</Anchor>
</Group>
</Paper>
<Divider />
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("This application will be able to:")}
</Text>
{scopeRows.map((row) => {
const requested = info.scopes.includes(row.scope);
return (
<Checkbox
key={row.scope}
size="sm"
checked={approvedScopes.includes(row.scope)}
disabled={!requested || submitting !== null}
onChange={(event) =>
toggleScope(row.scope, event.currentTarget.checked)
}
label={
<Group gap={6} wrap="nowrap">
{row.icon}
<Text size="sm" fw={500}>
{row.label}
</Text>
</Group>
}
description={row.description}
/>
);
})}
</Stack>
<div>
<Text size="xs" c="dimmed">
{t("You will be redirected to")}
</Text>
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{info.redirectUri}
</Text>
</div>
{!info.verified && (
<Alert
variant="light"
color="yellow"
icon={<IconInfoCircle size={16} />}
>
<Stack gap="xs">
<Text size="sm">
{t(
"This application is not on your workspace's trusted list. Authorize only if you recognize it.",
)}
</Text>
<Checkbox
size="sm"
checked={acknowledged}
disabled={submitting !== null}
onChange={(event) =>
setAcknowledged(event.currentTarget.checked)
}
label={t("I recognize this application and want to continue")}
/>
</Stack>
</Alert>
)}
<Group grow>
<Button
variant="default"
onClick={() => submitDecision(false)}
loading={submitting === "deny"}
disabled={submitting === "approve"}
>
{t("Cancel")}
</Button>
<Button
onClick={() => submitDecision(true)}
loading={submitting === "approve"}
disabled={
approvedScopes.length === 0 ||
(!info.verified && !acknowledged) ||
submitting === "deny"
}
>
{t("Authorize")}
</Button>
</Group>
</Stack>
);
}
@@ -1,45 +0,0 @@
import {
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
getOAuthGrants,
revokeOAuthGrant,
} from "@/ee/oauth/services/oauth-service";
import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
export function useOAuthGrantsQuery(): UseQueryResult<IOAuthGrant[], Error> {
return useQuery({
queryKey: ["oauth-grants"],
queryFn: () => getOAuthGrants(),
staleTime: 0,
gcTime: 0,
});
}
export function useRevokeOAuthGrantMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
mutationFn: (grantId) => revokeOAuthGrant(grantId),
onSuccess: () => {
notifications.show({ message: t("Access revoked") });
queryClient.invalidateQueries({
predicate: (item) =>
["oauth-grants"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({
message: errorMessage || t("Something went wrong. Please try again."),
color: "red",
});
},
});
}
@@ -1,36 +0,0 @@
import api from "@/lib/api-client";
import {
IApproveAuthorizationPayload,
IAuthorizeParams,
IOAuthAuthorizeInfo,
IOAuthGrant,
} from "@/ee/oauth/types/oauth.types";
export async function getOAuthAuthorizeInfo(
params: IAuthorizeParams,
): Promise<IOAuthAuthorizeInfo> {
const req = await api.post<IOAuthAuthorizeInfo>(
"/oauth/authorize-info",
params,
);
return req.data;
}
export async function approveOAuthAuthorization(
payload: IApproveAuthorizationPayload,
): Promise<{ redirectUrl: string }> {
const req = await api.post<{ redirectUrl: string }>(
"/oauth/authorize",
payload,
);
return req.data;
}
export async function getOAuthGrants(): Promise<IOAuthGrant[]> {
const req = await api.post<IOAuthGrant[]>("/oauth/grants", {});
return req.data;
}
export async function revokeOAuthGrant(grantId: string): Promise<void> {
await api.post("/oauth/grants/revoke", { grantId });
}
@@ -1,24 +0,0 @@
export interface IOAuthAuthorizeInfo {
clientName: string;
redirectUri: string;
scopes: string[];
clientCreatedAt: string;
verified: boolean;
}
export interface IOAuthGrant {
id: string;
clientName: string;
redirectUris: string[];
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
}
export type IAuthorizeParams = Record<string, string>;
export type IApproveAuthorizationPayload = {
[param: string]: unknown;
approved: boolean;
approvedScopes?: string[];
};
@@ -13,11 +13,16 @@ import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx"; import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature"; import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features"; import { Feature } from "@/ee/features";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { hintVectorCache } from "@/ee/ai/services/ai-search-service.ts";
import { getAiVectorDriver } from "@/lib/config.ts";
interface SearchSpotlightProps { interface SearchSpotlightProps {
spaceId?: string; spaceId?: string;
} }
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) { export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const workspace = useAtomValue(workspaceAtom);
const { t } = useTranslation(); const { t } = useTranslation();
const hasAiFeature = useHasFeature(Feature.AI); const hasAiFeature = useHasFeature(Feature.AI);
const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING); const hasAttachmentIndexing = useHasFeature(Feature.ATTACHMENT_INDEXING);
@@ -96,6 +101,15 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
/> />
)); ));
const handleSpotlightOpen = () => {
if (
workspace?.settings?.ai?.search === true &&
getAiVectorDriver() === "turbopuffer"
) {
hintVectorCache();
}
};
const handleFiltersChange = (newFilters: any) => { const handleFiltersChange = (newFilters: any) => {
setFilters(newFilters); setFilters(newFilters);
}; };
@@ -115,6 +129,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
<Spotlight.Root <Spotlight.Root
size="xl" size="xl"
maxHeight={600} maxHeight={600}
onSpotlightOpen={handleSpotlightOpen}
store={searchSpotlightStore} store={searchSpotlightStore}
query={query} query={query}
onQueryChange={setQuery} onQueryChange={setQuery}
@@ -31,7 +31,6 @@ export interface IWorkspace {
allowPersonalSpaces?: boolean; allowPersonalSpaces?: boolean;
defaultPageEditMode?: string; defaultPageEditMode?: string;
isScimEnabled?: boolean; isScimEnabled?: boolean;
trustedOauthClients?: ITrustedOAuthClient[];
} }
export interface IWorkspaceSettings { export interface IWorkspaceSettings {
@@ -43,11 +42,6 @@ export interface IWorkspaceSettings {
defaultPageEditMode?: string; defaultPageEditMode?: string;
} }
export interface ITrustedOAuthClient {
origin: string;
name: string;
}
export interface IWorkspaceApiSettings { export interface IWorkspaceApiSettings {
restrictToAdmins?: boolean; restrictToAdmins?: boolean;
} }
-2
View File
@@ -76,8 +76,6 @@ function redirectToLogin() {
APP_ROUTE.AUTH.MFA_CHALLENGE, APP_ROUTE.AUTH.MFA_CHALLENGE,
APP_ROUTE.AUTH.MFA_SETUP_REQUIRED, APP_ROUTE.AUTH.MFA_SETUP_REQUIRED,
"/invites", "/invites",
// the oauth consent page redirects to login itself, preserving its query string
"/oauth/consent",
]; ];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) { if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
const redirectTo = window.location.pathname; const redirectTo = window.location.pathname;
+4
View File
@@ -43,6 +43,10 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD")); return castToBoolean(getConfigValue("CLOUD"));
} }
export function getAiVectorDriver(): string {
return getConfigValue("AI_VECTOR_DRIVER");
}
export function getAvatarUrl( export function getAvatarUrl(
avatarUrl: string, avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR, type: AvatarIconType = AvatarIconType.AVATAR,
+2
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST, POSTHOG_HOST,
POSTHOG_KEY, POSTHOG_KEY,
AI_VECTOR_DRIVER,
} = loadEnv(mode, envPath, ""); } = loadEnv(mode, envPath, "");
return { return {
@@ -31,6 +32,7 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS, BILLING_TRIAL_DAYS,
POSTHOG_HOST, POSTHOG_HOST,
POSTHOG_KEY, POSTHOG_KEY,
AI_VECTOR_DRIVER,
}, },
APP_VERSION: JSON.stringify(process.env.npm_package_version), APP_VERSION: JSON.stringify(process.env.npm_package_version),
}, },
+1 -1
View File
@@ -43,7 +43,6 @@
"@fastify/cookie": "11.0.2", "@fastify/cookie": "11.0.2",
"@fastify/multipart": "10.0.0", "@fastify/multipart": "10.0.0",
"@fastify/static": "10.1.2", "@fastify/static": "10.1.2",
"@jmondi/oauth2-server": "^4.3.7",
"@keyv/redis": "5.1.6", "@keyv/redis": "5.1.6",
"@langchain/core": "1.1.46", "@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
@@ -67,6 +66,7 @@
"@nestjs/websockets": "11.1.28", "@nestjs/websockets": "11.1.28",
"@node-saml/passport-saml": "5.1.0", "@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0", "@socket.io/redis-adapter": "8.3.0",
"@turbopuffer/turbopuffer": "^2.8.0",
"ai": "6.0.134", "ai": "6.0.134",
"ai-sdk-ollama": "3.8.1", "ai-sdk-ollama": "3.8.1",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
@@ -57,6 +57,7 @@ import {
JSONContent, JSONContent,
} from '@tiptap/core'; } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
import { collapseBlankLines } from '../common/helpers';
// @tiptap/html library works best for generating prosemirror json state but not HTML // @tiptap/html library works best for generating prosemirror json state but not HTML
// see: https://github.com/ueberdosis/tiptap/issues/5352 // see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
@@ -146,7 +147,7 @@ export function htmlToJson(html: string) {
} }
export function jsonToText(tiptapJson: JSONContent) { export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions); return collapseBlankLines(generateText(tiptapJson, tiptapExtensions));
} }
export function jsonToNode(tiptapJson: JSONContent) { export function jsonToNode(tiptapJson: JSONContent) {
@@ -1,8 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const OAUTH_SCOPE_KEY = 'oauthScope';
export type OAuthRouteScope = 'read' | 'write';
export const OAuthScope = (scope: OAuthRouteScope) =>
SetMetadata(OAUTH_SCOPE_KEY, scope);
@@ -1,6 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRE_SESSION_AUTH_KEY = 'requireSessionAuth';
export const RequireSessionAuth = () =>
SetMetadata(REQUIRE_SESSION_AUTH_KEY, true);
@@ -23,11 +23,6 @@ export const AuditEvent = {
API_KEY_UPDATED: 'api_key.updated', API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted', API_KEY_DELETED: 'api_key.deleted',
// OAuth
OAUTH_CLIENT_REGISTERED: 'oauth_client.registered',
OAUTH_GRANT_CREATED: 'oauth_grant.created',
OAUTH_GRANT_REVOKED: 'oauth_grant.revoked',
// SCIM Tokens // SCIM Tokens
SCIM_TOKEN_CREATED: 'scim_token.created', SCIM_TOKEN_CREATED: 'scim_token.created',
SCIM_TOKEN_UPDATED: 'scim_token.updated', SCIM_TOKEN_UPDATED: 'scim_token.updated',
@@ -129,8 +124,6 @@ export const AuditResource = {
COMMENT: 'comment', COMMENT: 'comment',
SHARE: 'share', SHARE: 'share',
API_KEY: 'api_key', API_KEY: 'api_key',
OAUTH_CLIENT: 'oauth_client',
OAUTH_GRANT: 'oauth_grant',
SCIM_TOKEN: 'scim_token', SCIM_TOKEN: 'scim_token',
SSO_PROVIDER: 'sso_provider', SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation', WORKSPACE_INVITATION: 'workspace_invitation',
@@ -12,8 +12,6 @@ export enum EventName {
SPACE_UPDATED = 'space.updated', SPACE_UPDATED = 'space.updated',
SPACE_DELETED = 'space.deleted', SPACE_DELETED = 'space.deleted',
USER_PASSWORD_RESET = 'user.password.reset',
WORKSPACE_CREATED = 'workspace.created', WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated', WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted', WORKSPACE_DELETED = 'workspace.deleted',
-1
View File
@@ -23,7 +23,6 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
OAUTH: 'oauth',
} as const; } as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature]; export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -1,186 +0,0 @@
import {
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';
import { OAUTH_SCOPE_KEY } from '../decorators/oauth-scope.decorator';
import { REQUIRE_SESSION_AUTH_KEY } from '../decorators/require-session-auth.decorator';
import { JwtType } from '../../core/auth/dto/jwt-payload';
const handlerSentinel = () => 'handler';
const classSentinel = class Controller {};
function createCtx(): ExecutionContext {
return {
getHandler: () => handlerSentinel,
getClass: () => classSentinel,
} as any;
}
function createGuard(scopeMetadata?: unknown, requireSession?: boolean) {
const reflector = {
getAllAndOverride: jest.fn((key: string) =>
key === REQUIRE_SESSION_AUTH_KEY ? requireSession : scopeMetadata,
),
} as any;
const environmentService = {
isCloud: jest.fn().mockReturnValue(false),
} as any;
const guard = new JwtAuthGuard(reflector, environmentService);
return { guard, reflector };
}
function oauthUser(scopes: string[]) {
return {
user: { id: 'user_1' },
workspace: { id: 'ws_1' },
oauth: { grantId: 'grant_1', scopes },
};
}
describe('JwtAuthGuard.handleRequest', () => {
it('rethrows the strategy error', () => {
const { guard } = createGuard();
const err = new UnauthorizedException('bad token');
expect(() => guard.handleRequest(err, null, null, createCtx())).toThrow(err);
});
it('throws UnauthorizedException when there is no user', () => {
const { guard } = createGuard();
expect(() => guard.handleRequest(null, null, null, createCtx())).toThrow(
UnauthorizedException,
);
});
it('returns a non-oauth user untouched without consulting scope metadata', () => {
const { guard, reflector } = createGuard();
const user = { user: { id: 'user_1' }, workspace: { id: 'ws_1' } };
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
expect(reflector.getAllAndOverride).not.toHaveBeenCalledWith(
OAUTH_SCOPE_KEY,
expect.anything(),
);
});
it('forbids an oauth user on a route without scope metadata', () => {
const { guard, reflector } = createGuard(undefined);
expect(() =>
guard.handleRequest(null, oauthUser(['read', 'write']), null, createCtx()),
).toThrow(ForbiddenException);
expect(reflector.getAllAndOverride).toHaveBeenCalledWith(OAUTH_SCOPE_KEY, [
handlerSentinel,
classSentinel,
]);
});
it('passes read scope on a read route', () => {
const { guard } = createGuard('read');
const user = oauthUser(['read']);
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
});
it('forbids read scope on a write route with insufficient_scope', () => {
const { guard } = createGuard('write');
expect(() =>
guard.handleRequest(null, oauthUser(['read']), null, createCtx()),
).toThrow('insufficient_scope');
});
it('passes write scope on a read route', () => {
const { guard } = createGuard('read');
const user = oauthUser(['write']);
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
});
it('passes write scope on a write route', () => {
const { guard } = createGuard('write');
const user = oauthUser(['write']);
expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
});
describe('session-only routes', () => {
const sessionUser = {
user: { id: 'user_1' },
workspace: { id: 'ws_1' },
authType: JwtType.ACCESS,
};
it('allows a signed-in session', () => {
const { guard } = createGuard(undefined, true);
expect(guard.handleRequest(null, sessionUser, null, createCtx())).toBe(
sessionUser,
);
});
it('forbids an api key', () => {
const { guard } = createGuard(undefined, true);
const apiKeyUser = {
user: { id: 'user_1' },
workspace: { id: 'ws_1' },
authType: JwtType.API_KEY,
};
expect(() =>
guard.handleRequest(null, apiKeyUser, null, createCtx()),
).toThrow('This action requires an interactive user session');
});
it('forbids an oauth token even when it carries write scope', () => {
const { guard } = createGuard('write', true);
const user = { ...oauthUser(['write']), authType: JwtType.OAUTH_ACCESS };
expect(() => guard.handleRequest(null, user, null, createCtx())).toThrow(
'This action requires an interactive user session',
);
});
it('leaves api keys working on routes without the marker', () => {
const { guard } = createGuard(undefined, undefined);
const apiKeyUser = {
user: { id: 'user_1' },
workspace: { id: 'ws_1' },
authType: JwtType.API_KEY,
};
expect(guard.handleRequest(null, apiKeyUser, null, createCtx())).toBe(
apiKeyUser,
);
});
});
it('lets handler metadata override class metadata', () => {
const metadataByTarget = new Map<unknown, string>([
[handlerSentinel, 'write'],
[classSentinel, 'read'],
]);
const reflector = {
getAllAndOverride: jest.fn((key: string, targets: unknown[]) => {
if (key === REQUIRE_SESSION_AUTH_KEY) {
return undefined;
}
for (const target of targets) {
if (metadataByTarget.has(target)) {
return metadataByTarget.get(target);
}
}
return undefined;
}),
} as any;
const environmentService = { isCloud: jest.fn().mockReturnValue(false) } as any;
const guard = new JwtAuthGuard(reflector, environmentService);
expect(() =>
guard.handleRequest(null, oauthUser(['read']), null, createCtx()),
).toThrow('insufficient_scope');
});
});
@@ -1,26 +1,16 @@
import { import {
ExecutionContext, ExecutionContext,
ForbiddenException,
Injectable, Injectable,
Logger,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import {
OAUTH_SCOPE_KEY,
OAuthRouteScope,
} from '../decorators/oauth-scope.decorator';
import { REQUIRE_SESSION_AUTH_KEY } from '../decorators/require-session-auth.decorator';
import { JwtType } from '../../core/auth/dto/jwt-payload';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
private logger = new Logger('JwtAuthGuard');
constructor( constructor(
private reflector: Reflector, private reflector: Reflector,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
@@ -46,39 +36,6 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
throw err || new UnauthorizedException(); throw err || new UnauthorizedException();
} }
const requiresSession = this.reflector.getAllAndOverride<boolean>(
REQUIRE_SESSION_AUTH_KEY,
[ctx.getHandler(), ctx.getClass()],
);
if (requiresSession && user.authType !== JwtType.ACCESS) {
this.logger.warn(
`session-only endpoint ${ctx.getClass()?.name}.${ctx.getHandler()?.name} refused authType ${user.authType}`,
);
throw new ForbiddenException(
'This action requires an interactive user session',
);
}
if (user.oauth) {
const required = this.reflector.getAllAndOverride<
OAuthRouteScope | undefined
>(OAUTH_SCOPE_KEY, [ctx.getHandler(), ctx.getClass()]);
if (!required) {
this.logger.warn(
`oauth scope check: no @OAuthScope metadata on ${ctx.getClass()?.name}.${ctx.getHandler()?.name}`,
);
throw new ForbiddenException('OAuth tokens cannot access this endpoint');
}
const scopes: string[] = user.oauth.scopes ?? [];
const satisfied =
required === 'read'
? scopes.includes('read') || scopes.includes('write')
: scopes.includes('write');
if (!satisfied) {
throw new ForbiddenException('insufficient_scope');
}
}
this.setJoinedWorkspacesCookie(user, ctx); this.setJoinedWorkspacesCookie(user, ctx);
return user; return user;
} }
+1
View File
@@ -1,4 +1,5 @@
export * from './utils'; export * from './utils';
export * from './text.utils';
export * from './nanoid.utils'; export * from './nanoid.utils';
export * from './file.helper'; export * from './file.helper';
export * from './constants'; export * from './constants';
@@ -1,66 +0,0 @@
import {
resolveFrameHeader,
resolveFrameHeadersForPath,
SecurityHeader,
} from './security-headers';
describe('resolveFrameHeader', () => {
it('denies framing with X-Frame-Options when embedding is off', () => {
expect(resolveFrameHeader(false, [])).toEqual({
name: 'X-Frame-Options',
value: 'SAMEORIGIN',
});
});
it('returns null when embedding is on but no origins are configured', () => {
expect(resolveFrameHeader(true, [])).toBeNull();
});
it('emits a frame-ancestors CSP for the allowed origins', () => {
expect(resolveFrameHeader(true, ['https://a.example', 'https://b.example']))
.toEqual({
name: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://a.example https://b.example",
});
});
});
describe('resolveFrameHeadersForPath', () => {
const configured: SecurityHeader = {
name: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://a.example",
};
it.each(['/oauth/consent', '/oauth/consent/nested'])(
'force-denies %s regardless of configured header',
(path) => {
expect(resolveFrameHeadersForPath(path, configured)).toEqual([
{ name: 'X-Frame-Options', value: 'DENY' },
{ name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
]);
},
);
it('force-denies consent even when the global header is absent', () => {
expect(resolveFrameHeadersForPath('/oauth/consent', null)).toEqual([
{ name: 'X-Frame-Options', value: 'DENY' },
{ name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
]);
});
it('does not match an unrelated path that merely contains the prefix', () => {
expect(
resolveFrameHeadersForPath('/oauth/consenting-adults', configured),
).toEqual([configured]);
});
it('passes the configured header through for other paths', () => {
expect(resolveFrameHeadersForPath('/home', configured)).toEqual([
configured,
]);
});
it('returns nothing for other paths when no header is configured', () => {
expect(resolveFrameHeadersForPath('/home', null)).toEqual([]);
});
});
@@ -17,19 +17,3 @@ export function resolveFrameHeader(
value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`, value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`,
}; };
} }
// Deny OAuth consent in iframe
export const OAUTH_CONSENT_PATH = '/oauth/consent';
export function resolveFrameHeadersForPath(
path: string,
configuredHeader: SecurityHeader | null,
): SecurityHeader[] {
if (path === OAUTH_CONSENT_PATH || path.startsWith(`${OAUTH_CONSENT_PATH}/`)) {
return [
{ name: 'X-Frame-Options', value: 'DENY' },
{ name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
];
}
return configuredHeader ? [configuredHeader] : [];
}
@@ -0,0 +1,14 @@
import { collapseBlankLines } from './text.utils';
describe('collapseBlankLines', () => {
it.each([
['a\n\n\n\nb', 'a\n\nb'],
['a\n\nb', 'a\n\nb'],
['a\nb', 'a\nb'],
['\n\n\n\na\n\n\n', '\n\na\n\n'],
['no newlines', 'no newlines'],
['', ''],
])('collapses %j to %j', (input, expected) => {
expect(collapseBlankLines(input)).toBe(expected);
});
});
@@ -0,0 +1,3 @@
export function collapseBlankLines(text: string): string {
return text.replace(/\n{2,}/g, '\n\n');
}
@@ -24,7 +24,6 @@ import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types'; import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service'; import { StorageService } from '../../integrations/storage/storage.service';
import { import {
@@ -176,7 +175,6 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName') @Get('/files/:fileId/:fileName')
@OAuthScope('read')
async getFile( async getFile(
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@@ -429,7 +427,6 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('pages/attachments') @Post('pages/attachments')
@OAuthScope('read')
async getPageAttachments( async getPageAttachments(
@Body() dto: PageIdDto, @Body() dto: PageIdDto,
@Body() pagination: PaginationOptions, @Body() pagination: PaginationOptions,
+2 -2
View File
@@ -12,7 +12,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler'; import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import { import {
ALL_NAMED_THROTTLERS_SKIPPED, AI_CHAT_THROTTLER,
AUTH_THROTTLER, AUTH_THROTTLER,
} from '../../integrations/throttle/throttler-names'; } from '../../integrations/throttle/throttler-names';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
@@ -38,7 +38,7 @@ import {
IAuditService, IAuditService,
} from '../../integrations/audit/audit.service'; } from '../../integrations/audit/audit.service';
@SkipThrottle({ ...ALL_NAMED_THROTTLERS_SKIPPED, [AUTH_THROTTLER]: false }) @SkipThrottle({ [AI_CHAT_THROTTLER]: true })
@UseGuards(ThrottlerGuard) @UseGuards(ThrottlerGuard)
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@@ -7,7 +7,6 @@ export enum JwtType {
API_KEY = 'api_key', API_KEY = 'api_key',
PDF_RENDER = 'pdf_render', PDF_RENDER = 'pdf_render',
PDF_EXPORT_DOWNLOAD = 'pdf_export_download', PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
OAUTH_ACCESS = 'oauth_access',
} }
export type JwtPayload = { export type JwtPayload = {
sub: string; sub: string;
@@ -60,14 +59,3 @@ export type JwtPdfExportDownloadPayload = {
workspaceId: string; workspaceId: string;
type: 'pdf_export_download'; type: 'pdf_export_download';
}; };
export type JwtOAuthPayload = {
sub: string;
workspaceId: string;
grantId: string;
scope: string;
aud: string;
iss: string;
jti: string;
type: JwtType.OAUTH_ACCESS;
};
@@ -2,7 +2,6 @@ import {
BadRequestException, BadRequestException,
Inject, Inject,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -41,13 +40,9 @@ import {
IAuditService, IAuditService,
} from '../../../integrations/audit/audit.service'; } from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor( constructor(
private signupService: SignupService, private signupService: SignupService,
private tokenService: TokenService, private tokenService: TokenService,
@@ -58,7 +53,6 @@ export class AuthService {
private mailService: MailService, private mailService: MailService,
private domainService: DomainService, private domainService: DomainService,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private eventEmitter: EventEmitter2,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {} ) {}
@@ -267,19 +261,6 @@ export class AuthService {
await this.userSessionRepo.deleteByUserId(user.id, workspace.id); await this.userSessionRepo.deleteByUserId(user.id, workspace.id);
// A failed revocation must not block the reset itself; log loudly instead.
try {
await this.eventEmitter.emitAsync(EventName.USER_PASSWORD_RESET, {
userId: user.id,
workspaceId: workspace.id,
});
} catch (err) {
this.logger.error(
`failed to revoke oauth grants for user ${user.id} after password reset`,
err,
);
}
this.auditService.setActorId(user.id); this.auditService.setActorId(user.id);
this.auditService.log({ this.auditService.log({
event: AuditEvent.USER_PASSWORD_RESET, event: AuditEvent.USER_PASSWORD_RESET,
@@ -2,12 +2,7 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
JwtApiKeyPayload,
JwtOAuthPayload,
JwtPayload,
JwtType,
} from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
@@ -38,10 +33,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
}); });
} }
async validate( async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
req: any,
payload: JwtPayload | JwtApiKeyPayload | JwtOAuthPayload,
) {
if (!payload.workspaceId) { if (!payload.workspaceId) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
@@ -50,21 +42,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Workspace does not match'); throw new UnauthorizedException('Workspace does not match');
} }
// authType lets guards tell an interactive session from a programmatic credential.
if (payload.type === JwtType.API_KEY) { if (payload.type === JwtType.API_KEY) {
const authResult = await this.validateApiKey( return this.validateApiKey(req, payload as JwtApiKeyPayload);
req,
payload as JwtApiKeyPayload,
);
return { ...authResult, authType: JwtType.API_KEY };
}
if (payload.type === JwtType.OAUTH_ACCESS) {
const authResult = await this.validateOAuthToken(
req,
payload as JwtOAuthPayload,
);
return { ...authResult, authType: JwtType.OAUTH_ACCESS };
} }
if (payload.type !== JwtType.ACCESS) { if (payload.type !== JwtType.ACCESS) {
@@ -120,36 +99,4 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Enterprise API Key module missing'); throw new UnauthorizedException('Enterprise API Key module missing');
} }
private async validateOAuthToken(req: any, payload: JwtOAuthPayload) {
let OAuthStrategyModule: any;
let isOAuthModuleReady = false;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
OAuthStrategyModule = require('./../../../ee/oauth/services/oauth-strategy.service');
isOAuthModuleReady = true;
} catch (err) {
this.logger.debug(
'OAuth module requested but enterprise module not bundled in this build',
);
isOAuthModuleReady = false;
}
if (isOAuthModuleReady) {
const OAuthStrategyService = this.moduleRef.get(
OAuthStrategyModule.OAuthStrategyService,
{
strict: false,
},
);
return OAuthStrategyService.validateOAuthToken(payload, {
workspaceId: req.raw.workspaceId,
host: req.raw.headers?.host ?? req.headers?.host,
});
}
throw new UnauthorizedException('Enterprise OAuth module missing');
}
} }
@@ -16,7 +16,6 @@ import { PageIdDto, CommentIdDto } from './dto/comments.input';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
@@ -49,7 +48,6 @@ export class CommentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('create') @Post('create')
@OAuthScope('write')
async create( async create(
@Body() createCommentDto: CreateCommentDto, @Body() createCommentDto: CreateCommentDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -86,7 +84,6 @@ export class CommentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/') @Post('/')
@OAuthScope('read')
async findPageComments( async findPageComments(
@Body() input: PageIdDto, @Body() input: PageIdDto,
@Body() @Body()
@@ -123,7 +120,6 @@ export class CommentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
@OAuthScope('write')
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) { async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
const comment = await this.commentRepo.findById(dto.commentId, { const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true, includeCreator: true,
@@ -26,7 +26,6 @@ import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { Page, User, Workspace } from '@docmost/db/types/entity.types'; import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { SidebarPageDto } from './dto/sidebar-page.dto'; import { SidebarPageDto } from './dto/sidebar-page.dto';
@@ -70,7 +69,6 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/info') @Post('/info')
@OAuthScope('read')
async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) { async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId, { const page = await this.pageRepo.findById(dto.pageId, {
includeSpace: true, includeSpace: true,
@@ -201,7 +199,6 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('create') @Post('create')
@OAuthScope('write')
async create( async create(
@Body() createPageDto: CreatePageDto, @Body() createPageDto: CreatePageDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -272,7 +269,6 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
@OAuthScope('write')
async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) { async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(updatePageDto.pageId); const page = await this.pageRepo.findById(updatePageDto.pageId);
@@ -417,7 +413,6 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('recent') @Post('recent')
@OAuthScope('read')
async getRecentPages( async getRecentPages(
@Body() recentPageDto: RecentPageDto, @Body() recentPageDto: RecentPageDto,
@Body() pagination: PaginationOptions, @Body() pagination: PaginationOptions,
@@ -532,7 +527,6 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/sidebar-pages') @Post('/sidebar-pages')
@OAuthScope('read')
async getSidebarPages( async getSidebarPages(
@Body() dto: SidebarPageDto, @Body() dto: SidebarPageDto,
@Body() pagination: PaginationOptions, @Body() pagination: PaginationOptions,
@@ -575,7 +569,6 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('move-to-space') @Post('move-to-space')
@OAuthScope('write')
async movePageToSpace( async movePageToSpace(
@Body() dto: MovePageToSpaceDto, @Body() dto: MovePageToSpaceDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -629,7 +622,6 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('duplicate') @Post('duplicate')
@OAuthScope('write')
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) { async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
const copiedPage = await this.pageRepo.findById(dto.pageId); const copiedPage = await this.pageRepo.findById(dto.pageId);
if (!copiedPage) { if (!copiedPage) {
@@ -714,7 +706,6 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('move') @Post('move')
@OAuthScope('write')
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) { async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
const movedPage = await this.pageRepo.findById(dto.pageId); const movedPage = await this.pageRepo.findById(dto.pageId);
if (!movedPage) { if (!movedPage) {
@@ -496,10 +496,21 @@ export class PageService {
}, },
); );
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { await this.aiQueue.add(
QueueJob.PAGE_MOVED_TO_SPACE,
{
pageIds: pageIdsToMove, pageIds: pageIdsToMove,
spaceId,
workspaceId: rootPage.workspaceId, workspaceId: rootPage.workspaceId,
}); },
{
attempts: 2,
backoff: {
type: 'fixed',
delay: 2 * 60 * 1000,
},
},
);
} }
}); });
@@ -17,7 +17,6 @@ import {
} from './dto/search.dto'; } from './dto/search.dto';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { import {
@@ -43,7 +42,6 @@ export class SearchController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post() @Post()
@OAuthScope('read')
async pageSearch( async pageSearch(
@Body() searchDto: SearchDTO, @Body() searchDto: SearchDTO,
@AuthUser() user: User, @AuthUser() user: User,
@@ -77,7 +75,6 @@ export class SearchController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('suggest') @Post('suggest')
@OAuthScope('read')
async searchSuggestions( async searchSuggestions(
@Body() dto: SearchSuggestionDTO, @Body() dto: SearchSuggestionDTO,
@AuthUser() user: User, @AuthUser() user: User,
@@ -13,7 +13,6 @@ import { SpaceService } from './services/space.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { SpaceIdDto } from './dto/space-id.dto'; import { SpaceIdDto } from './dto/space-id.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceMemberService } from './services/space-member.service'; import { SpaceMemberService } from './services/space-member.service';
@@ -49,7 +48,6 @@ export class SpaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/') @Post('/')
@OAuthScope('read')
async getWorkspaceSpaces( async getWorkspaceSpaces(
@Body() @Body()
pagination: PaginationOptions, pagination: PaginationOptions,
@@ -94,7 +92,6 @@ export class SpaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('info') @Post('info')
@OAuthScope('read')
async getSpaceInfo( async getSpaceInfo(
@Body() spaceIdDto: SpaceIdDto, @Body() spaceIdDto: SpaceIdDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -132,7 +129,6 @@ export class SpaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('create') @Post('create')
@OAuthScope('write')
createSpace( createSpace(
@Body() createSpaceDto: CreateSpaceDto, @Body() createSpaceDto: CreateSpaceDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -149,7 +145,6 @@ export class SpaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
@OAuthScope('write')
async updateSpace( async updateSpace(
@Body() updateSpaceDto: UpdateSpaceDto, @Body() updateSpaceDto: UpdateSpaceDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -10,7 +10,6 @@ import { UserService } from './user.service';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@@ -25,7 +24,6 @@ export class UserController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('me') @Post('me')
@OAuthScope('read')
async getUserInfo( async getUserInfo(
@AuthUser() authUser: User, @AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@@ -24,7 +24,6 @@ import {
RevokeInviteDto, RevokeInviteDto,
} from '../dto/invitation.dto'; } from '../dto/invitation.dto';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../../common/decorators/oauth-scope.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import { import {
@@ -59,7 +58,6 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/info') @Post('/info')
@OAuthScope('read')
async getWorkspace(@AuthWorkspace() workspace: Workspace) { async getWorkspace(@AuthWorkspace() workspace: Workspace) {
return this.workspaceService.getWorkspaceInfo(workspace.id); return this.workspaceService.getWorkspaceInfo(workspace.id);
} }
@@ -115,7 +113,6 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('members') @Post('members')
@OAuthScope('read')
async getWorkspaceMembers( async getWorkspaceMembers(
@Body() @Body()
pagination: PaginationOptions, pagination: PaginationOptions,
@@ -1,32 +1,15 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { Type } from 'class-transformer';
import { CreateWorkspaceDto } from './create-workspace.dto'; import { CreateWorkspaceDto } from './create-workspace.dto';
import { TrustedOAuthClient } from '../workspace.util';
import { import {
ArrayMaxSize,
IsArray, IsArray,
IsBoolean, IsBoolean,
IsIn, IsIn,
IsInt, IsInt,
IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
MaxLength,
Min, Min,
ValidateNested,
} from 'class-validator'; } from 'class-validator';
export class TrustedOAuthClientDto {
@IsString()
@IsNotEmpty()
origin: string;
@IsString()
@IsNotEmpty()
@MaxLength(64)
name: string;
}
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@@ -85,11 +68,4 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsString() @IsString()
@IsIn(['read', 'edit']) @IsIn(['read', 'edit'])
defaultPageEditMode: string; defaultPageEditMode: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => TrustedOAuthClientDto)
trustedOauthClients?: TrustedOAuthClient[];
} }
@@ -30,10 +30,7 @@ import { DomainService } from '../../../integrations/environment/domain.service'
import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants'; import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
import { import { isAdminActingOnOwner } from '../workspace.util';
isAdminActingOnOwner,
normalizeTrustedOAuthClients,
} from '../workspace.util';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq'; import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@@ -317,12 +314,6 @@ export class WorkspaceService {
.filter(Boolean); .filter(Boolean);
} }
if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') {
updateWorkspaceDto.trustedOauthClients = normalizeTrustedOAuthClients(
updateWorkspaceDto.trustedOauthClients,
);
}
if (updateWorkspaceDto.hostname) { if (updateWorkspaceDto.hostname) {
const hostname = updateWorkspaceDto.hostname; const hostname = updateWorkspaceDto.hostname;
if (DISALLOWED_HOSTNAMES.includes(hostname)) { if (DISALLOWED_HOSTNAMES.includes(hostname)) {
@@ -343,8 +334,7 @@ export class WorkspaceService {
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' || typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' || typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' || typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' || typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined'
typeof updateWorkspaceDto.trustedOauthClients !== 'undefined'
) { ) {
const ws = await this.db const ws = await this.db
.selectFrom('workspaces') .selectFrom('workspaces')
@@ -372,18 +362,6 @@ export class WorkspaceService {
} }
} }
if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
Feature.OAUTH,
ws.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') { if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
if ( if (
!this.licenseCheckService.hasFeature( !this.licenseCheckService.hasFeature(
@@ -611,7 +589,6 @@ export class WorkspaceService {
'enforceMfa', 'enforceMfa',
'emailDomains', 'emailDomains',
'isScimEnabled', 'isScimEnabled',
'trustedOauthClients',
], ],
updateWorkspaceDto, updateWorkspaceDto,
workspaceBefore, workspaceBefore,
@@ -1,49 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { normalizeTrustedOAuthClients } from './workspace.util';
describe('normalizeTrustedOAuthClients', () => {
it('lowercases origins and trims names', () => {
expect(
normalizeTrustedOAuthClients([
{ origin: 'https://mcp.acme.com', name: ' Acme MCP ' },
]),
).toEqual([{ origin: 'https://mcp.acme.com', name: 'Acme MCP' }]);
});
it('dedupes origins case-insensitively with the last entry winning', () => {
expect(
normalizeTrustedOAuthClients([
{ origin: 'https://mcp.acme.com', name: 'First' },
{ origin: 'https://mcp.acme.com', name: 'Second' },
]),
).toEqual([{ origin: 'https://mcp.acme.com', name: 'Second' }]);
});
it.each([
['http origin', 'http://mcp.acme.com'],
['trailing slash', 'https://mcp.acme.com/'],
['path suffix', 'https://mcp.acme.com/oauth'],
['uppercase host', 'https://MCP.acme.com'],
['not a url', 'mcp.acme.com'],
])('rejects %s naming the origin', (_label, origin) => {
expect(() =>
normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]),
).toThrow(BadRequestException);
expect(() =>
normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]),
).toThrow(origin);
});
it.each([
['blank', ' '],
['too long', 'x'.repeat(65)],
])('rejects a %s name', (_label, name) => {
expect(() =>
normalizeTrustedOAuthClients([{ origin: 'https://mcp.acme.com', name }]),
).toThrow(BadRequestException);
});
it('returns an empty array for no entries', () => {
expect(normalizeTrustedOAuthClients([])).toEqual([]);
});
});
@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import { UserRole } from '../../common/helpers/types/permission'; import { UserRole } from '../../common/helpers/types/permission';
export function isAdminActingOnOwner( export function isAdminActingOnOwner(
@@ -8,39 +7,6 @@ export function isAdminActingOnOwner(
return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER; return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER;
} }
export type TrustedOAuthClient = { origin: string; name: string };
// Origins must be exact https origins; duplicates collapse case-insensitively, last entry wins.
export function normalizeTrustedOAuthClients(
entries: { origin: string; name: string }[],
): TrustedOAuthClient[] {
const byOrigin = new Map<string, TrustedOAuthClient>();
for (const entry of entries) {
const name = entry.name.trim();
if (name.length < 1 || name.length > 64) {
throw new BadRequestException(
`Invalid trusted application name for origin: ${entry.origin}`,
);
}
let parsed: URL;
try {
parsed = new URL(entry.origin);
} catch {
throw new BadRequestException(
`Invalid trusted application origin: ${entry.origin}`,
);
}
if (parsed.protocol !== 'https:' || parsed.origin !== entry.origin) {
throw new BadRequestException(
`Trusted application origin must be an exact https origin: ${entry.origin}`,
);
}
const origin = entry.origin.toLowerCase();
byOrigin.set(origin, { origin, name });
}
return Array.from(byOrigin.values());
}
export type PageEditMode = 'read' | 'edit'; export type PageEditMode = 'read' | 'edit';
export function getWorkspaceDefaultPageEditMode( export function getWorkspaceDefaultPageEditMode(
@@ -8,6 +8,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
export class SpaceEvent { export class SpaceEvent {
spaceId: string; spaceId: string;
workspaceId: string;
} }
@Injectable() @Injectable()
@@ -22,12 +23,12 @@ export class SpaceListener {
@OnEvent(EventName.SPACE_DELETED) @OnEvent(EventName.SPACE_DELETED)
async handleSpaceDeleted(event: SpaceEvent) { async handleSpaceDeleted(event: SpaceEvent) {
const { spaceId } = event; const { spaceId, workspaceId } = event;
if (this.isTypesense()) { if (this.isTypesense()) {
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId }); await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
} }
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId }); await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId, workspaceId });
} }
isTypesense(): boolean { isTypesense(): boolean {
@@ -1,96 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('oauth_clients')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
.addColumn('name', 'text', (col) => col.notNull())
.addColumn('redirect_uris', 'jsonb', (col) => col.notNull())
.addColumn('client_uri', 'text')
.addColumn('logo_uri', 'text')
.addColumn('grant_types', 'jsonb', (col) => col.notNull())
.addColumn('scopes', 'jsonb', (col) => col.notNull())
.addColumn('token_endpoint_auth_method', 'text', (col) => col.notNull().defaultTo('none'))
.addColumn('secret_hash', 'text')
.addColumn('is_dynamic', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('deleted_at', 'timestamptz')
.execute();
await db.schema.createIndex('oauth_clients_workspace_id_idx').on('oauth_clients').column('workspace_id').execute();
await db.schema
.createTable('oauth_authorization_codes')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
.addColumn('code_hash', 'text', (col) => col.notNull().unique())
.addColumn('client_id', 'uuid', (col) => col.notNull().references('oauth_clients.id').onDelete('cascade'))
.addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id').onDelete('cascade'))
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
.addColumn('scopes', 'jsonb', (col) => col.notNull())
.addColumn('redirect_uri', 'text', (col) => col.notNull())
.addColumn('code_challenge', 'text')
.addColumn('code_challenge_method', 'text')
.addColumn('expires_at', 'timestamptz', (col) => col.notNull())
.addColumn('consumed_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.execute();
await db.schema.createIndex('oauth_authorization_codes_expires_at_idx').on('oauth_authorization_codes').column('expires_at').execute();
await db.schema.createIndex('oauth_authorization_codes_consumed_at_idx').on('oauth_authorization_codes').column('consumed_at').execute();
await db.schema.createIndex('oauth_authorization_codes_client_id_idx').on('oauth_authorization_codes').column('client_id').execute();
await db.schema.createIndex('oauth_authorization_codes_user_id_idx').on('oauth_authorization_codes').column('user_id').execute();
await db.schema.createIndex('oauth_authorization_codes_workspace_id_idx').on('oauth_authorization_codes').column('workspace_id').execute();
await db.schema
.createTable('oauth_grants')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
.addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id').onDelete('cascade'))
.addColumn('client_id', 'uuid', (col) => col.notNull().references('oauth_clients.id').onDelete('cascade'))
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
.addColumn('scopes', 'jsonb', (col) => col.notNull())
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.addColumn('last_used_at', 'timestamptz')
.addColumn('revoked_at', 'timestamptz')
.addUniqueConstraint('oauth_grants_user_client_unique', ['user_id', 'client_id'])
.execute();
// The user_id/client_id unique constraint cannot serve client-side FK lookups.
await db.schema.createIndex('oauth_grants_client_id_idx').on('oauth_grants').column('client_id').execute();
await db.schema
.createTable('oauth_tokens')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
.addColumn('grant_id', 'uuid', (col) => col.notNull().references('oauth_grants.id').onDelete('cascade'))
.addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
.addColumn('access_token_jti', 'text', (col) => col.notNull())
.addColumn('refresh_token_hash', 'text', (col) => col.unique())
.addColumn('scopes', 'jsonb', (col) => col.notNull())
.addColumn('access_expires_at', 'timestamptz', (col) => col.notNull())
.addColumn('refresh_expires_at', 'timestamptz')
.addColumn('revoked_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.execute();
await db.schema.createIndex('oauth_tokens_grant_id_idx').on('oauth_tokens').column('grant_id').execute();
await db.schema.createIndex('oauth_tokens_access_token_jti_idx').on('oauth_tokens').columns(['workspace_id', 'access_token_jti']).execute();
// One index per branch of the cleanup sweep's OR so it can bitmap-or them.
await db.schema.createIndex('oauth_tokens_access_expires_at_idx').on('oauth_tokens').column('access_expires_at').execute();
await db.schema.createIndex('oauth_tokens_refresh_expires_at_idx').on('oauth_tokens').column('refresh_expires_at').execute();
await db.schema.createIndex('oauth_tokens_revoked_at_idx').on('oauth_tokens').column('revoked_at').execute();
await db.schema
.alterTable('workspaces')
.addColumn('trusted_oauth_clients', 'jsonb', (col) => col.defaultTo(sql`'[]'::jsonb`))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE workspaces DROP COLUMN IF EXISTS trusted_oauth_clients`.execute(
db,
);
await db.schema.dropTable('oauth_tokens').execute();
await db.schema.dropTable('oauth_grants').execute();
await db.schema.dropTable('oauth_authorization_codes').execute();
await db.schema.dropTable('oauth_clients').execute();
}
@@ -230,6 +230,7 @@ export class SpaceRepo {
this.eventEmitter.emit(EventName.SPACE_DELETED, { this.eventEmitter.emit(EventName.SPACE_DELETED, {
spaceId, spaceId,
workspaceId,
}); });
} }
} }
@@ -20,7 +20,6 @@ export class WorkspaceRepo {
'hostname', 'hostname',
'customDomain', 'customDomain',
'settings', 'settings',
'trustedOauthClients',
'defaultRole', 'defaultRole',
'emailDomains', 'emailDomains',
'defaultSpaceId', 'defaultSpaceId',
@@ -212,6 +211,24 @@ export class WorkspaceRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async updateAiEmbeddingFingerprint(
workspaceId: string,
fingerprint: { driver: string; model: string; dimensions: number },
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('workspaces')
.set({
settings: sql`COALESCE(settings, '{}'::jsonb)
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|| jsonb_build_object('embedding', ${JSON.stringify(fingerprint)}::text::jsonb))`,
updatedAt: new Date(),
})
.where('id', '=', workspaceId)
.execute();
}
async updateSharingSettings( async updateSharingSettings(
workspaceId: string, workspaceId: string,
prefKey: string, prefKey: string,
-62
View File
@@ -460,7 +460,6 @@ export interface Workspaces {
status: string | null; status: string | null;
stripeCustomerId: string | null; stripeCustomerId: string | null;
trialEndAt: Timestamp | null; trialEndAt: Timestamp | null;
trustedOauthClients: Generated<Json | null>;
updatedAt: Generated<Timestamp>; updatedAt: Generated<Timestamp>;
} }
@@ -637,63 +636,6 @@ export interface UserSessions {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
} }
export interface OauthAuthorizationCodes {
clientId: string;
codeChallenge: string | null;
codeChallengeMethod: string | null;
codeHash: string;
consumedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
expiresAt: Timestamp;
id: Generated<string>;
redirectUri: string;
scopes: Json;
userId: string;
workspaceId: string;
}
export interface OauthClients {
clientUri: string | null;
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
grantTypes: Json;
id: Generated<string>;
isDynamic: Generated<boolean>;
logoUri: string | null;
name: string;
redirectUris: Json;
scopes: Json;
secretHash: string | null;
tokenEndpointAuthMethod: Generated<string>;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface OauthGrants {
clientId: string;
createdAt: Generated<Timestamp>;
id: Generated<string>;
lastUsedAt: Timestamp | null;
revokedAt: Timestamp | null;
scopes: Json;
updatedAt: Generated<Timestamp>;
userId: string;
workspaceId: string;
}
export interface OauthTokens {
accessExpiresAt: Timestamp;
accessTokenJti: string;
createdAt: Generated<Timestamp>;
grantId: string;
id: Generated<string>;
refreshExpiresAt: Timestamp | null;
refreshTokenHash: string | null;
revokedAt: Timestamp | null;
scopes: Json;
workspaceId: string;
}
export interface DB { export interface DB {
aiChats: AiChats; aiChats: AiChats;
aiChatMessages: AiChatMessages; aiChatMessages: AiChatMessages;
@@ -735,8 +677,4 @@ export interface DB {
watchers: Watchers; watchers: Watchers;
workspaceInvitations: WorkspaceInvitations; workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces; workspaces: Workspaces;
oauthAuthorizationCodes: OauthAuthorizationCodes;
oauthClients: OauthClients;
oauthGrants: OauthGrants;
oauthTokens: OauthTokens;
} }
@@ -40,10 +40,6 @@ import {
Watchers, Watchers,
Audit as _Audit, Audit as _Audit,
Templates, Templates,
OauthClients,
OauthAuthorizationCodes,
OauthGrants,
OauthTokens,
} from './db'; } from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
@@ -188,13 +184,6 @@ export type ScimToken = Selectable<ScimTokens>;
export type InsertableScimToken = Insertable<ScimTokens>; export type InsertableScimToken = Insertable<ScimTokens>;
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>; export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
// OAuth
export type OAuthClient = Selectable<OauthClients>;
export type InsertableOAuthClient = Insertable<OauthClients>;
export type OAuthGrant = Selectable<OauthGrants>;
export type OAuthAuthorizationCode = Selectable<OauthAuthorizationCodes>;
export type OAuthToken = Selectable<OauthTokens>;
// Page Embedding // Page Embedding
export type PageEmbedding = Selectable<PageEmbeddings>; export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>; export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
@@ -310,6 +310,31 @@ export class EnvironmentService {
return val === 'true'; return val === 'true';
} }
getAiVectorDriver(): string {
return this.configService
.get<string>('AI_VECTOR_DRIVER', 'pgvector')
.toLowerCase();
}
getTurbopufferApiKey(): string {
return this.configService.get<string>('TURBOPUFFER_API_KEY');
}
getTurbopufferRegion(): string {
return this.configService.get<string>('TURBOPUFFER_REGION');
}
getTurbopufferBaseUrl(): string {
return this.configService.get<string>('TURBOPUFFER_BASE_URL');
}
getTurbopufferNamespacePrefix(): string {
return this.configService.get<string>(
'TURBOPUFFER_NAMESPACE_PREFIX',
'docmost',
);
}
getOpenAiApiKey(): string { getOpenAiApiKey(): string {
return this.configService.get<string>('OPENAI_API_KEY'); return this.configService.get<string>('OPENAI_API_KEY');
} }
@@ -5,6 +5,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
IsUrl, IsUrl,
Matches,
MinLength, MinLength,
ValidateIf, ValidateIf,
validateSync, validateSync,
@@ -108,6 +109,41 @@ export class EnvironmentVariables {
@IsString() @IsString()
AI_DRIVER: string; AI_DRIVER: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER)
@IsIn(['pgvector', 'turbopuffer'])
@IsString()
AI_VECTOR_DRIVER: string;
@ValidateIf((obj) => obj.AI_VECTOR_DRIVER === 'turbopuffer')
@IsNotEmpty()
@IsString()
TURBOPUFFER_API_KEY: string;
@ValidateIf(
(obj) =>
obj.AI_VECTOR_DRIVER === 'turbopuffer' && !obj.TURBOPUFFER_BASE_URL,
)
@IsNotEmpty({
message:
'TURBOPUFFER_REGION is required when AI_VECTOR_DRIVER is turbopuffer, unless TURBOPUFFER_BASE_URL is set',
})
@IsString()
TURBOPUFFER_REGION: string;
@IsOptional()
@ValidateIf((obj) => obj.TURBOPUFFER_BASE_URL != '' && obj.TURBOPUFFER_BASE_URL != null)
@IsUrl({ protocols: ['http', 'https'], require_tld: false })
TURBOPUFFER_BASE_URL: string;
@IsOptional()
@IsString()
@Matches(/^[A-Za-z0-9\-_.]{1,90}$/, {
message:
'TURBOPUFFER_NAMESPACE_PREFIX may only contain letters, digits, dot, dash, underscore (max 90 chars)',
})
TURBOPUFFER_NAMESPACE_PREFIX: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
AI_EMBEDDING_MODEL: string; AI_EMBEDDING_MODEL: string;
@@ -61,6 +61,7 @@ export enum QueueJob {
WORKSPACE_DELETED = 'workspace-deleted', WORKSPACE_DELETED = 'workspace-deleted',
WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings', WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings',
WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings', WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings',
WORKSPACE_RESET_EMBEDDINGS = 'workspace-reset-embeddings',
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings', GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings', DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
@@ -49,6 +49,10 @@ export class StaticModule implements OnModuleInit {
: undefined, : undefined,
POSTHOG_HOST: this.environmentService.getPostHogHost(), POSTHOG_HOST: this.environmentService.getPostHogHost(),
POSTHOG_KEY: this.environmentService.getPostHogKey(), POSTHOG_KEY: this.environmentService.getPostHogKey(),
AI_VECTOR_DRIVER:
this.environmentService.getAiVectorDriver() === 'turbopuffer'
? 'turbopuffer'
: undefined,
}; };
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`; const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
@@ -4,13 +4,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from '../environment/environment.service';
import { EnvironmentModule } from '../environment/environment.module'; import { EnvironmentModule } from '../environment/environment.module';
import { parseRedisUrl } from '../../common/helpers'; import { parseRedisUrl } from '../../common/helpers';
import { import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
AUTH_THROTTLER,
AI_CHAT_THROTTLER,
OAUTH_REGISTER_THROTTLER,
OAUTH_TOKEN_THROTTLER,
OAUTH_AUTHORIZE_THROTTLER,
} from './throttler-names';
import Redis from 'ioredis'; import Redis from 'ioredis';
@Module({ @Module({
@@ -24,9 +18,6 @@ import Redis from 'ioredis';
throttlers: [ throttlers: [
{ name: AUTH_THROTTLER, ttl: 60_000, limit: 10 }, { name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
{ name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 }, { name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 },
{ name: OAUTH_REGISTER_THROTTLER, ttl: 3_600_000, limit: 10 },
{ name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 },
{ name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
], ],
errorMessage: 'Too many requests', errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService( storage: new ThrottlerStorageRedisService(
@@ -1,14 +1,2 @@
export const AUTH_THROTTLER = 'auth'; export const AUTH_THROTTLER = 'auth';
export const AI_CHAT_THROTTLER = 'ai-chat'; export const AI_CHAT_THROTTLER = 'ai-chat';
export const OAUTH_REGISTER_THROTTLER = 'oauth-register';
export const OAUTH_TOKEN_THROTTLER = 'oauth-token';
export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize';
// Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false.
export const ALL_NAMED_THROTTLERS_SKIPPED: Record<string, boolean> = {
[AUTH_THROTTLER]: true,
[AI_CHAT_THROTTLER]: true,
[OAUTH_REGISTER_THROTTLER]: true,
[OAUTH_TOKEN_THROTTLER]: true,
[OAUTH_AUTHORIZE_THROTTLER]: true,
};
+6 -18
View File
@@ -13,10 +13,7 @@ import fastifyCookie from '@fastify/cookie';
import fastifyIp from 'fastify-ip'; import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter'; import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service'; import { EnvironmentService } from './integrations/environment/environment.service';
import { import { resolveFrameHeader } from './common/helpers';
resolveFrameHeader,
resolveFrameHeadersForPath,
} from './common/helpers';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
@@ -42,14 +39,7 @@ async function bootstrap() {
app.useLogger(app.get(PinoLogger)); app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', { app.setGlobalPrefix('api', {
exclude: [ exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'],
'robots.txt',
'share/:shareId/p/:pageSlug',
'mcp',
'.well-known/oauth-authorization-server',
'.well-known/oauth-protected-resource',
'.well-known/oauth-protected-resource/mcp',
],
}); });
const reflector = app.get(Reflector); const reflector = app.get(Reflector);
@@ -67,9 +57,10 @@ async function bootstrap() {
environmentService.isIframeEmbedAllowed(), environmentService.isIframeEmbedAllowed(),
environmentService.getIframeAllowedOrigins(), environmentService.getIframeAllowedOrigins(),
); );
if (frameHeader) {
// Skipped routes: // Skipped routes:
// /api/files/ - attachment controller sets its own CSP we'd overwrite // /api/files/ - attachment controller sets its own CSP we'd overwrite
// /share/ - public share pages are safe to embed // /share/ 0 public share pages are safe to embed
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/']; const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
app app
.getHttpAdapter() .getHttpAdapter()
@@ -78,13 +69,10 @@ async function bootstrap() {
if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) { if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
return done(null, payload); return done(null, payload);
} }
const path = req.url.split('?')[0]; reply.header(frameHeader.name, frameHeader.value);
// Force-denies the oauth consent screen even when the global frame header is absent.
for (const header of resolveFrameHeadersForPath(path, frameHeader)) {
reply.header(header.name, header.value);
}
done(null, payload); done(null, payload);
}); });
}
app app
.getHttpAdapter() .getHttpAdapter()
+16 -13
View File
@@ -524,9 +524,6 @@ importers:
'@fastify/static': '@fastify/static':
specifier: 10.1.2 specifier: 10.1.2
version: 10.1.2 version: 10.1.2
'@jmondi/oauth2-server':
specifier: ^4.3.7
version: 4.3.7
'@keyv/redis': '@keyv/redis':
specifier: 5.1.6 specifier: 5.1.6
version: 5.1.6(keyv@5.6.0) version: 5.1.6(keyv@5.6.0)
@@ -596,6 +593,9 @@ importers:
'@socket.io/redis-adapter': '@socket.io/redis-adapter':
specifier: 8.3.0 specifier: 8.3.0
version: 8.3.0(socket.io-adapter@2.5.4(supports-color@10.2.2))(supports-color@10.2.2) version: 8.3.0(socket.io-adapter@2.5.4(supports-color@10.2.2))(supports-color@10.2.2)
'@turbopuffer/turbopuffer':
specifier: ^2.8.0
version: 2.8.0
ai: ai:
specifier: 6.0.134 specifier: 6.0.134
version: 6.0.134(zod@4.3.6) version: 6.0.134(zod@4.3.6)
@@ -2522,10 +2522,6 @@ packages:
resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
'@jmondi/oauth2-server@4.3.7':
resolution: {integrity: sha512-Mo0HHIB/TVRETVSxzvmyclfNPn0HnvSyrLoqvrtlk0z3oWMUr1CFRc7msDNQm6vRJIbRGnCbNKuAwXiNaS6xfw==}
engines: {node: '>=16'}
'@joplin/turndown-plugin-gfm@1.0.64': '@joplin/turndown-plugin-gfm@1.0.64':
resolution: {integrity: sha512-8GJ7f9OenE3zkSVII5B6qzIkvgF7C/a20gaASEjM6jWPLPJFFQ2nQ3Ou/kXH1mPUTs9dC9VYs8QXVPvZabKXBQ==} resolution: {integrity: sha512-8GJ7f9OenE3zkSVII5B6qzIkvgF7C/a20gaASEjM6jWPLPJFFQ2nQ3Ou/kXH1mPUTs9dC9VYs8QXVPvZabKXBQ==}
@@ -4389,6 +4385,9 @@ packages:
'@tsconfig/node16@1.0.4': '@tsconfig/node16@1.0.4':
resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==}
'@turbopuffer/turbopuffer@2.8.0':
resolution: {integrity: sha512-lurmVByLynuWtPtVl7SBEJ0bNakb8bs4KsafKzeLWnXA6BsQiGS9obT/7ExQVb9/yecyRn/grj8VgwUzi2xMyg==}
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -8176,6 +8175,9 @@ packages:
pako@2.0.3: pako@2.0.3:
resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==} resolution: {integrity: sha512-WjR1hOeg+kki3ZIOjaf4b5WVcay1jaliKSYiEaB1XzwhMQZJxRdQRv0V31EKBYlxb4T7SK3hjfc/jxyU64BoSw==}
pako@2.2.0:
resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
parent-module@1.0.1: parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -12437,12 +12439,6 @@ snapshots:
'@types/yargs': 17.0.35 '@types/yargs': 17.0.35
chalk: 4.1.2 chalk: 4.1.2
'@jmondi/oauth2-server@4.3.7':
dependencies:
jsonwebtoken: 9.0.3
ms: 2.1.3
uri-js: 4.4.1
'@joplin/turndown-plugin-gfm@1.0.64': {} '@joplin/turndown-plugin-gfm@1.0.64': {}
'@joplin/turndown@4.0.82(supports-color@7.2.0)': '@joplin/turndown@4.0.82(supports-color@7.2.0)':
@@ -14355,6 +14351,11 @@ snapshots:
'@tsconfig/node16@1.0.4': {} '@tsconfig/node16@1.0.4': {}
'@turbopuffer/turbopuffer@2.8.0':
dependencies:
pako: 2.2.0
undici: 7.29.0
'@tybys/wasm-util@0.10.2': '@tybys/wasm-util@0.10.2':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -18763,6 +18764,8 @@ snapshots:
pako@2.0.3: {} pako@2.0.3: {}
pako@2.2.0: {}
parent-module@1.0.1: parent-module@1.0.1:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0