Skip to content

MM-69271: Editor authoring – page drafts, TipTap content handling, and presence - #5

Merged
catalintomai merged 36 commits into
masterfrom
MM-69271-editor-authoring
Jul 28, 2026
Merged

MM-69271: Editor authoring – page drafts, TipTap content handling, and presence#5
catalintomai merged 36 commits into
masterfrom
MM-69271-editor-authoring

Conversation

@catalintomai

@catalintomai catalintomai commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR adds the editor-authoring layer on top of the page tree CRUD foundation (MM-69268). It introduces per-user page drafts with autosave semantics, TipTap document sanitization and content extraction, and a lightweight presence system derived from draft activity.

Page Drafts (autosave)

Six new endpoints manage the full draft lifecycle:

Method Path Description
POST /spaces/{space_id}/drafts Reserve a new-page draft before the page row exists
GET /spaces/{space_id}/drafts List all user's drafts in a space
PATCH /spaces/{space_id}/pages/{page_id}/draft Upsert (autosave) a draft — merge semantics: omitted fields are preserved
GET /spaces/{space_id}/pages/{page_id}/draft Fetch the calling user's draft
DELETE /spaces/{space_id}/pages/{page_id}/draft Discard a draft
POST /spaces/{space_id}/pages/{page_id}/draft/publish Publish a draft to the live page, with optimistic-lock conflict detection

Merge-on-write autosave: PATCH /draft merges rather than replaces — only fields the editor touched need to be sent. Partial heartbeats from different editor panels cannot clobber each other.

Optimistic-lock conflict detection: the client stores original_page_edit_at in draft props when opening an existing page. On publish, the server rejects with 409 Conflict if another user has since saved the page, prompting a merge flow.

Draft migration (000005_add_draft_lastactiveat): adds LastActiveAt to the DOCS_Draft table, stamped on every autosave and used as the presence heartbeat.

TipTap Content Handling

  • model.ParseTipTapDocument — parses and sanitizes TipTap JSON (strips disallowed node types, base64-decodes image src, enforces depth/size limits).
  • model.BuildSearchText — extracts plain text + mention labels for full-text indexing.
  • app.normalizePageContent — shared normalization path used by both draft autosave and page publish, so stored content is always sanitized before it lands in the DB.

Page Presence (active editors)

  • GET /spaces/{space_id}/pages/{page_id}/active-editors returns user IDs actively editing the page.
  • Presence is derived from DOCS_Draft.LastActiveAt: any user with a draft updated within the last 5 minutes counts as active.
  • Presence broadcasts fire on every autosave (rate-limited to 1 per 30 s per page per editor), and unconditionally on draft delete and publish.
  • Moving a page to another space clears its re-homed drafts' presence heartbeat, so a source-only editor is not surfaced to target-space members after the move.
  • Best-effort: a store failure yields an empty list and never fails the triggering request.

WebSocket Events

  • page_presence_updated — the active-editors snapshot (active_editors, as_of, active_timeout_ms). Broadcast channel-wide on autosave (rate-limited to 1 per 30 s per page per editor) and unconditionally on discard and publish; for a new-page draft (no live page yet) it is sent only to the author to avoid disclosing the reserved page id.
  • page_created — fired when a publish creates the live page for a new-page draft.
  • page_updated — fired when a publish updates an already-live page.

@mattermost mattermost deleted a comment from coderabbitai Bot Jul 15, 2026
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5689b3a6-7e30-4117-9502-3535ad286e99

📥 Commits

Reviewing files that changed from the base of the PR and between 81e731d and 8cfb94f.

📒 Files selected for processing (5)
  • server/api_page_drafts_test.go
  • server/app/page_content_test.go
  • server/app/page_draft_test.go
  • server/model/page_content.go
  • server/model/page_content_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • server/model/page_content.go
  • server/api_page_drafts_test.go
  • server/model/page_content_test.go
  • server/app/page_draft_test.go

📝 Walkthrough

Walkthrough

