Compare commits

..
Author SHA1 Message Date
Philipinho 64e47b5679 Merge branch 'main' into fix/package-updates 2026-08-19 19:56:49 +01:00
Philipinho 1ad94c03dc chore: package updates 2026-08-19 19:52:22 +01:00
Philip Okugbe 232beda471 feat(ee): turbopuffer ai vector search driver (#2402)
* feat(ai): add AI_VECTOR_DRIVER and turbopuffer configuration

* feat(ai): carry workspace and target space ids on vector lifecycle events

* feat(ai): add vector driver interface and turbopuffer request helpers

* feat(ai): add pgvector driver behind the vector driver interface

* refactor(ai): route vector reads and writes through the vector driver

* feat(ai): add turbopuffer vector driver

* feat(ai): rebuild turbopuffer namespaces when the embedding model changes

* feat(ai): warm the vector namespace cache on session start

* fix(ai): harden turbopuffer misconfiguration and reset failure paths

* fix(ai): collapse blank-line runs in extracted page text

* fix(ai): skip full re-embed when ai search is re-enabled within the delete grace window

* sync

* fix(ai): store real embedding dimensions instead of serialized vector length

* fix(ai): filter search hits by the page's current space at query time

* fix(ai): retry the page moved-to-space vector patch job

* sync

* feat(ai): pre-warm the vector namespace

* fix(ai): pass AI_VECTOR_DRIVER through the client build config
2026-08-17 21:18:16 +01:00
Philip Okugbe 911c1057d6 feat(server): add global encryption module (AES-256-GCM) (#2400)
Provides an injectable EncryptionService that encrypts/decrypts strings
with AES-256-GCM using a key derived from APP_SECRET with domain
separation.
2026-08-16 12:35:37 +01:00
Philipinho d136864ef1 sync 2026-08-16 11:46:50 +01:00
Philip Okugbe c093c18bf3 fix(client): keep space switcher dropdown inside its popover (#2392) 2026-08-14 02:08:28 +01:00
Philip Okugbe ea59912c7e feat(editor): auto-detect text direction for RTL support (#2389) 2026-08-12 23:48:03 +01:00
Philipinho db3ff54da1 fix base node 2026-08-12 16:47:32 +01:00
Philip Okugbe 9414a38215 feat: page attachments endpoint and modal (#2386)
* feat: page attachments endpoint

* feat: attachments modal
2026-08-12 14:04:33 +01:00
39 changed files with 720 additions and 313 deletions
+1 -1
View File
@@ -52,7 +52,7 @@
"mantine-form-zod-resolver": "1.3.0",
"mermaid": "11.16.1",
"mitt": "3.0.1",
"nanoid": "3.3.17",
"nanoid": "3.3.18",
"posthog-js": "1.391.2",
"react": "19.2.7",
"react-clear-modal": "^2.0.18",
@@ -255,6 +255,7 @@ export default function ChatInput({
},
content: "",
editable: true,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: autofocus ? "end" : false,
@@ -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(
params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
@@ -41,6 +41,7 @@ export default function ReadonlyTemplateEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={template.content}
/>
@@ -87,6 +87,7 @@ export default function TemplateEditor() {
const editor = useEditor({
extensions: templateExtensions,
content: "",
textDirection: "auto",
editorProps: {
scrollThreshold: 80,
scrollMargin: 80,
@@ -103,6 +103,7 @@ const CommentEditor = forwardRef(
},
content: defaultContent,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
autofocus: (autofocus && "end") || false,
@@ -40,6 +40,7 @@ export default function TransclusionContent({ content }: Props) {
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content as any}
/>
@@ -249,6 +249,7 @@ function CollabPageEditor({
{
extensions,
editable,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -484,6 +485,7 @@ function StaticPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={mainExtensions}
content={content}
editorProps={{
@@ -85,6 +85,7 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={titleExtensions}
content={title}
></EditorProvider>
@@ -93,6 +94,7 @@ export default function ReadonlyPageEditor({
<EditorProvider
editable={false}
immediatelyRender={true}
textDirection="auto"
extensions={extensions}
content={content}
onCreate={({ editor }) => {
@@ -54,7 +54,7 @@
var(--mantine-color-dark-5)
);
font-weight: bold;
text-align: left;
text-align: start;
}
.column-resize-handle {
@@ -86,6 +86,7 @@ export function TitleEditor({
},
editable: editable,
content: title,
textDirection: "auto",
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
@@ -31,6 +31,7 @@ export function HistoryEditor({
const editor = useEditor({
extensions: mainExtensions,
editable: false,
textDirection: "auto",
});
useEffect(() => {
@@ -13,11 +13,16 @@ import { SearchResultItem } from "./search-result-item.tsx";
import { AiSearchResult } from "../../../ee/ai/components/ai-search-result.tsx";
import { useHasFeature } from "@/ee/hooks/use-feature";
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 {
spaceId?: string;
}
export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
const workspace = useAtomValue(workspaceAtom);
const { t } = useTranslation();
const hasAiFeature = useHasFeature(Feature.AI);
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) => {
setFilters(newFilters);
};
@@ -115,6 +129,7 @@ export function SearchSpotlight({ spaceId }: SearchSpotlightProps) {
<Spotlight.Root
size="xl"
maxHeight={600}
onSpotlightOpen={handleSpotlightOpen}
store={searchSpotlightStore}
query={query}
onQueryChange={setQuery}
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks";
import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -14,6 +14,7 @@ interface SpaceSelectProps {
width?: number;
opened?: boolean;
clearable?: boolean;
withinPortal?: boolean;
}
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
@@ -41,6 +42,7 @@ export function SpaceSelect({
width,
opened,
clearable,
withinPortal = true,
}: SpaceSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
@@ -50,9 +52,13 @@ export function SpaceSelect({
limit: 50,
});
const [data, setData] = useState([]);
const fetchedSpaces = useRef(new Map<string, ISpace>());
useEffect(() => {
if (spaces) {
spaces.items.forEach((space: ISpace) =>
fetchedSpaces.current.set(space.slug, space),
);
const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => {
@@ -83,14 +89,19 @@ export function SpaceSelect({
onSearchChange={setSearchValue}
clearable={clearable}
variant="filled"
onChange={(slug) =>
onChange(spaces.items?.find((item) => item.slug === slug))
}
onChange={(slug) => {
// options accumulate across fetches; resolve against everything
// fetched, not just the latest query result
const space = slug && fetchedSpaces.current.get(slug);
if (space) {
onChange(space);
}
}}
onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")}
limit={50}
checkIconPosition="right"
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened}
/>
);
@@ -70,6 +70,7 @@ export function SwitchSpace({
onChange={(space) => handleSelect(space.slug)}
width={300}
opened={true}
withinPortal={false}
/>
</Popover.Dropdown>
</Popover>
+4
View File
@@ -43,6 +43,10 @@ export function isCloud(): boolean {
return castToBoolean(getConfigValue("CLOUD"));
}
export function getAiVectorDriver(): string {
return getConfigValue("AI_VECTOR_DRIVER");
}
export function getAvatarUrl(
avatarUrl: string,
type: AvatarIconType = AvatarIconType.AVATAR,
+2
View File
@@ -16,6 +16,7 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
AI_VECTOR_DRIVER,
} = loadEnv(mode, envPath, "");
return {
@@ -31,6 +32,7 @@ export default defineConfig(({ mode }) => {
BILLING_TRIAL_DAYS,
POSTHOG_HOST,
POSTHOG_KEY,
AI_VECTOR_DRIVER,
},
APP_VERSION: JSON.stringify(process.env.npm_package_version),
},
+11 -10
View File
@@ -40,32 +40,33 @@
"@clickhouse/client": "1.18.2",
"@docmost/base-formula": "workspace:*",
"@docmost/pdf-inspector": "1.9.6",
"@fastify/cookie": "11.0.2",
"@fastify/multipart": "10.0.0",
"@fastify/static": "10.1.2",
"@fastify/cookie": "11.1.2",
"@fastify/multipart": "10.1.1",
"@fastify/static": "10.1.3",
"@keyv/redis": "5.1.6",
"@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "1.30.0",
"@nest-lab/throttler-storage-redis": "1.2.0",
"@nestjs-labs/nestjs-ioredis": "11.0.4",
"@nestjs/bullmq": "11.0.4",
"@nestjs/bullmq": "11.0.5",
"@nestjs/cache-manager": "3.1.3",
"@nestjs/common": "11.1.28",
"@nestjs/common": "11.2.1",
"@nestjs/config": "4.0.4",
"@nestjs/core": "11.1.27",
"@nestjs/core": "11.2.1",
"@nestjs/event-emitter": "3.1.0",
"@nestjs/jwt": "11.0.2",
"@nestjs/mapped-types": "2.1.1",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-fastify": "11.1.28",
"@nestjs/platform-socket.io": "11.1.28",
"@nestjs/platform-fastify": "11.2.1",
"@nestjs/platform-socket.io": "11.2.1",
"@nestjs/schedule": "6.1.3",
"@nestjs/terminus": "11.1.1",
"@nestjs/throttler": "6.5.0",
"@nestjs/websockets": "11.1.28",
"@nestjs/websockets": "11.2.1",
"@node-saml/passport-saml": "5.1.0",
"@socket.io/redis-adapter": "8.3.0",
"@turbopuffer/turbopuffer": "^2.8.0",
"ai": "6.0.134",
"ai-sdk-ollama": "3.8.1",
"bcrypt": "6.0.0",
@@ -119,7 +120,7 @@
"tmp-promise": "3.0.3",
"typesense": "3.0.5",
"undici": "7.29.0",
"ws": "8.21.0",
"ws": "8.21.3",
"yauzl": "3.4.0",
"zod": "4.3.6"
},
+2
View File
@@ -27,6 +27,7 @@ import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
import { EncryptionModule } from './integrations/encryption/encryption.module';
const enterpriseModules = [];
try {
@@ -53,6 +54,7 @@ try {
CoreModule,
DatabaseModule,
EnvironmentModule,
EncryptionModule,
RedisModule.forRootAsync({
useClass: RedisConfigService,
}),
@@ -50,8 +50,14 @@ import {
Footnote,
FootnoteReference,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import {
extensions as coreExtensions,
generateText,
getSchema,
JSONContent,
} from '@tiptap/core';
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
// see: https://github.com/ueberdosis/tiptap/issues/5352
// see:https://github.com/ueberdosis/tiptap/issues/4089
@@ -61,6 +67,7 @@ import * as Y from 'yjs';
import { Logger } from '@nestjs/common';
export const tiptapExtensions = [
coreExtensions.TextDirection.configure({ direction: 'auto' }),
StarterKit.configure({
document: false,
codeBlock: false,
@@ -140,7 +147,7 @@ export function htmlToJson(html: string) {
}
export function jsonToText(tiptapJson: JSONContent) {
return generateText(tiptapJson, tiptapExtensions);
return collapseBlankLines(generateText(tiptapJson, tiptapExtensions));
}
export function jsonToNode(tiptapJson: JSONContent) {
+1
View File
@@ -1,4 +1,5 @@
export * from './utils';
export * from './text.utils';
export * from './nanoid.utils';
export * from './file.helper';
export * from './constants';
@@ -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');
}
@@ -496,10 +496,21 @@ export class PageService {
},
);
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageIds: pageIdsToMove,
workspaceId: rootPage.workspaceId,
});
await this.aiQueue.add(
QueueJob.PAGE_MOVED_TO_SPACE,
{
pageIds: pageIdsToMove,
spaceId,
workspaceId: rootPage.workspaceId,
},
{
attempts: 2,
backoff: {
type: 'fixed',
delay: 2 * 60 * 1000,
},
},
);
}
});
@@ -8,6 +8,7 @@ import { EnvironmentService } from '../../integrations/environment/environment.s
export class SpaceEvent {
spaceId: string;
workspaceId: string;
}
@Injectable()
@@ -22,12 +23,12 @@ export class SpaceListener {
@OnEvent(EventName.SPACE_DELETED)
async handleSpaceDeleted(event: SpaceEvent) {
const { spaceId } = event;
const { spaceId, workspaceId } = event;
if (this.isTypesense()) {
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 {
@@ -230,6 +230,7 @@ export class SpaceRepo {
this.eventEmitter.emit(EventName.SPACE_DELETED, {
spaceId,
workspaceId,
});
}
}
@@ -211,6 +211,24 @@ export class WorkspaceRepo {
.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(
workspaceId: string,
prefKey: string,
@@ -0,0 +1,13 @@
export class UnableToInitialize extends Error {
constructor(message: string) {
super(`Unable to initialize the encryption service: ${message}`);
this.name = 'UnableToInitialize';
}
}
export class UnableToDecrypt extends Error {
constructor(reason: string) {
super(`Unable to decrypt the ciphertext: ${reason}`);
this.name = 'UnableToDecrypt';
}
}
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { EncryptionService } from './encryption.service';
@Global()
@Module({
providers: [EncryptionService],
exports: [EncryptionService],
})
export class EncryptionModule {}
@@ -0,0 +1,184 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EncryptionService } from './encryption.service';
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
import { EnvironmentService } from '../environment/environment.service';
const APP_SECRET = 'test-app-secret-with-plenty-of-entropy-1234567890';
const buildService = (appSecret: string | undefined) => {
const env = { getAppSecret: () => appSecret } as EnvironmentService;
return new EncryptionService(env);
};
const decodeEnvelope = (encrypted: string) =>
JSON.parse(Buffer.from(encrypted, 'base64').toString()) as {
iv: string;
authTag: string;
cipherText: string;
};
const encodeEnvelope = (envelope: {
iv: string;
authTag: string;
cipherText: string;
}) => Buffer.from(JSON.stringify(envelope)).toString('base64');
describe('EncryptionService', () => {
let service: EncryptionService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
EncryptionService,
{
provide: EnvironmentService,
useValue: { getAppSecret: () => APP_SECRET },
},
],
}).compile();
service = module.get<EncryptionService>(EncryptionService);
});
describe('initialization', () => {
it('compiles via Nest DI', () => {
expect(service).toBeDefined();
});
it('throws UnableToInitialize when APP_SECRET is missing', () => {
expect(() => buildService(undefined)).toThrow(UnableToInitialize);
expect(() => buildService('')).toThrow(UnableToInitialize);
});
});
describe('encrypt + decrypt round-trip', () => {
it('decrypts back to the original plaintext', () => {
const plaintext = 'hello world';
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('handles empty string', () => {
const encrypted = service.encrypt('');
expect(service.decrypt(encrypted)).toBe('');
});
it('handles unicode (multi-byte UTF-8)', () => {
const plaintext = 'héllo 🔐 世界';
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('handles long plaintext (>1 block)', () => {
const plaintext = 'a'.repeat(10_000);
const encrypted = service.encrypt(plaintext);
expect(service.decrypt(encrypted)).toBe(plaintext);
});
it('produces distinct ciphertexts for the same plaintext (random IV)', () => {
const plaintext = 'same input';
const a = service.encrypt(plaintext);
const b = service.encrypt(plaintext);
expect(a).not.toBe(b);
expect(service.decrypt(a)).toBe(plaintext);
expect(service.decrypt(b)).toBe(plaintext);
});
});
describe('cross-key isolation', () => {
it('cannot decrypt ciphertext produced under a different APP_SECRET', () => {
const other = buildService('totally-different-secret-value-9876543210');
const encrypted = service.encrypt('secret');
expect(() => other.decrypt(encrypted)).toThrow(UnableToDecrypt);
});
});
describe('tamper detection', () => {
it('rejects modified ciphertext', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedCipher = Buffer.from(env.cipherText, 'base64');
tamperedCipher[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
cipherText: tamperedCipher.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
it('rejects modified auth tag', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedTag = Buffer.from(env.authTag, 'base64');
tamperedTag[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
authTag: tamperedTag.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
it('rejects modified IV', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const tamperedIV = Buffer.from(env.iv, 'base64');
tamperedIV[0] ^= 0x01;
const tampered = encodeEnvelope({
...env,
iv: tamperedIV.toString('base64'),
});
expect(() => service.decrypt(tampered)).toThrow(UnableToDecrypt);
});
});
describe('malformed payloads', () => {
it('rejects non-base64 garbage', () => {
expect(() => service.decrypt('!!!not-valid-base64!!!')).toThrow(
UnableToDecrypt,
);
});
it('rejects base64 of non-JSON', () => {
const garbage = Buffer.from('not json at all').toString('base64');
expect(() => service.decrypt(garbage)).toThrow(UnableToDecrypt);
});
it('rejects JSON missing required fields', () => {
const partial = encodeEnvelope({
iv: Buffer.alloc(12).toString('base64'),
authTag: Buffer.alloc(16).toString('base64'),
} as never);
expect(() => service.decrypt(partial)).toThrow(UnableToDecrypt);
});
it('rejects wrong-length IV', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const bad = encodeEnvelope({
...env,
iv: Buffer.alloc(8).toString('base64'),
});
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
});
it('rejects wrong-length auth tag', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
const bad = encodeEnvelope({
...env,
authTag: Buffer.alloc(8).toString('base64'),
});
expect(() => service.decrypt(bad)).toThrow(UnableToDecrypt);
});
});
describe('envelope format', () => {
it('returns base64 of JSON envelope with iv (12B), authTag (16B), cipherText', () => {
const encrypted = service.encrypt('hello');
const env = decodeEnvelope(encrypted);
expect(Buffer.from(env.iv, 'base64')).toHaveLength(12);
expect(Buffer.from(env.authTag, 'base64')).toHaveLength(16);
expect(Buffer.from(env.cipherText, 'base64').length).toBeGreaterThan(0);
});
});
});
@@ -0,0 +1,108 @@
// https://github.com/nhedger/nestjs-encryption - MIT
import { Injectable } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
import { UnableToDecrypt, UnableToInitialize } from './encryption.errors';
import { EnvironmentService } from '../environment/environment.service';
const ALGORITHM = 'aes-256-gcm';
const KEY_DOMAIN = 'docmost:encryption:v1';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
type AEADPayload<TFormat = string | Buffer> = {
iv: TFormat;
authTag: TFormat;
cipherText: TFormat;
};
@Injectable()
export class EncryptionService {
private readonly key: Buffer;
constructor(environmentService: EnvironmentService) {
const appSecret = environmentService.getAppSecret();
if (!appSecret) {
throw new UnableToInitialize('APP_SECRET is not set.');
}
this.key = createHash('sha256')
.update(KEY_DOMAIN)
.update(appSecret)
.digest();
}
public encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv);
const cipherText = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag();
const aead: AEADPayload<string> = {
iv: iv.toString('base64'),
authTag: authTag.toString('base64'),
cipherText: cipherText.toString('base64'),
};
return Buffer.from(JSON.stringify(aead)).toString('base64');
}
public decrypt(encrypted: string): string {
try {
const { iv, authTag, cipherText } = this.decodeAEADPayload(encrypted);
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(cipherText),
decipher.final(),
]);
return decrypted.toString('utf8');
} catch (e: unknown) {
throw new UnableToDecrypt((e as Error).message);
}
}
private decodeAEADPayload(encodedPayload: string): AEADPayload<Buffer> {
const payload = Buffer.from(encodedPayload, 'base64');
let deserializedPkg: Record<string, unknown>;
try {
deserializedPkg = JSON.parse(payload.toString());
} catch {
throw new Error('The decoded AEAD payload is not a valid JSON string.');
}
for (const field of ['iv', 'authTag', 'cipherText']) {
if (!Object.prototype.hasOwnProperty.call(deserializedPkg, field)) {
throw new Error(`The AEAD payload is missing the ${field} field.`);
}
}
const iv = Buffer.from(deserializedPkg.iv as string, 'base64');
if (iv.length !== IV_LENGTH) {
throw new Error(
`The decoded IV is not the correct length. Expected ${IV_LENGTH} bytes, got ${iv.length} bytes.`,
);
}
const authTag = Buffer.from(deserializedPkg.authTag as string, 'base64');
if (authTag.length !== AUTH_TAG_LENGTH) {
throw new Error(
`The decoded auth tag is not the correct length. Expected ${AUTH_TAG_LENGTH} bytes, got ${authTag.length} bytes.`,
);
}
const cipherText = Buffer.from(
deserializedPkg.cipherText as string,
'base64',
);
return { iv, authTag, cipherText };
}
}
@@ -310,6 +310,31 @@ export class EnvironmentService {
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 {
return this.configService.get<string>('OPENAI_API_KEY');
}
@@ -5,6 +5,7 @@ import {
IsOptional,
IsString,
IsUrl,
Matches,
MinLength,
ValidateIf,
validateSync,
@@ -108,6 +109,41 @@ export class EnvironmentVariables {
@IsString()
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()
@IsString()
AI_EMBEDDING_MODEL: string;
@@ -61,6 +61,7 @@ export enum QueueJob {
WORKSPACE_DELETED = 'workspace-deleted',
WORKSPACE_CREATE_EMBEDDINGS = 'workspace-create-embeddings',
WORKSPACE_DELETE_EMBEDDINGS = 'workspace-delete-embeddings',
WORKSPACE_RESET_EMBEDDINGS = 'workspace-reset-embeddings',
GENERATE_PAGE_EMBEDDINGS = 'generate-page-embeddings',
DELETE_PAGE_EMBEDDINGS = 'delete-page-embeddings',
@@ -49,6 +49,10 @@ export class StaticModule implements OnModuleInit {
: undefined,
POSTHOG_HOST: this.environmentService.getPostHogHost(),
POSTHOG_KEY: this.environmentService.getPostHogKey(),
AI_VECTOR_DRIVER:
this.environmentService.getAiVectorDriver() === 'turbopuffer'
? 'turbopuffer'
: undefined,
};
const windowScriptContent = `<script>window.CONFIG=${JSON.stringify(configString)};</script>`;
@@ -192,6 +192,7 @@ export const defaultAsyncNodes: NodeSerializerAsync = {
// No usable static export representation: skip without failing.
subpages() {},
transclusionReference() {},
base() {},
};
export const defaultMarks: MarkSerializer = {
+200 -285
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,12 +6,12 @@ patchedDependencies:
overrides:
prosemirror-changeset: 2.4.0
glob: 13.0.6
ws: 8.21.0
ws: 8.21.3
dompurify: 3.4.13
mermaid: 11.16.1
undici: 7.29.0
tmp: 0.2.7
nanoid@^3: 3.3.17
nanoid@^3: 3.3.18
lodash-es: 4.18.1
express-rate-limit: 8.2.2
flatted: 3.4.2