Table of Contents
- Verification status
- Confirmed in production
- Check these first
- 1. The base schema and bulk tools — NEWEST CODE
- 1b. The two OAuth paths a read+write grant cannot test
- 2. PDF export end to end
- 3. Tika extraction
- 4. MFA login flow
- Running SQL against the instance
- Checking the DI graph
- Verified in isolation
- Known unknowns
- Fixed during the build loop
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Mirrored from
VERIFICATION.mdat commitf31ec6a. Edit the file in the repository: this page is regenerated from it, and edits made here are overwritten.
Verification status
In production use since 2026-08-31. Most of the bundle is exercised daily; the gaps below are specific and named.
tsc passing says the types line up. It says nothing about whether Gotenberg
renders a page or Authentik's payloads match what we parse — so this file
tracks what has actually been observed working, and what has not.
Two other things check what type-checking cannot, and neither replaces this
file: scripts/check-upstream.sh (upstream contract, run in CI on every push)
and di-check.ts (DI graph, needs a running container — see below).
This file lists what to check first and where reality is most likely to diverge. Delete entries as they're confirmed.
Confirmed in production
Reported working by the operator of the live instance on 2026-08-31, with the specific evidence noted. Recorded rather than deleted, because "we checked this and it works" is more useful than silence.
- Audit logging persists. The highest-priority unknown, now closed:
SELECT count(*) FROM auditreturns rows, and the contents are sensible — logouts, API key creation and deletion. SoAuditEeServicedoes win theAUDIT_SERVICEbinding over core'sNoopAuditModule, and the "last global module wins" assumption inapp.module.tsholds in practice. - SCIM against real Authentik. This was the riskiest thing in the bundle — the only part written from a spec rather than reverse-engineered from an existing client contract, with PATCH payload shapes the likeliest divergence. Users, groups and group sync all work.
- SCIM deprovisioning kills live sessions. Unassigning a user ends their session on the next request, and reassigning restores access. This was the explicit end-to-end test this file used to ask for.
- Externally-synced groups are locked in the UI (patch 0008). SCIM-synced groups show the badge, cannot be renamed or deleted directly, and members cannot be removed by hand — only through the IdP.
- OIDC SSO, including the local-account merge paths.
- Group-driven workspace roles (
group-role/, patch 0009). - Bases / Kanban. All six stages, in daily use. This had been the largest
outstanding risk in the bundle: every query, the realtime bridge and the
queue processor were untested against a real database, and the specific
worries were
jsonb_set_manymerge semantics on cell patches, the?|/@>operators against real jsonb, and whetherBaseRealtimeBridgeresolvesBaseWsServicethroughModuleRef. All of it holds. - MCP over OAuth, with a read+write grant (27 Sept 2026). A real client
(Claude) completed discovery, dynamic registration, consent and the token
exchange, and every tool that existed then was exercised against the live
instance: page create/read/append/rename, reorder and reparent via
move_page, threaded comments, attachments on a real page, and the base read/row tools. That pass found one bug —list_commentsdoubled the space at every mark boundary — fixed inc626d9f. Two parts of the OAuth flow are still unexercised and stay under "Check these first".
Check these first
1. The base schema and bulk tools — NEWEST CODE
create_base, create_property, update_property, delete_property,
create_base_rows, upsert_base_rows, create_base_view,
update_base_view and delete_base. Unit-tested (mcp/base-schema-tools.spec.ts,
with negative controls for option-rename id stability, noon pinning, case
sensitive keys and duplicate-key refusal), and the DI constructor tokens were
probed, but none of it has run against a real database. In
likelihood-of-breaking order:
- The server boots. These add a provider and two
BaseModuleexports. A DI failure is a boot failure — a 502, not an error page. If it will not start, runnode dist/ee/di-check.jsin the container. - An open tab sees the change. Everything goes through the base services, which broadcast websocket events. With the base open in a browser, create a property and rename an option over MCP: both should appear without a reload.
clearedCellsis right. Remove an option used by a known number of rows and compare the reported count to what the grid shows afterwards.- A date-only column shows the right day. Write
2026-03-01and an instant like2026-03-01T23:30:00Zto anincludeTime: falsecolumn, and check both read as 1 March in the browser — ideally from a machine in a negative UTC offset, which is where the midnight bug lived. - Upsert paging. The unit test proves pagination is followed with a stub; against a base above 200 rows, confirm a key on a later page updates rather than duplicates.
- A view built over MCP renders, and survives the UI. Create one with
visibleProperties, a sort and a filter, open it in the browser, drag a column, thenget_base: the columns reported should match what the browser shows, before and after the drag. trash_pagelands in the trash and restores. Trash a test base, check the space trash and the audit log (PAGE_TRASHED), restore it, and confirm its rows are back.get_pageon a row page resolves people. Use a row that is not the base's first: names used to come back empty for every other row.
1b. The two OAuth paths a read+write grant cannot test
- Scope enforcement. Approve read only, then confirm the client sees no
write tools and that a write is refused. Then check a
@OAuthScope('write')core route also refuses that token — that path runs through core's guard readinguser.oauth.scopes, not our code. - Revocation. Revoke from the authorized-apps panel and confirm the client fails on its next call, not an hour later.
SELECT c.name, g.scopes, g.last_used_at FROM oauth_grants g
JOIN oauth_clients c ON c.id = g.client_id;
2. PDF export end to end
Failure modes, in likelihood order:
- Gotenberg can't reach
APP_URL— see DEPLOYMENT.md. Symptom: Gotenberg returns a non-200 and the job fails with its response body. waitForExpressionnever satisfied —pdf-export.service.tswaits ondocument.querySelector('.ProseMirror') !== null. If that isn't the right selector for the rendered editor, you get a blank or partial PDF rather than an error. Check a real output before trusting it.- Gotenberg v8 route paths (
/forms/chromium/convert/url) are from memory, not verified against the pinned image.
3. Tika extraction
PUT /tika with Accept: text/plain / text/html is long-stable API, but
verify against the pinned tag. Check that a scanned PDF returns empty (and is
stored as '', not NULL, so backfill doesn't retry it forever) rather than
erroring.
4. MFA login flow
Should be testable standalone with no external service: enable MFA in account
settings, log out, log back in. Confirm the pending-login mfaToken cookie
path works — /mfa/setup, /mfa/enable, /mfa/verify and
/mfa/validate-access deliberately carry no JwtAuthGuard, since forced
setup happens before a session exists.
Running SQL against the instance
Several checks here are a single query. From the host, not from inside a container:
docker compose exec db psql -U docmost -d docmost
If that reports role "docmost" does not exist, the role is not what the
compose file says: POSTGRES_USER is only honoured when the data directory is
first initialised, and is silently ignored on every later start. The
credentials the app actually uses are the authoritative ones —
docker compose exec docmost printenv DATABASE_URL
— and psql -U postgres -c '\du' lists every role in the cluster.
Check which container you are in first. A container-management TUI started
from this directory can attach you to an unrelated Postgres container
elsewhere on the same host. That reports role "docmost" does not exist too,
which reads like a credentials problem and is not one.
Checking the DI graph
tsc cannot catch a missing Nest provider — it's a runtime module-graph
problem. Run this before any deploy:
docker compose exec -w /app/apps/server docmost node dist/ee/di-check.js
It builds the real application (Fastify adapter, init() but never
listen()), forcing every provider to resolve, then verifies each of core's
scattered require() + moduleRef.get() hooks into this bundle actually
resolves — the failure that produced a 500 on every API-key request while the
build was green.
Needs the app's normal environment (postgres, redis), which is why it runs
in the container. An earlier version used createApplicationContext and
claimed to need neither; that was wrong twice over. CollaborationModule's
onModuleInit calls httpAdapter.getHttpServer(), which is null without an
HTTP adapter, so init always failed — and the old catch-all reported that as
"infrastructure, expected" and exited 0. The hook checks never ran. Any build
failure now exits non-zero.
init() starts BullMQ workers for the few seconds it lives, so run it while
the instance is idle rather than mid-import.
Expect a skip line for anything genuinely not bundled (typesense), and
ok for everything else. Modules without a scattered hook (page-permission,
scim, bases) are covered by the graph check rather than listed.
Run it against the compiled output, never through tsx — the TS loader
resolves circular imports differently and reports false failures in core
(UserService ... undefined at runtime) that don't happen in production.
This exists because the first real deploy died on exactly this: DocxImportService
injected AttachmentService, but AttachmentModule is neither @Global() nor
exports it. Fixed by using AttachmentRepo from the global DatabaseModule.
Verified in isolation
Run as standalone scripts against the real engine, not mocked. Each caught at least one genuine bug:
- Filter SQL — compiled a representative filter and read the output.
Confirmed jsonb's
?|survives parameterisation without colliding with the driver's placeholders, LIKE metacharacters in user input are escaped, and unknown properties drop out of both filter and sort. - Timezones — resolved presets across UTC, Europe/Prague,
America/Los_Angeles and Pacific/Kiritimati at a fixed instant.
thisMonthin Prague correctly starts in CET and ends in CEST; the spring-forward day is 23 hours. - Formulas — compiled and evaluated nested formulas in dependency order,
confirmed transitive
affectedFormulas,DIV_BY_ZEROproducing an error cell, and cycle rejection. Caught thatFormulaParseErrorexposeserrorsnotissues, and thatevalOrder()includes non-formula dependencies. - Type conversion — walked the conversion matrix. Caught that converting out of a choice column emitted option ids rather than labels.
Known unknowns
- The base schema tools have not run against a database. See "Check these first", item 1. The OAuth flow itself is verified for a read+write grant; read-only scope enforcement and revocation are not (item 1b).
- Confluence import is deliberately dropped, not missing — see the status
table in README.md. Core keeps the hook
(
ee/confluence-import/confluence-import.service), so an attempted import logs an error and imports nothing. That is the intended degradation, not a gap to close. - Filter-only attachment search returns nothing. Upstream made
SearchDTO.queryoptional in917195b2so a search can be filters-only. Page search honours that;searchAttachmentsstill returns an empty list when there is no query text, which is what it did before. - Page verification QMS transitions were inferred from the client's
conditional rendering (
status === "draft"gates submit, etc.), not from a spec. The shape is unambiguous but edge behaviour is our reading — e.g. whether re-submitting an already-approved page should be allowed. - DOCX export image fallback returns a 1×1 transparent PNG when an image can't be resolved, because the serializer has no "skip this node" concept. It logs a warning; it does not fail loudly.
/sso/providersand/sso/inforeturnoidcClientSecretin plaintext to the admin UI. TODO marked insso/services/sso.service.ts.- Row list pagination is offset-based, so a concurrent insert can shift a row across a page boundary. A known design tradeoff rather than an unknown: keyset over arbitrary nullable jsonb sort expressions was judged not worth the complexity. Bases is in production and this has not been reported, which is expected — it needs a concurrent insert during paging to show at all.
getSsoProviderById()in the client is buggy — it accepts aproviderIdargument and never sends it. The server tolerates this by falling back to "the workspace's only provider". That breaks with a second IdP; fix the client then.
Fixed during the build loop
Recorded because they're all the same category — environment-specific typing, not logic — and the next batch will probably be too:
ActorTypeimported from the wrong module (it re-exports fromcommon/events/audit-events, notintegrations/audit/audit.service).catch (err)types asunknown; core usescatch (err: any)where it inspects the error.@types/nodev24 makesBuffergeneric, so it no longer satisfies fetch'sBodyInit— wrap as aUint8Arrayview.spaceshas noiconcolumn; it'slogo..where(sql\…`, 'in', array)` types operands against the sql expression, not a column — express as one raw predicate instead.