Adds test harnesses for tenant isolation, TypeORM/MikroORM parity, persistence invariants and idempotency; startup validation for DB config; Nx plugin/core boundary rules; a fresh-database migration smoke test; and a correlation id carried from HTTP requests into docs queue jobs and logs. Testing (packages/core/src/lib/core/testing): - tenant-isolation: an in-memory tenant-aware repository, fixtures and strict assertions (a non-empty page, every row in the caller's tenant). Applied to EmployeeService and OrganizationProjectService. - orm-conformance and persistence-invariants: suites that run the same checks under TypeORM and MikroORM (run-both-orms.sh uses yarn nx). - idempotency: assertion helpers and specs for token cleanup (positive control), employee notifications and the Zapier timer webhook. - database/migration-smoke.spec.ts: runs the full migration chain on a fresh better-sqlite3 database. It is excluded from the default core jest run; run it with `nx run core:test-migration-smoke`. Idempotency: - EmployeeNotificationService.create() takes an opt-in `absorbRedelivery`. With it set, an identical unread, unarchived notification under 60 s old (same receiver, entity, type, sender, title and message) is returned instead of inserted, and a missing key or a failed lookup inserts as usual. The event handler does not enable it (the in-process EventBus never redelivers), so every caller inserts one row per event as before. - ZapierWebhookService skips resending a webhook that already succeeded for the same subscription, action and time log within 5 minutes. The key is reserved while in flight, failed deliveries stay retryable, pruning stops at the first unexpired entry, and the cache is capped at 10,000 entries. Config (packages/config): - An unknown DB_TYPE fails fast with the list of supported values; an empty DB_TYPE still means better-sqlite3; mongodb throws an Error. - Pool and timeout variables are parsed with Number.parseInt semantics, only for postgres and mysql. They throw only where tarn already refused to start and warn otherwise. SQLite ignores them and still prints the startup values. Observability: - RequestContextMiddleware accepts an inbound x-correlation-id of 1-128 visible ASCII characters (otherwise it generates a UUIDv4) and echoes it on the response. CORS allows and exposes X-Correlation-Id. RequestContext.currentCorrelationId() is added. - The docs queue carries the correlation id through job payloads (including bulk reindex) into pipeline outcome, error, dead-letter and enqueue-failure logs. Boundaries and build: - Every project gets a type tag. The ESLint depConstraints stop type:core depending on plugins, and plugins may depend only on core, shared libs and the known extension points (ai-chat, job-proposal, integration-ai, job-*-ui). - core declares @gauzy/scheduler (package.json and implicitDependencies); the webapp Dockerfile copies scheduler's package.json. - The integration-zapier jest config can import @gauzy/core (transformIgnorePatterns, allowJs, isolatedModules). - Fixes the stale @nrwl/nx eslint-disable id in the e2e roles-permissions steps and the broken @gauzy/core mock in the docs document-scope spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gauzy/plugin-docs
The Ever Gauzy Documents backend plugin: a single hub for uploaded files, authored wiki pages (TipTap), folders, categories, versions, links to business records, and (in later milestones) the AI knowledge pipeline.
Overview
- One tree, one entity:
Documentdiscriminated bykind(FOLDER|PAGE|FILE). - Satellite entities:
DocumentCategory,DocumentVersion,DocumentChunk,DocumentIndexState,DocumentShare,DocumentLink. - HTTP API under
/api/plugins/docs/..., guarded byTenantPermissionGuard+PermissionGuard+FeatureFlagGuard(FeatureEnum.FEATURE_DOCUMENTS) with per-routeDOCS_*permissions. - Controllers contain no business logic — every mutation dispatches a CQRS command, every read a query.
- Migrations do not live in this package — they ship in
packages/core/src/lib/database/migrations/.
⚠️ The Document name-shadowing gotcha
The entity class name Document shadows the DOM Document global available in TypeScript's ambient
lib. Always import it explicitly (import { Document } from './entities/document.entity';) — a
missing import compiles silently against the DOM type and fails at runtime. The package
eslint.config.js carries a no-restricted-globals rule so a bare Document reference is a lint
error, not a silent DOM fallback.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
GAUZY_DOCS_MAX_FILE_SIZE |
52428800 |
Per-file upload size limit in bytes (50 MB) |
GAUZY_DOCS_MAX_BINARY_BYTES |
10485760 |
Cap on the PAGE contentBinary CRDT column (10 MB) |
GAUZY_DOCS_AI_ENABLED |
false |
Master switch for the AI pipeline (classification, embedding, retrieval) |
GAUZY_DOCS_EMBEDDING_MODEL |
text-embedding-3-small |
Embedding model id (1536 dims) |
GAUZY_DOCS_CLASSIFY_MODEL |
(chat default) | Classification model id |
GAUZY_DOCS_VERSION_DEBOUNCE_MINUTES |
10 |
Server-side debounce window for PAGE version snapshots |
GAUZY_DOCS_QUEUE_CONCURRENCY |
2 |
docs-processing worker concurrency per process (queued mode only) |
GAUZY_DOCS_QUEUE_ENABLED |
(follows isSchedulerQueueRootEnabled()) |
Register the BullMQ queue. Off ⇒ the pipeline runs inline |
GAUZY_DOCS_QUEUE_WORKER_ENABLED |
true |
Also run the docs-processing consumer here. Set false on the API when a dedicated apps/worker is deployed |
Pipeline dispatch: queued vs inline
The extract → classify → chunk → embed → index pipeline has one definition
(DocsPipelineService) and two dispatchers:
- Queued —
DocsProcessingWorker, the BullMQ worker host. Requires a@gauzy/schedulerroot (SchedulerModule.forRoot({ enableQueueing: true })) in the process. Every process that loads the plugin list registers one under the same condition —@gauzy/core'sAppModule(the API) andSeederModule.forPlugins()(theyarn seedCLI) register a producer-only root ({ enabled: false, enableQueueing: true }— queueing on, cron off), andapps/workerregisters both halves because it also consumes. Jobs getattempts: 3with exponential backoff from a 120 s base and a deterministicdocs:<stage>:<documentId>job id, so duplicate triggers coalesce in Redis. - Inline —
DocsQueueServiceruns the same stage handlers in-process on a background task when no scheduler root is present (any deployment without Redis: single-container, dev, orSCHEDULER_QUEUE_ENABLED=false), so the HTTP request is never blocked. Inline runs get a single immediate attempt; a failure dead-letters onto the document row (FAILED+statusMessage) exactly like the queue's final attempt, and an in-flight guard keyed ondocs:<stage>:<documentId>stops a duplicate trigger from running the same stage twice at once.
SchedulerQueueService is injected @Optional() for exactly this reason — a required dependency
made the whole API fail to bootstrap the moment this plugin was registered. The active mode is
logged once at startup by DocsQueueService (docs-processing dispatch mode: QUEUED|INLINE).
Turning it off
SCHEDULER_QUEUE_ENABLED=false removes the BullMQ root from every process at once and puts the
whole fleet back on the inline path; GAUZY_DOCS_QUEUE_ENABLED=false does the same for this
plugin alone. Either is a safe, reversible rollback — inline dispatch is a supported mode, not a
degraded one.
Building
Run yarn nx build plugin-docs to build the library.
Running unit tests
Run yarn nx test plugin-docs to execute the unit tests via Jest.
Inbound email capture
Documents emailed to an organization's capture address land in Documents as source: EMAIL,
reviewStatus: PENDING, knowledgeStatus: NONE — a human approves them before anything reaches the
AI knowledge base.
Two kinds of address
| Kind | Address | Armed when |
|---|---|---|
PLATFORM |
docs-<128-bit hex>@$GAUZY_DOCS_INBOUND_DOMAIN |
immediately — minted on first read of GET /plugins/docs/inbound-addresses |
CUSTOM_DOMAIN |
<mailbox>@<the tenant's own domain> |
only once _gauzy-docs.<domain> IN TXT carries the value the settings UI shows |
A platform address is unguessable, so the address is the credential. A custom-domain mailbox name
is chosen by the tenant and therefore guessable, which is why that kind stays inert until domain
ownership is proven. Re-verifying a VERIFIED domain whose record has disappeared moves it to
FAILED and it stops accepting mail — a domain that changes hands does not keep delivering.
How a delivery proves itself
Either proof is sufficient:
- Deployment-wide HMAC —
hex(HMAC_SHA256(secret, "<timestamp>.<rawBody>"))inx-gauzy-docs-signature, withx-gauzy-docs-timestamp. Fails closed whenGAUZY_DOCS_INBOUND_WEBHOOK_SECRETis unset; enforces a 5-minute tolerance, a constant-time compare, and single-use replay consumption. - Per-address relay secret — presented in
x-gauzy-docs-address-secret, issued once at creation or rotation and stored only as SHA-256. Prefer this for tenant-owned domains: the deployment-wide secret can post as any tenant, a per-address secret only as one.
🛑 The HMAC is computed over the raw request bytes. packages/core/src/lib/bootstrap/index.ts
preserves them via the body-parser verify hook; the adapter falls back to canonical JSON only when
they are absent, and that fallback will not match a real provider's signature.
🛑 Failure ordering is deliberate. "No valid proof" (403) is raised before "unknown address" (404), because a per-address secret cannot be checked until the address has been resolved. If the 404 came first, an unauthenticated caller could sweep addresses and learn which ones exist.
Gates, in order
- Channel enabled (
GAUZY_DOCS_INBOUND_EMAIL_ENABLED) — otherwise 404, as if the route did not exist - Authentication — either proof above, else 403
- Recipient resolves to an armed address — else 404
- SPF/DKIM verdicts, when the provider reports them
- Sender allowlist — empty means "any sender that passed gate 4"; matches a full address or a whole
domain, compared exactly (
acme.comadmits neitherevil-acme.comnoracme.com.evil.tld) - Size caps, per message and per attachment
- Attachments only, then the same magic-byte sniffing the upload endpoint runs
Operational notes
- Addresses live in
document_inbound_address, with a deployment-wide UNIQUE index onaddress. That is a security control, not an optimization: routing is by recipient alone, so two rows sharing an address would make the destination tenant depend on row order. - Rotating a platform address changes the address itself; the previous one stops resolving at once.
- Setting an address inactive rejects mail while keeping its history — capture data is never deleted.