This change adds page-draft CRUD and publishing APIs, transactional draft storage, optimistic conflict handling, server-side TipTap content normalization, derived search text, active-editor presence events, hierarchy and quota validation, and extensive integration and store test coverage.

Changes

Draft lifecycle and page content

Layer / File(s) Summary
Content normalization and search projection
server/model/page_content.go, server/app/page_content.go, server/app/page.go, server/api_page.go, server/model/*_test.go
TipTap and plain-text bodies are normalized and sanitized, while SearchText is derived server-side from page content.
Draft data and transactional publishing
server/model/draft.go, server/store/draft_store.go, server/store/page_store.go, server/store/page_move.go, server/store/migrations/*
Draft baselines, activity timestamps, quotas, hierarchy validation, versioned deletion, cross-space movement, and atomic new-page/edit publishing are implemented.
Draft service and HTTP lifecycle
server/app/page_draft.go, server/api_page_drafts.go, server/api.go, server/*draft*_test.go
Draft creation, autosave, retrieval, deletion, listing, publishing, membership checks, pagination, force publishing, and conflict responses are exposed through HTTP and service methods.
Editor presence and WebSocket events
server/app/page_presence.go, server/app/ws_events.go, server/api_page_presence.go, server/app/*presence*_test.go, server/app/ws_events_test.go
Active editors are tracked from LastActiveAt, returned through REST, and broadcast through throttled WebSocket presence snapshots.
Routing, translations, and compatibility
assets/i18n/en.json, server/app/service.go, server/model/props.go, server/model/page.go, server/app/page_hierarchy.go, go.mod, README.md
Draft and presence error keys, shared validation exports, depth-limit wiring, route authorization coverage, dependency pinning, and documentation spacing are updated.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly reflects the main additions: page drafts, TipTap content handling, and presence.
Description check ✅ Passed The description matches the PR’s draft, content, and presence features and is on-topic.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch MM-69271-editor-authoring

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (2)
server/store/store_test.go (1)

2684-2685: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the specific publish conflict reasons.

These tests pass even if PublishDraft drops the reason used by the app layer to distinguish concurrent edits from concurrent autosaves.

Proposed assertions
 _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, draft.UpdateAt)
 require.True(t, store.IsErrConflict(err), "a stale baseline must conflict, got %v", err)
+require.Equal(t, store.ReasonConcurrentEdit, store.ConflictReason(err))

 _, err = s.PublishDraft(false, &edit, userID, space.Id, false, testDefaultMaxDepth, stale.UpdateAt)
 require.True(t, store.IsErrConflict(err), "publishing stale draft content must conflict, got %v", err)
+require.Equal(t, store.ReasonConcurrentAutosave, store.ConflictReason(err))

Also applies to: 2743-2744

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/store/store_test.go` around lines 2684 - 2685, Strengthen the
assertions around the PublishDraft calls in the stale-baseline tests near the
shown assertion and the corresponding case at 2743-2744. In addition to
verifying store.IsErrConflict(err), assert that the conflict error preserves the
specific expected reason for each scenario, distinguishing concurrent edits from
concurrent autosaves as consumed by the application layer.
server/model/draft.go (1)

170-187: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

EditBaseline conflates "absent" with "unparseable type."

If original_page_edit_at is present but not float64/int64/int (e.g. a client sends a string), this silently returns (0, false) — identical to the key being absent entirely. SanitizeProps only checks key presence, not value type, so a malformed value survives sanitization and could cause a previously-valid baseline to read back as "no baseline" on a later call, changing the optimistic-lock/new-page branching in whatever caller consumes this (not in this review batch). Consider surfacing "present but malformed" distinctly (e.g. a third return value, or reject at IsValid/write time) so a bad value fails loudly instead of silently downgrading to "no baseline."

💡 Sketch: distinguish malformed from absent
-func (d *Draft) EditBaseline() (int64, bool) {
+// EditBaseline extracts the optimistic-lock baseline. ok=false means "absent"; malformed=true means
+// the key was present but not a recognized numeric type, so callers can reject it explicitly instead
+// of treating it as "no baseline."
+func (d *Draft) EditBaseline() (value int64, ok bool, malformed bool) {
 	v, ok := d.GetProps()[DraftPropsOriginalPageEditAt]
 	if !ok {
-		return 0, false
+		return 0, false, false
 	}
 	switch n := v.(type) {
 	case float64:
-		return int64(n), n != 0
+		return int64(n), n != 0, false
 	case int64:
-		return n, n != 0
+		return n, n != 0, false
 	case int:
-		return int64(n), n != 0
+		return int64(n), n != 0, false
 	}
-	return 0, false
+	return 0, false, true
 }

Also worth adding direct unit tests in draft_test.go for EditBaseline's type-switch branches and SanitizeProps's allowlist behaviour — currently only exercised indirectly via app-layer integration tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/model/draft.go` around lines 170 - 187, Update Draft.EditBaseline so a
present but unsupported original_page_edit_at value is distinguished from an
absent or zero baseline, rather than returning the same (0, false) result; use
an explicit error/status return or reject malformed values during
validation/write handling. Preserve valid float64, int64, and int conversions,
and add direct unit coverage for EditBaseline type branches and SanitizeProps
allowlist behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/app/page_draft.go`:
- Around line 287-291: Ensure successful draft discards cannot be resurrected by
in-flight autosaves. Add a server-side tombstone or generation/order marker in
the discard flow and make UpsertDraft reject autosaves issued before the
discard, including unpublished drafts without a page row; preserve normal
autosave behavior for drafts that were not discarded.
- Around line 145-154: Update the presence limiter around
presenceBroadcastLast.LoadOrStore and CompareAndSwap to key entries by both page
ID and editor user, and apply the same page-and-user key to publish/delete
cleanup. In server/app/ws_events_test.go lines 350-389, add a second user and
verify that their first autosave broadcasts within the interval; retain the
existing repeated-save coverage for the original user.
- Around line 91-108: Update the unpublished-page validation in UpdatePageDraft
so a missing caller-owned draft is rejected even when another user has reserved
the page ID. Remove the AnyDraftExistsForPageInSpace-based allowance and return
the existing page-not-found error whenever PageExistsInSpace reports no live
page; retain the live-page path and existing error handling.

In `@server/app/page_presence.go`:
- Around line 28-40: The presence flow must distinguish a failed active-editor
query from a successful empty result. Update getActiveEditors and its callers,
including broadcastPagePresence, to return or propagate a success flag, and skip
publishing the snapshot or fresh as_of when the query fails; preserve the
originating request’s best-effort behavior and continue broadcasting valid empty
results.

In `@server/app/page.go`:
- Around line 316-328: Update clonePageFields so Props is recursively
deep-cloned rather than copied with maps.Clone. Preserve all existing fields and
use the project’s established recursive JSON-value cloning approach so nested
maps and slices in src.Props cannot alias the cloned page.

In `@server/app/service.go`:
- Around line 154-165: Make the ReasonDraftCycle and ReasonDraftTooDeep mappings
in storeAppError operation-aware so CreateSpaceDraft never receives the
update-only app.page_draft.update.* translation keys. Use the appropriate
create-safe or shared translation keys for create operations while preserving
the existing update mappings for update operations.

In `@server/store/draft_store.go`:
- Line 231: Run gofmt on the Go source containing the parentIDParam declaration
and ensure the resulting formatting passes the repository’s formatting check.
- Around line 490-529: Update DeleteDraftReparenting to reparent child drafts
only when the deleted draft’s page is no longer live in that draft’s space.
Before executing the reparentQ update, check the page’s live status using the
existing page/space lookup symbols; skip the update for published-page edit
drafts while preserving deletion and transaction behavior.
- Around line 188-197: The live-ancestor depth check in the draft validation
flow uses MaxPageHierarchyDepth instead of the application’s draft/publishing
limit. Update the comparison in pageDepth handling to use
draftCycleCheckMaxDepth (or the explicitly propagated publishing limit),
ensuring the computed live depth plus draft chain and new leaf cannot exceed the
10-level limit.

In `@server/store/page_move.go`:
- Around line 430-457: Update server/store/page_move.go lines 430-457 in
rewriteSubtreeSpace to compute the complete transitive draft closure, including
nested new-page descendants, before counting mover drafts and enforcing the
target-space quota; update lines 496-508 in rewriteSubtreeSpace to delete or
reparent every affected non-mover descendant from that same closure rather than
only direct children.

---

Nitpick comments:
In `@server/model/draft.go`:
- Around line 170-187: Update Draft.EditBaseline so a present but unsupported
original_page_edit_at value is distinguished from an absent or zero baseline,
rather than returning the same (0, false) result; use an explicit error/status
return or reject malformed values during validation/write handling. Preserve
valid float64, int64, and int conversions, and add direct unit coverage for
EditBaseline type branches and SanitizeProps allowlist behavior.

In `@server/store/store_test.go`:
- Around line 2684-2685: Strengthen the assertions around the PublishDraft calls
in the stale-baseline tests near the shown assertion and the corresponding case
at 2743-2744. In addition to verifying store.IsErrConflict(err), assert that the
conflict error preserves the specific expected reason for each scenario,
distinguishing concurrent edits from concurrent autosaves as consumed by the
application layer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0afb0479-a2c3-4178-8055-82d0f2445b95

📥 Commits

Reviewing files that changed from the base of the PR and between 5f724e1 and ed1325b.

📒 Files selected for processing (31)
  • assets/i18n/en.json
  • server/api.go
  • server/api_handler_test.go
  • server/api_page.go
  • server/api_page_drafts.go
  • server/api_page_drafts_test.go
  • server/api_page_presence.go
  • server/app/page.go
  • server/app/page_content.go
  • server/app/page_content_test.go
  • server/app/page_draft.go
  • server/app/page_draft_test.go
  • server/app/page_duplicate_test.go
  • server/app/page_hierarchy.go
  • server/app/page_presence.go
  • server/app/service.go
  • server/app/service_test.go
  • server/app/ws_events.go
  • server/app/ws_events_test.go
  • server/model/draft.go
  • server/model/draft_test.go
  • server/model/page_content.go
  • server/model/page_content_test.go
  • server/store/draft_store.go
  • server/store/migrations/000005_add_draft_lastactiveat.down.sql
  • server/store/migrations/000005_add_draft_lastactiveat.up.sql
  • server/store/page_move.go
  • server/store/page_move_test.go
  • server/store/page_store.go
  • server/store/store.go
  • server/store/store_test.go

Comment thread server/app/page_draft.go Outdated
Comment thread server/app/page_draft.go Outdated
Comment thread server/app/page_draft.go Outdated
Comment thread server/app/page_presence.go Outdated
Comment thread server/app/page.go
Comment thread server/app/service.go
Comment thread server/store/draft_store.go
Comment thread server/store/draft_store.go Outdated
Comment thread server/store/draft_store.go Outdated
Comment thread server/store/page_move.go Outdated
@mattermost mattermost deleted a comment from coderabbitai Bot Jul 15, 2026
@catalintomai
catalintomai marked this pull request as draft July 15, 2026 13:37
@mattermost mattermost deleted a comment from coderabbitai Bot Jul 15, 2026
@mattermost mattermost deleted a comment from coderabbitai Bot Jul 15, 2026
@mattermost mattermost deleted a comment from coderabbitai Bot Jul 15, 2026
@mattermost mattermost deleted a comment from coderabbitai Bot Jul 15, 2026
catalintomai and others added 20 commits July 17, 2026 08:34
Drop the hello-world scaffolding inherited from the plugin starter
template: the /hello API route and handler, the hello slash command and
its mocks, the demo background job, the KV store sample, public/hello.html,
and the placeholder webapp test. The router, auth middleware, and
configuration plumbing the Docs feature builds on are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@nang2049 nang2049 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Comment thread server/app/page_draft.go Outdated
// broadcast is needed.
if !pageWasLive {
return nil
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest calling publishToUser(wsEventPagePresenceUpdated with empty editor list or alternately, making a helper similar to publishSelfPresence

Comment thread server/app/page_draft.go Outdated
return draft, nil
}

// DeletePageDraft removes the calling user's draft for the given page (on publish or discard).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 513 calls out that PublishPageDraft bypasses "the app-level DeletePageDraft" so this "(on publish or discard)" is misleading.

Comment thread server/app/page_draft.go Outdated
nil, "", http.StatusConflict)
}
s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, existing.ChannelId)
return existing, false, nil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of returning existing, suggestion is to refetch to handle any concurrency issues. Not sure if that is actually possible (but might be later?)

current, getErr := s.GetPage(pageID)
if getErr != nil {
	return nil, false, getErr
}
s.clearThrottleAndBroadcastPagePresence(pageID, userID, spaceID, current.ChannelId)
return current, false, nil

@jgheithcock jgheithcock left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than my nits, looks good.

@JulienTant JulienTant left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also worth noting that I ask AI to review the indices and it returned those:

  • Missing composite index (PageId, SpaceId, LastActiveAt) for GetPageActiveEditors — a per-autosave hot path currently
    scanning+re-filtering on the PageId-only index
    File:Line: store/migrations/000005_*.up.sql, store/draft_store.go:625-650

  • Page-move draft-rehoming cascade filters/joins on unindexed SpaceId/ParentId while holding both space locks (lower urgency —
    admin-triggered, not per-autosave)
    File:Line: store/page_move.go:525-543
    Source(s): rev-db-arch

Comment thread server/model/page_content.go Outdated
Comment thread server/app/page_draft.go Outdated
Comment thread server/store/store_test.go
@catalintomai

Copy link
Copy Markdown
Collaborator Author

Also worth noting that I ask AI to review the indices and it returned those:

  • Missing composite index (PageId, SpaceId, LastActiveAt) for GetPageActiveEditors — a per-autosave hot path currently
    scanning+re-filtering on the PageId-only index
    File:Line: store/migrations/000005_*.up.sql, store/draft_store.go:625-650
  • Page-move draft-rehoming cascade filters/joins on unindexed SpaceId/ParentId while holding both space locks (lower urgency —
    admin-triggered, not per-autosave)
    File:Line: store/page_move.go:525-543
    Source(s): rev-db-arch

re: "composite (PageId, SpaceId, LastActiveAt):"
Declining this one: idx_docs_draft_pageid already narrows the scan to one page's drafts — bounded by concurrent editors of that page, a handful of rows — so the extra filtering is negligible. Also LastActiveAt changes on every autosave, so indexing it adds index maintenance to the hottest write path.

re: "unindexed SpaceId/ParentId in the move cascade"
Declining as well: cross-space moves are rare, and DOCS_Draft stays small (rows are deleted on publish/discard), so the seq scan is cheap even under the space locks. An index here would tax every autosave to speed up a rare path.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
server/store/page_store.go (1)

781-795: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Nil-patch guard missing in PublishPageEditDraft.

PublishNewPageDraft guards page == nil before use, but here patch.IsValid() is called without a nil check. Add a symmetric guard so a store-level caller cannot panic.

🛡️ Proposed guard
 	if userID == "" {
 		return nil, &ErrInvalidInput{Entity: "Draft", Field: "userID", Value: userID}
 	}
+	if patch == nil {
+		return nil, &ErrInvalidInput{Entity: "Page", Field: "Patch", Value: nil}
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/store/page_store.go` around lines 781 - 795, Add a nil check for patch
in PublishPageEditDraft before calling patch.IsValid(), returning the same
store-level invalid-input error used for invalid patches. Preserve the existing
validation order so nil or invalid patches are rejected before opening the
transaction.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/store/page_store.go`:
- Around line 781-795: Add a nil check for patch in PublishPageEditDraft before
calling patch.IsValid(), returning the same store-level invalid-input error used
for invalid patches. Preserve the existing validation order so nil or invalid
patches are rejected before opening the transaction.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 409536e9-ec0c-433b-9780-27580ff56182

📥 Commits

Reviewing files that changed from the base of the PR and between 256687a and be22639.

📒 Files selected for processing (22)
  • assets/i18n/en.json
  • server/api_page_drafts.go
  • server/api_page_drafts_test.go
  • server/app/page_content.go
  • server/app/page_content_test.go
  • server/app/page_draft.go
  • server/app/page_draft_internal_test.go
  • server/app/page_draft_test.go
  • server/app/page_presence.go
  • server/app/service_test.go
  • server/app/ws_events.go
  • server/app/ws_events_test.go
  • server/model/draft.go
  • server/model/page_content.go
  • server/model/page_content_test.go
  • server/store/draft_store.go
  • server/store/draft_store_test.go
  • server/store/page_move.go
  • server/store/page_move_test.go
  • server/store/page_store.go
  • server/store/store.go
  • server/store/store_test.go
🚧 Files skipped from review as they are similar to previous changes (11)
  • server/api_page_drafts.go
  • server/model/draft.go
  • assets/i18n/en.json
  • server/store/page_move_test.go
  • server/store/store.go
  • server/store/page_move.go
  • server/model/page_content.go
  • server/api_page_drafts_test.go
  • server/app/page_content.go
  • server/model/page_content_test.go
  • server/store/draft_store.go

Willyfrog added a commit that referenced this pull request Jul 27, 2026
Renumber the import migration and fix seven inspector/model hardening issues
that are independent of PR #5:

- Migration renumbered 000005 -> 000006 to avoid colliding with PR #5's
  000005_add_draft_lastactiveat_baseeditat (morph keys on the version number,
  so two 000005s would block plugin activation). Updated the model comment and
  the implementation plan references.

archive.go:
- Validate mode, encryption, and compression method for every file entry,
  including data/ payloads that are never opened (previously method/encryption
  were checked only for import.jsonl/import-manifest.json).
- Genuinely normalize entry names via path.Clean before duplicate detection
  (after the raw ".." check), so "data//x" and "data/x" collide as duplicates
  instead of the "normalized" map being a no-op alias of the raw map.

inspect.go:
- Reject a manifest with trailing data after its JSON object (decoder stopped
  at the first value).
- Reject a JSONL line that carries a payload not matching its declared type
  (e.g. type:"page" also carrying a "space" payload).
- Reject a bundle whose manifest source has no space key, since it becomes the
  ImportSource's required ExternalSpaceKey.
- Use attachments_not_imported (plan section 20.2) for the attachment-records
  issue, distinct from the attachment_placeholder_not_imported link code.
- Judge future timestamps against InspectOptions.Now + a skew allowance when
  supplied (fixed year-2100 ceiling as the pure-function fallback).
- Include the manifest advisory target team in the aggregate team-mismatch
  check.

model/import.go:
- Require BundleSha256 to be a valid 64-hex digest (never empty) at the model
  boundary; a persisted job always has it from inspection.

Added unit tests for each. go test ./server/... , go build ./... , and
golangci-lint on the changed packages all pass; the renamed migration applies
cleanly via the store test harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@catalintomai
catalintomai requested a review from JulienTant July 27, 2026 11:06
@catalintomai catalintomai added the 2: Dev Review Requires review by a core committer label Jul 27, 2026

@JulienTant JulienTant left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @catalintomai!

@catalintomai

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (6)
server/model/page_content_test.go (1)

131-134: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Stale "denylist"/case-insensitive comments.

The sanitizer now uses a case-sensitive allowlist, so SCRIPT/IFrame/MAction are rejected because they are absent from the allowlist, not because of case-insensitive denylist matching. The assertions still hold; only the annotations mislead.

Also applies to: 154-155, 186-186

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/model/page_content_test.go` around lines 131 - 134, Update the
comments in TestParseTipTapDocumentRejectsForbiddenTypes and the referenced
assertion locations to describe the sanitizer’s case-sensitive allowlist,
removing stale denylist and case-insensitive matching language while preserving
the existing rejection assertions.
server/app/page_content_test.go (1)

118-138: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No direct coverage for normalizeContentBody.

The draft autosave path goes through normalizeContentBody, which is the only entry point that skips SearchText derivation. A small test (empty → no-op, valid TipTap → normalized, invalid → 400) would pin that branch alongside the page path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/app/page_content_test.go` around lines 118 - 138, Add focused coverage
for normalizeContentBody alongside the existing normalizePageContent tests:
verify empty input is a no-op, valid TipTap content is normalized without
SearchText derivation, and invalid content returns an HTTP 400 application
error. Use the existing test helpers and assertions, and keep the tests scoped
to the draft autosave normalization branch.
server/model/page_content.go (2)

256-291: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Sanitized values are written back under the original (untrimmed) key.

m[key] = sanitizeURL(v) re-keys nothing, so " href" stays " href" while a sibling "href" may also exist. Both are sanitized, so this is safe today, but a renderer resolving the trimmed name could see two entries. Consider deleting the padded variant instead of rewriting it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/model/page_content.go` around lines 256 - 291, The stripDangerousKeys
function currently retains whitespace-padded attribute keys after sanitizing
their values, allowing both padded and canonical names to coexist. When
trimBrowserIgnoredChars changes a key, remove the original padded entry rather
than writing the sanitized value back under it, while preserving the existing
handling for canonical keys and dangerous attributes.

347-366: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Attrs depth restarts at 0 while flat-key values inherit node depth.

sanitizeAttrs(attrs, 0) on Line 353 gives the attrs subtree its own 100-level budget, but Line 361 passes the node depth for flat keys, so flat-key containers get 100 - depth. Both fail closed, but the asymmetry is worth a short comment so a future reader doesn't read it as a bug.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/model/page_content.go` around lines 347 - 366, The depth behavior in
sanitizeObjAttrsAndFlatKeys is intentional but undocumented: sanitizeAttrs
should continue restarting the attrs subtree at depth 0, while sanitizeAttrValue
should continue using the current node depth for flat-key values. Add a brief
comment near these calls documenting this distinction without changing the
existing depth handling.
server/api_page_drafts_test.go (1)

120-137: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Comments say PUT, but the requests are PATCH.

Lines 121, 128 (comment), 135 and 159/168 describe the draft-update route as PUT while every call uses http.MethodPatch. Worth aligning so the comments stay usable as the contract description.

♻️ Suggested wording fix
-// TestHandler_UpdatePageDraftRequiresExistingDraft confirms the update-only guard: PUT on a page id
+// TestHandler_UpdatePageDraftRequiresExistingDraft confirms the update-only guard: PATCH on a page id
 // that has no existing draft must return 404 rather than silently creating one.
@@
-// distinction: sending parent_id: "" in the PUT body must clear an existing parent (set it to
+// distinction: sending parent_id: "" in the PATCH body must clear an existing parent (set it to
 // root), while omitting parent_id entirely must leave the parent unchanged.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api_page_drafts_test.go` around lines 120 - 137, Align the
draft-update test comments with the implemented HTTP method by replacing
references to PUT with PATCH in TestHandler_UpdatePageDraftRequiresExistingDraft
and the adjacent parent-clearing test comments, while leaving the
http.MethodPatch requests unchanged.
server/app/page_draft_test.go (1)

805-815: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Derive chainLen from model.MaxPageDepth instead of hard-coding 10.

The whole point of the test is the depth boundary, but the literal silently decouples from the constant if it ever changes — the test would then pass without exercising the limit.

♻️ Suggested change
-	const chainLen = 10
+	const chainLen = model.MaxPageDepth
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/app/page_draft_test.go` around lines 805 - 815, Update the
chain-length setup in the draft depth-boundary test to derive chainLen from
model.MaxPageDepth instead of hard-coding 10, preserving the existing loop and
parent-chain construction so the test continues to exercise the configured
maximum depth.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/api_page_drafts_test.go`:
- Around line 120-137: Align the draft-update test comments with the implemented
HTTP method by replacing references to PUT with PATCH in
TestHandler_UpdatePageDraftRequiresExistingDraft and the adjacent
parent-clearing test comments, while leaving the http.MethodPatch requests
unchanged.

In `@server/app/page_content_test.go`:
- Around line 118-138: Add focused coverage for normalizeContentBody alongside
the existing normalizePageContent tests: verify empty input is a no-op, valid
TipTap content is normalized without SearchText derivation, and invalid content
returns an HTTP 400 application error. Use the existing test helpers and
assertions, and keep the tests scoped to the draft autosave normalization
branch.

In `@server/app/page_draft_test.go`:
- Around line 805-815: Update the chain-length setup in the draft depth-boundary
test to derive chainLen from model.MaxPageDepth instead of hard-coding 10,
preserving the existing loop and parent-chain construction so the test continues
to exercise the configured maximum depth.

In `@server/model/page_content_test.go`:
- Around line 131-134: Update the comments in
TestParseTipTapDocumentRejectsForbiddenTypes and the referenced assertion
locations to describe the sanitizer’s case-sensitive allowlist, removing stale
denylist and case-insensitive matching language while preserving the existing
rejection assertions.

In `@server/model/page_content.go`:
- Around line 256-291: The stripDangerousKeys function currently retains
whitespace-padded attribute keys after sanitizing their values, allowing both
padded and canonical names to coexist. When trimBrowserIgnoredChars changes a
key, remove the original padded entry rather than writing the sanitized value
back under it, while preserving the existing handling for canonical keys and
dangerous attributes.
- Around line 347-366: The depth behavior in sanitizeObjAttrsAndFlatKeys is
intentional but undocumented: sanitizeAttrs should continue restarting the attrs
subtree at depth 0, while sanitizeAttrValue should continue using the current
node depth for flat-key values. Add a brief comment near these calls documenting
this distinction without changing the existing depth handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c0887b40-fba8-4190-afd5-619f0fac7691

📥 Commits

Reviewing files that changed from the base of the PR and between 598cb9c and 81e731d.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (46)
  • README.md
  • assets/i18n/en.json
  • go.mod
  • server/api.go
  • server/api_handler_test.go
  • server/api_page.go
  • server/api_page_drafts.go
  • server/api_page_drafts_test.go
  • server/api_page_presence.go
  • server/app/page.go
  • server/app/page_content.go
  • server/app/page_content_test.go
  • server/app/page_draft.go
  • server/app/page_draft_internal_test.go
  • server/app/page_draft_test.go
  • server/app/page_duplicate_test.go
  • server/app/page_hierarchy.go
  • server/app/page_move_test.go
  • server/app/page_move_to_space_test.go
  • server/app/page_presence.go
  • server/app/page_presence_test.go
  • server/app/pagination.go
  • server/app/service.go
  • server/app/service_test.go
  • server/app/ws_events.go
  • server/app/ws_events_test.go
  • server/model/draft.go
  • server/model/draft_test.go
  • server/model/page.go
  • server/model/page_content.go
  • server/model/page_content_test.go
  • server/model/page_presence.go
  • server/model/props.go
  • server/model/props_test.go
  • server/model/space.go
  • server/store/draft_store.go
  • server/store/draft_store_test.go
  • server/store/migrations/000005_add_draft_lastactiveat_baseeditat.down.sql
  • server/store/migrations/000005_add_draft_lastactiveat_baseeditat.up.sql
  • server/store/page_hierarchy.go
  • server/store/page_move.go
  • server/store/page_move_test.go
  • server/store/page_store.go
  • server/store/space_store.go
  • server/store/store.go
  • server/store/store_test.go

@catalintomai
catalintomai merged commit d58e3e1 into master Jul 28, 2026
6 checks passed
@Willyfrog

Copy link
Copy Markdown
Contributor

a bit late, but approved ;)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2: Dev Review Requires review by a core committer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants