Commit Graph

686 Commits

Author SHA1 Message Date
claude_code
4df79aafd3 test(server): batch 5 authorization, transclusion, search & comment coverage
Test-only. Fills the authorization / data-integrity gaps from the strategy
report. Full server suite: 100 suites / 1031 passed + 1 todo, green.

Authorization (privilege-escalation catches):
- workspace/space ability factories: exact can/cannot per (action,subject) —
  admin cannot Manage Audit, writer/reader cannot Manage Settings/Member, etc.
- findHighestUserSpaceRole, isAdminActingOnOwner.
- WorkspaceService role guards: last-owner lockout, admin-over-owner, self-target.
- SpaceMemberService.validateLastAdmin: never orphan a space without an admin.
- GroupService: default-group immutability, name uniqueness.

Access / data integrity:
- PageAccessService: restriction-vs-space-ability branches for view/edit/comment.
- TransclusionService.unsyncReference: cross-workspace/NotFound boundary asserts
  NO attachment write or ref-row delete on rejection; lookupWithAccessSet
  positional status mapping; listReferences drops private/cross-ws/deleted refs;
  syncPageTransclusions/References diff (no-op on unchanged content).
- SearchService.searchPage: query-mode scoping; leakage modes return empty
  before executing the query.
- CommentService: reply-to-reply guard, agent provenance, self-mention filter,
  no double-notify.

Pure helpers:
- prosemirror extractors (mention dedup-key id-vs-entityId, attachment UUID
  validation, removeMarkTypeFromDoc), collaboration.util (getPageId,
  isEmptyParagraphDoc, stripUnknownNodes unwrap, prosemirrorNodeToYElement).

Reviewed (APPROVE WITH SUGGESTIONS): mutation-resistant, not vacuous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:40:07 +03:00
claude_code
0b2af34029 test(integrations/client/packages): batch 2-4 unit coverage + zip-slip guard extraction
Batch 2-4 of the test-strategy rollout. Test-only except one minimal,
behaviour-preserving extraction in file.utils.ts. All suites green:
server 82 suites/836+1todo, editor-ext 86, mcp 270, client (new files) 86.

integrations (server):
- file.utils.ts: extract pure `isEntryPathSafe(entryName, targetDir)` from
  extractZipInternal so the zip-slip/path-traversal guard is unit-testable;
  call site rerouted, behaviour identical (only a warn-message string merged).
- file.utils.zip-safety.spec.ts: traversal/strip/__MACOSX/prefix-confusion
  cases (mutation-resistant: fails if containment loses the path.sep).
- import-formatter / import.utils / table-utils / export utils / import.service
  extractTitleAndRemoveHeading: pure import/export transforms, Notion/XWiki
  formatting, table colspan widths (idempotent), slug/link rewriting.

client:
- safeRedirectPath: open-redirect guard, every reject branch independently.
- buildChatMarkdown (fence anti-breakout), label-colors, normalize-label,
  share tree build, page URL builders, notification time-grouping (fake clock).

packages:
- editor-ext: deriveFootnoteId golden table, parseHtmlEmbedHeight crafted
  values, orphan footnote extraction.
- mcp: deriveFootnoteId parity (drift guard vs editor-ext), applyTextEdits
  idempotency + cross-block replaceAll, diffDocs/summarizeChange on reorder.

Reviewed (APPROVE): extraction behaviour-preserving, assertions mutation-resistant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:22:15 +03:00
claude_code
f8e8ada581 test(server): add behavioural unit tests for auth + common security helpers
Batch 1 of the test-strategy rollout. Fills the highest-value gaps where
existing specs were only `toBeDefined()` smoke tests or absent. Test-only,
no production source touched.

- token.service.behavior.spec.ts: verifyJwt type-mismatch rejection (confused
  deputy), generateAccessToken/generateCollabToken disabled-user -> Forbidden,
  agent `actor` claim only from signed provenance, correct expiry.
- auth.util.spec.ts: computeEmailSignature (stable HMAC, case-normalized),
  throwIfEmailNotVerified, validateSsoEnforcement, validateAllowedEmail;
  it.todo flags the unguarded `@`-less email TypeError.
- guards/setup.guard.spec.ts: cloud blocks setup, first-run allows, re-run on
  an initialised instance is forbidden (privilege escalation guard).
- security-headers.spec.ts: resolveFrameHeader clickjacking/CSP branches.
- utils.security.spec.ts: redactSensitiveUrl, extractBearerTokenFromHeader,
  parseRedisUrl, normalizePostgresUrl, diffAuditTrackedFields, isUserDisabled.

60 tests + 1 todo, all green. Reviewed for mutation resistance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:00:09 +03:00
claude_code
d4658d4cb3 Merge pull request '#114 refactor(ai-chat): shared parseNodeArg helper; keep duplication backlog doc' (#114) from refactor/ai-chat-tool-spec-registry into develop
# Conflicts:
#	apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts
2026-06-21 14:45:20 +03:00
claude_code
4105836a2d Merge pull request '#112 test(ai-chat): current-page coverage + getCurrentPage helper' (#112) from feat/ai-chat-current-page-robustness into develop 2026-06-21 14:31:12 +03:00
claude_code
f5a45d5453 Merge pull request '#115 test(server): integration harness + deferred coverage' (#115) from test/deferred-integration-coverage into develop 2026-06-21 14:31:12 +03:00
claude_code
c7f0b51389 fix(ai-chat): keep tool-duplication backlog doc; fix parseNodeArg comment
Pre-merge review follow-up for the parseNodeArg dedupe (PR #114):
- Restore docs/backlog/ai-chat-tool-definitions-duplicated.md instead of
  deleting it: it still tracks open debt (unified spec registry + ProseMirror
  <-> Markdown converter unification) that this branch defers, and
  docs/git-sync-plan.md links to its converter section. Mark the node-arg
  quirk as done and add a Progress section.
- Reword the in-app helper header from "byte-for-byte" to "behaviorally
  identical": the two copies differ in comments/quote style; only the logic,
  throw messages and branch order match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:30:37 +03:00
claude_code
6397b500ba fix(share-ai): lower default per-workspace cap to 100 (#62)
The fail-closed limiter behavior (#62 primary item) already shipped; this
finishes the issue by lowering the default hourly per-workspace cap from 300
to 100 to better fit real anonymous-assistant load. Still overridable via
SHARE_AI_WORKSPACE_MAX_PER_HOUR.

- public-share-workspace-limiter.ts: SHARE_AI_WORKSPACE_MAX_PER_WINDOW 300 -> 100.
- .env.example: documented default + example value 300 -> 100.
- public-share-chat.spec.ts: update the default-cap assertion to 100.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:18 +03:00
claude_code
c3161a05dd refactor(ws): single-snapshot move audience to close the restricted-move race (#93)
Implements Option 2 of #93. The restricted branch of broadcastPageMoved
previously resolved its audience twice — emitToAuthorizedUsers and
emitDeleteToUnauthorized each ran an independent fetchSockets +
getUserIdsWithPageAccess — leaving a race window between the two snapshots
where a socket could receive both the move and the delete (leak) or neither
(lost compensating delete).

- ws.service.ts: add emitMoveWithRestrictionSplit() that takes ONE socket
  snapshot and ONE authorization resolution, then partitions the room:
  authorized users get the moveTreeNode, everyone else (unauthorized +
  anonymous) get the compensating deleteTreeNode. Disjoint + complete by
  construction. Remove the now-unused emitToAuthorizedUsers /
  emitDeleteToUnauthorized; keep private broadcastToAuthorizedUsers (still
  used by emitRestrictedAwareToSpace).
- ws-tree.service.ts: broadcastPageMoved restricted branch now drives move +
  delete from the single method.
- specs: assert the single method is used and that fetchSockets /
  getUserIdsWithPageAccess are each called exactly once (single snapshot);
  re-route ws-service.spec to emitTreeEvent after the method removal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:18 +03:00
claude code agent 227
04f05626ad test(server): integration harness + deferred coverage vs real Postgres/Redis
Builds the deferred integration tests from docs/backlog/feature-test-coverage-
deferred.md that needed real infra (a test Postgres + real Redis) which the repo
lacked. Runs against an isolated, auto-created docmost_test database and Redis
logical DB 15 — never the dev data.

Harness (apps/server/test/integration/, run via new `pnpm --filter server test:int`
=> jest --config test/jest-integration.json; default unit `jest` is untouched and
excludes these via the *.int-spec.ts name + rootDir):
- db.ts: buildTestDb() mirrors database.module.ts exactly (PostgresJSDialect,
  CamelCasePlugin, bigint to:20/from:[20,1700] parsing) + minimal seed helpers.
- global-setup.ts: DROP/CREATE docmost_test, CREATE EXTENSION vector, migrate to
  latest via Kysely Migrator (fails loud on any errored migration).
- global-teardown.ts: closes the pool.

Coverage (5 suites, 16 tests, all green against live PG+Redis):
- WorkspaceRepo.updateSetting: jsonb-merge persists htmlEmbed without clobbering
  sibling ai/sharing namespaces (the kill-switch write half).
- AiAgentRoleRepo: soft-delete exclusion, cross-workspace tenant isolation,
  duplicate (name,workspace) -> 23505, name reusable after softDelete (partial
  unique index WHERE deleted_at IS NULL), same name across workspaces allowed.
- page_template_references: deleting either source or referenced page cascades
  the link row (onDelete cascade) — real FK, not mocked.
- PublicShareWorkspaceLimiter vs REAL Redis: real ioredis EVAL of the sliding-
  window Lua — max boundary (3 admit / 4th deny), re-admit after the window
  slides, same-ms distinct members. Catches Lua bugs a FakeRedis cannot.
- AiChatRepo.findByCreator: role-badge join (enabled->badge; soft-deleted or
  disabled role -> null).

Review: APPROVE; applied its two hardening suggestions (fail loud on errored
migration result even without a top-level error; TEST_REDIS_URL override + ping
preflight). tsc clean; unit run excludes int-spec (verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:02:55 +03:00
claude code agent 227
f9757fda12 refactor(ai-chat): dedupe node-arg JSON normalization into a shared helper
First, safe step of docs/backlog/ai-chat-tool-definitions-duplicated.md: the
"node may be a JSON object OR a JSON string" quirk was hand-copied at 6 tool
sites. Extract it into a single parseNodeArg() helper per package and call it at
every site. Behavior-preserving — each site's throw message is byte-identical
(patch/insert: 'node was a string but not valid JSON'; update_page_json: 'content
was a string but not valid JSON'); no tool name/description/schema changed.

Two helper copies (packages/mcp/src/lib/parse-node-arg.ts and
apps/server/src/core/ai-chat/tools/parse-node-arg.ts) are intentional: the
ESM-only @docmost/mcp cannot be imported by the CommonJS server (it is loaded at
runtime via the Function('import()') trick), so runtime code cannot cross that
boundary by a normal import. Each copy is now the single source within its
package (6 inline copies -> 2 helpers). packages/mcp/build rebuilt in sync.

Tests: parse-node-arg.spec.ts (server, Jest) + parse-node-arg.test.mjs (mcp,
node:test) — object passthrough, valid-string parse, invalid-string throw with
the right message. Server tsc clean; mcp suite 254 pass; agent structural-edit
path verified live in-browser (agent inserted a node, persisted to the doc).

Deferred (documented for the record, since the backlog doc is removed with this
commit): the FULL transport-agnostic tool-spec registry (one name+schema+
description per tool shared by both transports) and deriving DocmostClientLike
from the real client type. Both are blocked by the current architecture, not by
effort: (1) @docmost/mcp ships no type declarations and is ESM-only, so a
type-only derivation needs declaration emission + tsconfig path wiring, and the
real client's precise return types break the in-app tool test stubs (attempted,
reverted to keep tsc green); (2) the two transports intentionally DIVERGE in tool
NAMES (snake_case x38 vs camelCase x41), membership (in-app adds getCurrentPage/
listSidebarPages, omits delete_comment/image tools) and model-facing
DESCRIPTIONS, so a unified registry would change behavior on BOTH the agent and
external MCP clients and needs its own verification pass. This is forward-looking
debt (the code is correct today), to be done incrementally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:51:09 +03:00
claude code agent 227
e6b1170553 test(ai-chat): cover current-page injection; extract resolveCurrentPageResult
The 'current page' feature (client useMatch openPage + server getCurrentPage
tool + system-prompt injection) was already implemented & merged; this backfills
its missing test coverage and removes the completed backlog doc.

- extract pure resolveCurrentPageResult(openedPage) into current-page.util.ts
  (byte-identical to the prior inline getCurrentPage tool body) so it is
  unit-testable without the dynamically-imported ESM Docmost client; the tool
  now delegates to it.
- current-page.util.spec.ts: 7 cases (null/undefined/no-id/empty-id/full/no-title).
- ai-chat.prompt.spec.ts: +8 cases for the openedPage context line (title+pageId
  present, Untitled fallback for blank/whitespace title, no line when absent/blank
  id, and sandwich ordering before the trailing safety block).

Verified live in-browser: client sends openPage{id,title} on a page and null
off-page; the agent invokes getCurrentPage and answers with the real title+id.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 06:21:38 +03:00
claude code agent 227
85fd4afa85 test(workspace): cover trackerHead DTO validation, CASL gate, no-op audit (#98)
DTO: trackerHead @IsString/@MaxLength(20000) + htmlEmbed @IsBoolean accept/reject
cases. CASL: a non-admin updating trackerHead/htmlEmbed gets ForbiddenException
(update not called); owner/admin proceed. Audit: a no-op trackerHead re-save
doesn't enter the audit diff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:52:15 +03:00
claude code agent 227
d9fa804197 test(share): extract + cover injectTrackerHead (#100, #98)
Extract the admin trackerHead <head> injection into a pure injectTrackerHead()
and test it: a snippet containing $&/$$/backtick-dollar survives BYTE-FOR-BYTE
(pins the function-replacer fix), empty/whitespace/undefined and a missing </head>
leave the html unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:52:15 +03:00
claude code agent 227
ec4622a1b8 test(security): export + unit-test resolveTrustProxy (#105)
Relocate resolveTrustProxy from main.ts (untestable — bootstraps on import) to
integrations/environment/trust-proxy.util.ts and import it back. Unit-test every
branch (empty/undefined -> safe loopback/private default; true/false; hop count;
trim; CIDR/negative passthrough) so a regression can't silently re-open the XFF
spoofing hole (#61).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:52:15 +03:00
claude code agent 227
33c52045a2 test(share-ai): drive the non-text message-part 400 path (#103)
Covers the #63 guard: a message with a non-text part -> 400 'Unsupported message
content'; a message mixing text + a non-text part still 400s (before the 413
size check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:52:15 +03:00
claude code agent 227
85db20f9f2 test(page): cover movePage server-side cycle guard (#102)
Adds the missing tests for the #67 guard: self-move and a destination inside the
moved page's subtree both throw BadRequestException before updatePage; a
legitimate move proceeds. Mocks pageRepo + spies getPageBreadCrumbs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:52:15 +03:00
claude code agent 227
455a554054 Merge remote-tracking branch 'gitea/fix/review-batch-2' into fix/review-batch-2
# Conflicts:
#	.env.example
#	README.ru.md
2026-06-21 05:34:17 +03:00
claude code agent 227
7e26239c3f Merge remote-tracking branch 'gitea/develop' into fix/review-batch-2
# Conflicts:
#	AGENTS.md
#	CHANGELOG.md
#	README.md
#	apps/server/src/collaboration/collaboration.handler.ts
#	apps/server/src/common/helpers/prosemirror/html-embed.spec.ts
#	apps/server/src/common/helpers/prosemirror/html-embed.util.ts
#	apps/server/src/core/ai-chat/public-share-chat.service.ts
#	apps/server/src/core/ai-chat/public-share-chat.spec.ts
#	apps/server/src/core/ai-chat/public-share-workspace-limiter.ts
#	apps/server/src/core/page/services/page.service.ts
#	apps/server/src/core/page/transclusion/transclusion.service.ts
#	apps/server/src/integrations/import/services/file-import-task.service.ts
#	apps/server/src/integrations/import/services/import.service.ts
2026-06-21 05:32:44 +03:00
claude_code
bc0c49db05 fix(review): address PR #101 review findings (dead DI, docs)
Some checks failed
Test / test (pull_request) Has been cancelled
- ai-chat: drop the unused pagePermissionRepo injection from
  PublicShareChatToolsService (its only use moved into
  ShareService.resolveReadableSharePage); update all 5 positional
  test construction sites to match the 3-arg constructor.
- env: correct the anonymous share-AI per-workspace cap comment —
  the limiter FAILS CLOSED on Redis failure (#62), not open.
- docs: sync README.ru.md with README.md — move "Page templates"
  from Planned to Done and drop the dead plan-doc link.

Remaining test-coverage gaps tracked as #102, #103, #104, #105, #106.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:24:13 +03:00
claude_code
0fbaebd108 Merge gitea/develop into develop
Some checks failed
Develop / test (push) Has been cancelled
Develop / build (push) Has been cancelled
Reconcile the diverged develop (13 ahead / 20 behind) with gitea/develop.

Conflict resolution — html-embed: keep the local sandboxed-iframe model
(opaque-origin srcdoc, no role-gating) and supersede gitea's same-origin
strip/kill-switch hardening (#26/#28/#29/#30). The 4 conflicted html-embed
source files resolve to the local version; the 3 strip-era spec files stay
deleted. The strip apparatus (stripDisallowedHtmlEmbedNodes,
collectHtmlEmbedSources, canAuthorHtmlEmbed, htmlEmbedAllowed) is fully gone.

Integrate gitea's page-templates / page-embed work (#31-#40) cleanly.

Fix an auto-merge arity mismatch: two new gitea page-template specs
constructed TransclusionService with the pre-sandbox 11-arg signature; drop
the trailing workspaceRepo argument to match the reduced 10-arg constructor.

Verified: server + client tsc --noEmit clean; jest (html-embed + transclusion)
14 suites / 119 tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:21:20 +03:00
claude_code
3936c482d9 refactor(workspace-settings): extract useWorkspaceSetting hook
Deduplicate the "save a workspace setting" plumbing shared by HtmlEmbedSettings
and TrackerSettings (workspace atom read, isLoading state, updateWorkspace + atom
merge forcing settings[key], success/error notifications) into a new
feature-scoped hook useWorkspaceSetting(key).

- Each component keeps its own interaction model: html-embed is an optimistic
  toggle with revert-on-failure; tracker is edit-then-save on an explicit button.
- Unify error handling on the better pattern: surface err.response?.data?.message
  and use console.error (html-embed previously used console.log + a generic message).

No user-facing behavior change; client typecheck clean.

Test-coverage follow-ups (untested trackerHead injection in ShareSeoController and
the no-op audit branch) tracked in #100.
2026-06-21 04:17:54 +03:00
claude code agent 227
a20f4c3876 fix(mcp): close the brute-force limiter check-then-act race (#83)
Some checks failed
Test / test (pull_request) Has been cancelled
isBlocked was checked synchronously but recordFailure ran only AFTER the bcrypt
awaits, so N concurrent /mcp Basic requests for one email all slipped past the
threshold. Add FailedLoginLimiter.tryReserve (atomic synchronous check+increment)
+ release (undo), and reserve all 3 keys BEFORE any await so the (threshold+1)-th
concurrent attempt is rejected before its bcrypt runs. The reservation IS the
failure record (post-await recordFailure removed -> counted exactly once). Non-
credential early throws (missing workspace, SSO/MFA gate) and business errors
release the reservation so they don't burn a victim's budget; success clears.
Tests prove login() runs exactly threshold times under concurrency and that
gate/config rejects don't consume budget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 04:14:38 +03:00
claude code agent 227
31fcb764d7 refactor(transclusion): unify the ProseMirror collectors into collectNodes (#94)
The three collect*FromPmJson collectors shared the same recursion (and the #55
depth cap) but were copy-pasted, so a future edit could diverge them. Extract a
generic collectNodes(doc, {type, map, key, lastWins, skipChildrenOfType}) and
reimplement all three on it, byte-output-identical (transclusions last-wins;
references/embeds first-wins + transclusionSource skip). Documents (not removes)
the write-only page_template_references graph and the near-duplicate client
lookup-context as tracked follow-ups, per the issue's guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 04:04:09 +03:00
claude code agent 227
3f46496192 refactor(share): single resolveReadableSharePage for the share access boundary (#92)
The '(shareId,pageId) -> usable non-restricted page in THIS share' boundary was
written as 3 must-be-identical async sequences. They weren't: the chat funnel
omitted an explicit page.deletedAt check (latently safe via getShareForPage's
CTE) and layered isSharingAllowed separately. Add ShareService.resolveReadable-
SharePage(shareId,pageId,workspaceId) running the single canonical sequence
(getShareForPage -> id match (skipped when null) -> findById -> !deletedAt ->
!hasRestrictedAncestor) returning {share,page}|null; getSharedPage, the funnel,
and the getSharePage tool all use it. hasRestrictedAncestor now lives in the one
method no caller can skip; the funnel still returns uniform 404s and keeps
isSharingAllowed. Adds a direct security-invariant test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 04:04:09 +03:00
claude code agent 227
3953ecdb17 refactor(ai-chat): single live+enabled role resolve in the repo (#95)
resolveRoleForRequest and resolveShareRole duplicated the security invariant
'role exists, not soft-deleted, enabled, workspace-scoped, else null'. Move it to
AiAgentRoleRepo.findLiveEnabled(id, workspaceId) (deletedAt IS NULL + enabled +
workspace scope) and have both services call it, preserving each one's roleId
derivation + null handling. (describeProviderError half of #95 was done earlier.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:49:52 +03:00
claude code agent 227
3147b6ddf4 refactor(ws): single restriction-aware emit for tree + comment events (#93)
emitTreeEvent and emitCommentEvent were byte-identical (same room resolution,
spaceHasRestrictions gate, hasRestrictedAncestor, authorized-only vs broadcast
fallback). Collapse the body into one private emitRestrictedAwareToSpace; both
stay thin wrappers with unchanged signatures, so the restriction-routing gate
lives in exactly one place. Adds coverage for the comment entry point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:49:52 +03:00
claude code agent 227
7c57a386b2 test(mcp): coupling guard between enforceBasicLoginGate and login (#91)
McpService.enforceBasicLoginGate re-implements AuthController.login's pre-token
SSO/MFA gate; silent drift would re-open the bypass. Add an AST contract test
(comments stripped) asserting BOTH method bodies contain validateSsoEnforcement,
the EE-MFA require, and checkMfaRequirements — so dropping the gate from either
side fails CI. Test-only (no core/auth refactor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:49:52 +03:00
claude code agent 227
a2ded7ecfb refactor(html-embed): extract the admin-gate strip into one tested helper (#90)
The 4-step html-embed gate (feature-enabled AND role-allowed -> stripHtmlEmbedNodes)
was replicated across call-sites, pinned only by brittle source-regex tests. Add
stripHtmlEmbedIfNotAllowed(json, {featureEnabled, role, onStrip}) and migrate the
5 plain strip-all sites (collab handler, page create+duplicate, both import paths,
transclusion) to it, each keeping its own feature/role resolve + log via onStrip.
Left the 2 sites with different semantics: persistence.extension (#29 preserve-
admin) and share.service (feature-only kill-switch, no role gate). Real unit tests
replace the regex pins; behavior identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:49:52 +03:00
claude code agent 227
c486750b2a test-infra: re-enable 16 disabled server suites (jest DI + lib0 ESM) (#56)
16 suites were disabled via testPathIgnorePatterns due to two root causes: lib0
ESM not transformed (the @hocuspocus/server -> lib0/decoding.js chain) and stock
'should be defined' specs built via Test.createTestingModule without providers.
Add lib0 to transformIgnorePatterns; convert the 14 DI placeholders to direct
new X(...) instantiation with stub deps (keeping a real construct smoke test);
re-enable the suites. Also updates the public-share limiter test to assert the
fail-closed behavior from #62 (surfaced now that the suite runs). Full server
suite: 67 passed, 689 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:40:40 +03:00
claude code agent 227
d45ca00bcc docs(mcp): document the MCP_TOKEN header breaking change + one-time warning (#84)
The shared MCP_TOKEN guard moved from 'Authorization: Bearer <MCP_TOKEN>' to the
X-MCP-Token header (Authorization is now per-user Basic/Bearer), silently breaking
existing /mcp clients. Document it as a Breaking Change in CHANGELOG (reconfigure
to X-MCP-Token). Add a once-per-process migration warning: when MCP_TOKEN is set,
no x-mcp-token is present, and Authorization carries the old 'Bearer <MCP_TOKEN>',
log a hint to migrate — without changing the auth decision (still rejected) or
logging the token value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:40:40 +03:00
claude code agent 227
a11c87c4dc docs(page-templates): document that lookupTemplate is flat (no server recursion) (#54)
Assessment of the page-embed depth/cycle cap: the server /pages/template/lookup
returns FLAT single-level content and does NOT recurse into embedded pages — the
recursive expansion + the PAGE_EMBED_MAX_DEPTH cap are entirely client render
concerns, and a scripted client is already bounded by the per-user throttle
(30/min) + the ArrayMaxSize(50) per-call cap. So no server-side depth guard is
needed; documented at lookupTemplate so future readers don't add a redundant one
or assume server recursion exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:40:40 +03:00
claude code agent 227
6928817cee fix(ws): broadcast realtime page rename/icon change (#72)
handleMessage became a no-op and PageWsListener intentionally ignored
PAGE_UPDATED, so a rename/icon change (client operation:updateOne) was no longer
rebroadcast -> other clients saw stale title/icon in the sidebar+breadcrumbs
until a reload (create/duplicate/restore were covered; updateOne regressed).
Add a server-authoritative onPageUpdated handler: PageService.update detects a
real title/icon change (DTO carries the field AND value differs; no-op/content-
only saves excluded) and attaches a treeUpdate snapshot to PAGE_UPDATED; the
listener broadcasts a tree updateOne via the restriction-aware emitTreeEvent
(so a restricted page's title never leaks). Content-only saves attach nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:29:52 +03:00
claude code agent 227
c78177c28b test(page): exercise the real getSidebarPagesTree via an extracted pure helper (#75)
sidebar-pages-tree.spec tested a LOCAL COPY of the tree-shaping (so a regression
in the real getSidebarPagesTree was invisible) and justified it with a false
jest-config claim (the ^src mapping exists). Extract the pure shaping into
shapeSidebarPagesTree(); the service now calls it and the spec imports the REAL
helper. Behavior unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:29:52 +03:00
claude code agent 227
b597841cf0 test(ai-roles): cover update() happy-path return shape (#88)
The concurrent-soft-delete guard was already covered; add the missing assertion
that update() returns toView(updated) from the post-update re-fetch (full
AgentRoleView shape, distinct second findById row), so a regression returning the
stale pre-update view or leaking columns is caught.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:28:58 +03:00
claude code agent 227
317fdb9424 test(public-share): cover getSharePage positive + soft-deleted branches (#85)
The anonymous share page-fetch tool's positive branch (sanitize via
updatePublicAttachments then jsonToMarkdown before returning to the model) was
untested, so a dropped/reordered sanitizer would ship a comment-mark/raw-
attachment leak with green tests. Add a positive-branch test pinning the
sanitizer call + that markdown derives from sanitized content, and a soft-deleted
test asserting a generic error with no content fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:28:58 +03:00
claude code agent 227
40f68e95fb fix(ws): shrink restriction-cache TTL to bound the leak window (#53)
invalidateSpaceRestrictionCache has no callers because no restriction-mutation
path exists yet (PagePermissionRepo mutators are uncalled; there is no
restrict/grant/revoke endpoint), so the 30s spaceHasRestrictions cache could
serve a stale 'no restrictions' verdict. Until a mutation endpoint exists to
wire the direct invalidation, lower the TTL (30s -> 3s) to bound the worst-case
window; the invalidation primitive is kept for that future endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:28:58 +03:00
claude code agent 227
342bb47b30 fix(ai-roles): validate chatModel + guard driver-enum drift (#52)
chatModel was a free string accepted with empty/garbage values, failing only at
runtime as a provider 503; tighten it (trim + non-empty + max 200). Driver was
already @IsIn(AI_DRIVERS). Collapse the client driver list to one AI_DRIVER_VALUES
source and add a contract test that reads the server AI_DRIVERS and fails on
client/server drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:28:58 +03:00
claude_code
e9ceb0f899 fix(html-embed): address code-review findings on the sandbox commit
Follow-up fixes to the htmlEmbed-sandbox / trackerHead change:

- share-seo: inject trackerHead via a function replacer so `$`-sequences
  ($&, $', $`, $$) in the admin snippet are inserted literally instead of
  being treated as String.replace substitution patterns; warn when the
  </head> marker is absent instead of silently skipping injection.
- mcp: register a passthrough `htmlEmbed` node in the schema mirror so an
  AI/MCP edit of a page containing an embed no longer throws
  "Unknown node type: htmlEmbed" in TiptapTransformer.toYdoc.
- editor-ext + client: treat a non-finite `data-height` as auto (null) so a
  crafted/corrupted height cannot disable auto-resize or yield a NaN iframe
  height; extract a shared clampHeight helper.
- client: rename render-raw-html.{ts,test.ts} -> html-embed-sandbox.{...} and
  shouldExecute -> shouldRender so the seam name matches the sandbox model.
- client: i18n the iframe title; surface the real error reason in
  tracker-settings (console.error + err.response.data.message).
- docs: note hasHtmlEmbedNode is now a test-only helper; add an Unreleased
  CHANGELOG entry; drop the dangling "arbitrary HTML embed" planning-doc ref.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:22:37 +03:00
claude code agent 227
c0d312d8f5 harden(transclusion): depth-cap the ProseMirror collectors (#55)
collectPageEmbedsFromPmJson (and the sibling collectors/remap) recursed with no
guard, so a pathological/cyclic non-JSON input could stack-overflow (RangeError).
Add a depth cap (1000, far above any real doc nesting) so such input is handled
gracefully. Normal documents are unaffected. Updates a stale test that asserted
the old throwing behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:17:37 +03:00
claude code agent 227
5215913533 fix(security): env-configurable trustProxy with a safe default (#61)
trustProxy was unconditionally true, so req.ip came from a client-forgeable
X-Forwarded-For and the per-IP throttles (share-AI, /mcp brute-force) were
spoofable. Make it env-configurable (TRUST_PROXY) with a safe default that
trusts XFF only from loopback/private proxies, documented in .env.example.
NOTE: this changes the default from trust-all; deployments whose proxy is on a
public IP must set TRUST_PROXY (caveat documented).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:17:37 +03:00
claude code agent 227
099d31f594 fix(ai): sandwich SAFETY_FRAMEWORK around the role persona (#68)
A custom AI-role's text preceded the only SAFETY_FRAMEWORK block and replaced
the persona, so a jailbreak in the role text sat before the safety rules.
buildSystemPrompt now emits SAFETY both before AND after the persona, with the
role/persona delimited as lower-trust (<role_persona note=...>); the default
persona is sandwiched too. Context (currently-viewing-page) preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:17:37 +03:00
claude code agent 227
212bcea4d7 fix(page): movePage cycle guard + no phantom PAGE_MOVED (#67, #64)
#67: movePage didn't check the destination wasn't the page itself or inside its
own subtree, so MCP/REST/agent/fast-drag could persist+broadcast a cycle. Reject
before the update (self-parent, or moved page among the destination parent's
ancestors via getPageBreadCrumbs).
#64: movePage emitted PAGE_MOVED from a stale pre-read even when the row didn't
change / was concurrently deleted (phantom move). Gate the emit on
updateResult.numUpdatedRows !== 0n. Both are movePage hardening in one method.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:08:34 +03:00
claude code agent 227
05a7a4001f fix(share-ai): cap per-request output + unify provider errors (#60, #95)
#60: streamText had no maxOutputTokens, so one anonymous request could run up
the provider bill. Add maxOutputTokens (env SHARE_AI_MAX_OUTPUT_TOKENS, default
512) via resolveShareAiMaxOutputTokens().
#95: the anonymous path hand-built error strings, diverging from the unified
describeProviderError format used on the authenticated path; both onError blocks
now call describeProviderError so a share reader sees 402/429/503 causes in the
same form (and the stack is still logged). Both changes are in this one file and
share hunks, hence one commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:08:34 +03:00
claude code agent 227
5344a9bdde fix(auth): handle null-password (SSO/LDAP-only) accounts without bcrypt throw (#70)
A user with password=NULL passed the missing/disabled guard and reached
comparePasswordHash(pw, null), which native bcrypt rejects -> 500 on
/api/auth/login and, on /mcp, a leaky 401 that the brute-force limiter ignored
(enumeration oracle + limiter evasion). Treat a null/empty password like a
missing user in verifyUserCredentials (dummy compare for timing parity + unified
CREDENTIALS_MISMATCH_MESSAGE) and reject early in changePassword before bcrypt.
Contract spec asserts the null-password guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:07:53 +03:00
claude code agent 227
2b4ec0bfcc fix(share-ai): reject non-text message parts to close size-cap bypass (#63)
MAX_SHARE_MESSAGE_CHARS only counted text parts, so a forged non-text part
(tool-result/file/data) bypassed the cap and bloated the model input
(token-DoS); convertToModelMessages would also expand a forged tool-result. The
anonymous path runs no tools, so a client non-text part is never legitimate —
reject any message with a non-text part (isTextUIPart) before the size check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:07:53 +03:00
claude code agent 227
e19849d980 fix(share-ai): fail-closed workspace limiter on Redis failure (#62)
The per-workspace anonymous share-AI cost cap failed OPEN on a Redis error
(return true => admit), so a Redis outage removed the cap entirely (unmetered
billable anonymous calls). The feature is optional, so unavailability is
harmless: fail CLOSED (return false => controller 429s) instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:07:53 +03:00
claude_code
81823fce1e feat(html-embed): sandbox the embed block; split trusted trackers into an admin field
Convert the htmlEmbed node from same-origin raw-HTML execution to a sandboxed
iframe (sandbox="allow-scripts allow-popups allow-forms", no allow-same-origin,
srcdoc) with postMessage auto-resize (validated by event.source) and an optional
manual height attr. The block now runs in an opaque origin and cannot reach the
viewer's cookies/session/API, so it is safe for any member.

Because the block is now harmless, remove the entire admin/role gating apparatus:
drop htmlEmbedAllowed/canAuthorHtmlEmbed/stripDisallowedHtmlEmbedNodes/
collectHtmlEmbedSources and every role-based strip on the write paths (collab
REST/MCP + socket, page create/duplicate, import x2, transclusion unsync), along
with the now-unused WorkspaceRepo/UserRepo injections and the PageService.create
callerRole param. Keep one strip: prepareContentForShare still removes htmlEmbed
on the anonymous public-share read path when the workspace master toggle is OFF.

The workspace settings.htmlEmbed toggle is now a plain feature switch (gates the
slash-menu and share rendering); when ON the block is available to all members.

Add settings.trackerHead: an admin-only raw HTML/JS analytics snippet injected
verbatim into the <head> of public share pages only (ShareSeoController), for
trackers that genuinely need same-origin. Admin-gated via the existing CASL
Manage/Settings ability; never injected into the authenticated app shell.

Closes security-review findings #1, #2, #4, #5, #10 (and #3 as a security issue).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:48:41 +03:00
claude_code
64818cf9df Merge branch 'feat/share-ai-cost-guards' into develop 2026-06-21 02:21:04 +03:00
claude_code
262a0707d9 feat(share-ai): cap per-request output tokens and fail closed on Redis loss
Harden the anonymous public-share AI assistant against token-cost abuse
before exposing it to the internet:

- Add an env-tunable per-request output ceiling (maxOutputTokens) to the
  public-share streamText call so one anonymous request cannot run up the
  provider bill even if the per-IP throttle is evaded. New
  resolveShareAiMaxOutputTokens() / SHARE_AI_MAX_OUTPUT_TOKENS_DEFAULT
  (env SHARE_AI_MAX_OUTPUT_TOKENS, default 512), mirroring
  resolveShareAiWorkspaceMax().
- Flip the per-workspace cost limiter to FAIL CLOSED on Redis failure
  (was fail-open): if Redis is unavailable we cannot prove the workspace is
  under its cap, so deny rather than admit an unmetered, billable call.
- Update the limiter spec (fail-open -> fail-closed) and add resolver tests;
  document both knobs in .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:15:54 +03:00