Skip to content

fix: carry the pad deletion token across movePad (#7995) - #8089

Open
JohnMcLear wants to merge 2 commits into
developfrom
fix/7995-move-pad-deletion-token
Open

fix: carry the pad deletion token across movePad (#7995)#8089
JohnMcLear wants to merge 2 commits into
developfrom
fix/7995-move-pad-deletion-token

Conversation

@JohnMcLear

Copy link
Copy Markdown
Member

Closes #7995 (the "moved pads don't keep, offer a new deletion token" half — the "let me turn the popup off" half is #7996).

Repro (from @dcht00)

  1. create pad abc → deletion-token modal, save the token
  2. movePad abcdef
  3. open def → a second deletion-token modal, and the token saved in step 1 no longer deletes anything

Root cause

API.movePad is Pad.copy() + Pad.remove():

const pad = await getPadSafe(sourceID, true);
await pad.copy(destinationID, force);
await pad.remove();

Pad.copy() replicates only the pad:<id>, pad:<id>:revs:N and pad:<id>:chat:N records — it never touches pad:<id>:deletionToken (src/node/db/Pad.ts). Pad.remove() then calls padDeletionManager.removeDeletionToken(sourceID). Net effect: the token record is destroyed and never recreated at the new id.

Two visible consequences:

  • the plaintext token the creator was told to save validates against nothing — deletePad(def, <token>) returns invalid deletionToken;
  • the copy preserves the revision-0 author, so on the renamed pad the creator is still isCreator, createDeletionTokenIfAbsent() finds an empty slot, and PadMessageHandler ships a fresh padDeletionToken → the modal fires a second time.

Fix

New PadDeletionManager.transferDeletionToken(src, dst), called from movePad between copy() and remove() (it must run before remove() drops the source record). A move is a rename, so the token moves with the pad.

  • force-overwriting an existing destination discards that pad's own token — copy() already removed the overwritten pad and its content, so its token would be a key to nothing.
  • copyPad is deliberately left alone. A copy is a separate pad; sharing one hash between two pads would mean a token saved for one deletes the other. The copy gets its own token on the creator's first visit, as today.

Tests

src/tests/backend/specs/api/movePadDeletionToken.ts — 4 cases:

test pre-fix
moved pad accepts the original token invalid deletionToken
moved pad does not offer the creator a second token ❌ mints a new one
movePad --force replaces the destination's own token
copyPad does not share the source token ✅ (guards the fix's blast radius)

Full backend suite: 1617 passing, 0 failing. pnpm run ts-check clean.

Docs: movePad section of doc/api/http_api.md + CHANGELOG entry.

Reported by @dcht00.

🤖 Generated with Claude Code

`movePad` is `Pad.copy()` + `Pad.remove()`, but `copy()` only replicates
the `pad:<id>`, `:revs:N` and `:chat:N` records — never
`pad:<id>:deletionToken` — and `remove()` then dropped the source pad's
token. So a renamed pad had no token at all: the token the creator was
told to save stopped deleting anything, and because the copy preserves
the revision-0 author the creator is still `isCreator` on arrival, so
`createDeletionTokenIfAbsent()` minted a fresh one and popped a second
"save your pad deletion token" modal.

Hand the token record over to the destination as part of the move, so
the saved token keeps working and the modal does not reappear.
`force`-overwriting an existing destination discards that pad's own
token along with its content. `copyPad` deliberately does not do this:
two pads sharing one secret would let a token saved for one delete the
other.

Adds backend coverage for all four cases (three of them fail on the
pre-fix code) plus http_api.md and CHANGELOG notes.

Reported by @dcht00.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 4, 2026 10:42
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 4, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Fix movePad to transfer pad deletionToken on rename

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Preserve pad deletionToken across movePad so saved tokens remain valid after rename.
• Prevent duplicate deletion-token modal by keeping the original token record.
• Add regression tests and document movePad deletionToken semantics (incl. --force).
Diagram

graph TD
  client(["API caller"]) --> move["API.movePad"] --> copy["Pad.copy()"] --> xfer["PadDeletionManager.transferDeletionToken()"] --> remove["Pad.remove()"] --> drop["PadDeletionManager.removeDeletionToken()"]
  xfer --> db[("UeberDB")]
  drop --> db
  subgraph Legend
    direction LR
    _caller(["Caller"]) ~~~ _fn["Function"] ~~~ _db[("Database")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Teach Pad.copy() about deletion tokens via a flag
  • ➕ Keeps all copy-like behavior consolidated in Pad.copy()
  • ➕ Could reduce risk of future callers forgetting token handling
  • ➖ Easy to accidentally enable for copyPad, causing two pads to share a deletion secret
  • ➖ Adds API surface/branching to a hot path for a narrowly-scoped movePad concern
2. Implement movePad as an atomic DB-level rename of pad:* keys
  • ➕ Conceptually cleaner “rename” primitive; no intermediate duplicated state
  • ➕ Could naturally include all sub-records (including deletionToken)
  • ➖ Harder to implement safely across all backends/ueberdb behavior
  • ➖ Higher risk/cross-cutting changes vs. the minimal targeted fix
3. Store deletionToken inside the main pad record (pad:)
  • ➕ Copy/move semantics become automatic because the token travels with pad:
  • ➕ Potentially fewer DB round-trips/keys
  • ➖ Data migration needed; increases blast radius
  • ➖ Mixes a sensitive secret-derived hash into the core pad payload/schema

Recommendation: Current approach (explicit PadDeletionManager.transferDeletionToken() called from API.movePad between copy() and remove()) is the best tradeoff: it fixes the bug with minimal blast radius, preserves the deliberate separation between move vs copy semantics, and is well-covered by targeted regression tests (including --force behavior and ensuring copyPad does not share the token).

Files changed (5) +130 / -0

Bug fix (2) +23 / -0
API.tsTransfer deletionToken during movePad +5/-0

Transfer deletionToken during movePad

• Updates API.movePad to call PadDeletionManager.transferDeletionToken(sourceID, destinationID) after Pad.copy() and before Pad.remove(), ensuring the token record is preserved across renames and not destroyed by remove().

src/node/db/API.ts

PadDeletionManager.tsAdd deletionToken transfer helper for pad renames +18/-0

Add deletionToken transfer helper for pad renames

• Introduces transferDeletionToken(srcPadId, dstPadId) to move the deletion token DB record from source to destination. If the source has no token, it removes any destination token (handling --force overwrite cases).

src/node/db/PadDeletionManager.ts

Tests (1) +94 / -0
movePadDeletionToken.tsAdd regression tests for movePad deletionToken behavior +94/-0

Add regression tests for movePad deletionToken behavior

• Adds backend API tests verifying that movePad preserves deletionToken validity, avoids minting a second token for the creator, handles --force overwrites by replacing the destination token, and confirms copyPad does not share the source token.

src/tests/backend/specs/api/movePadDeletionToken.ts

Documentation (2) +13 / -0
CHANGELOG.mdDocument movePad deletionToken transfer fix +6/-0

Document movePad deletionToken transfer fix

• Adds a 3.3.4 notable fix entry explaining the movePad deletionToken bug, user-visible impact (invalid saved tokens + second modal), and the corrected behavior including --force semantics.

CHANGELOG.md

http_api.mdClarify movePad deletionToken semantics in HTTP API docs +7/-0

Clarify movePad deletionToken semantics in HTTP API docs

• Updates the movePad endpoint documentation to state that moves are renames that carry the deletionToken, avoid re-issuing a second token to the creator, and discard the destination token when overwritten via force. Explicitly notes copyPad behavior is unchanged.

doc/api/http_api.md

Copilot AI 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.

Pull request overview

Fixes Etherpad’s movePad behavior so a pad’s deletion token is preserved across a move (rename), preventing both (a) previously-issued tokens from becoming invalid and (b) the creator being prompted for a second token after the move.

Changes:

  • Added PadDeletionManager.transferDeletionToken(src, dst) to move the stored deletion token record between pad IDs.
  • Updated API.movePad to transfer the token after copy() and before remove().
  • Added backend regression tests for move/copy token behavior; updated HTTP API docs and the changelog.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/node/db/PadDeletionManager.ts Adds deletion-token transfer helper used during pad moves.
src/node/db/API.ts Calls token transfer during movePad to preserve token across rename.
src/tests/backend/specs/api/movePadDeletionToken.ts Adds regression coverage for token behavior across movePad and copyPad.
doc/api/http_api.md Documents movePad deletion-token behavior (including force overwrite semantics).
CHANGELOG.md Adds release note describing the fix and its user-visible impact.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/node/db/PadDeletionManager.ts Outdated
Comment on lines +55 to +60
if (stored == null) {
// The destination pad is being replaced wholesale, so any token it still
// carries belongs to content that no longer exists.
await DB.db.remove(getDeletionTokenKey(dstPadId));
return;
}
@qodo-code-review

qodo-code-review Bot commented Aug 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Token transfer not locked 🐞 Bug ☼ Reliability
Description
transferDeletionToken() is not serialized with createDeletionTokenIfAbsent(), so concurrent creator
connections can interleave token creation with a move and cause the transfer to observe “no token”
or be overwritten by a late token creation. This can still result in a moved pad with no usable
carried-over token (or a token different from what the creator was shown).
Code

src/node/db/PadDeletionManager.ts[R54-55]

+  const stored = await DB.db.get(getDeletionTokenKey(srcPadId));
+  if (stored == null) {
Relevance

●●● Strong

Team previously accepted per-key serialization/mutex to prevent deletion-token races; same
concurrency class here.

PR-#7546

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code explicitly documents and implements per-pad serialization for token creation to prevent
races, and token creation is triggered during normal creator connections. The newly added transfer
path performs token reads/writes without using that serialization mechanism, so it can interleave
with token creation on the same pad IDs.

src/node/db/PadDeletionManager.ts[13-36]
src/node/db/PadDeletionManager.ts[47-66]
src/node/handler/PadMessageHandler.ts[1322-1367]
src/node/db/API.ts[754-775]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`createDeletionTokenIfAbsent()` uses a per-pad in-memory queue (`inflightCreate`) to prevent concurrent token creations from racing and invalidating the plaintext token returned to the creator. `transferDeletionToken()` introduces another token-mutating path (read/set/remove across two pads) but does not participate in that serialization, so it can race with token creation on either the source or destination pad.

### Issue Context
- Token creation is triggered on creator connect (`PadMessageHandler`) and on API/group pad creation.
- `movePad` now runs `transferDeletionToken` after the destination pad has been created by `Pad.copy()` but before the source is removed.

### Fix Focus Areas
- src/node/db/PadDeletionManager.ts[13-63]
- src/node/handler/PadMessageHandler.ts[1322-1366]
- src/node/db/API.ts[754-776]

### Suggested change
- Introduce a shared per-pad token-operation queue/lock (not just for "create") and use it for **both** `createDeletionTokenIfAbsent()` and `transferDeletionToken()`.
- For transfer across two pads, acquire locks in a deterministic order (e.g., lexicographic by padId) to avoid deadlocks, then perform the `get`/`set` (and any cleanup) while holding the locks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Source token removed early ✓ Resolved 🐞 Bug ☼ Reliability
Description
PadDeletionManager.transferDeletionToken() deletes the source pad’s deletionToken before
API.movePad() calls pad.remove(); if pad.remove() fails (or the process crashes) after the transfer,
the source pad can remain but its saved token is already gone. This strands token-based deletion for
the surviving source pad even though Pad.remove() already handles token cleanup on successful
completion.
Code

src/node/db/PadDeletionManager.ts[R61-62]

+  await DB.db.set(getDeletionTokenKey(dstPadId), stored);
+  await DB.db.remove(getDeletionTokenKey(srcPadId));
Relevance

●●● Strong

They accept crash/partial-failure resilience changes; avoid deleting state early before the
operation completes.

PR-#7550

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
movePad performs copy → transferDeletionToken → remove; transferDeletionToken deletes the source
token, but pad.remove() is the operation that actually removes the source pad and already includes
deletion-token cleanup. If pad.remove() does not complete, the source token has already been deleted
by the transfer step.

src/node/db/API.ts[754-775]
src/node/db/PadDeletionManager.ts[47-66]
src/node/db/Pad.ts[821-878]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`transferDeletionToken()` currently removes the source pad’s deletion token immediately after writing the destination token. Because `API.movePad()` invokes this before `pad.remove()`, any failure after the transfer but before successful completion of `pad.remove()` can leave the source pad still present but without its deletion token.

### Issue Context
`Pad.remove()` already calls `padDeletionManager.removeDeletionToken(padID)` as part of the pad deletion flow, so removing the source token inside `transferDeletionToken()` is redundant on success but harmful on partial failure.

### Fix Focus Areas
- src/node/db/PadDeletionManager.ts[47-66]
- src/node/db/API.ts[754-776]

### Suggested change
- In `transferDeletionToken(src, dst)`, keep the `set(dstKey, stored)` but do **not** remove `srcKey` there; rely on `pad.remove()` to remove the source token when the move completes.
- If you still want eager cleanup, only remove the source token *after* `pad.remove()` succeeds (or implement rollback on failure).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/node/db/PadDeletionManager.ts Outdated
Comment thread src/node/db/PadDeletionManager.ts Outdated
Comment on lines +54 to +55
const stored = await DB.db.get(getDeletionTokenKey(srcPadId));
if (stored == null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Token transfer not locked 🐞 Bug ☼ Reliability

transferDeletionToken() is not serialized with createDeletionTokenIfAbsent(), so concurrent creator
connections can interleave token creation with a move and cause the transfer to observe “no token”
or be overwritten by a late token creation. This can still result in a moved pad with no usable
carried-over token (or a token different from what the creator was shown).
Agent Prompt
### Issue description
`createDeletionTokenIfAbsent()` uses a per-pad in-memory queue (`inflightCreate`) to prevent concurrent token creations from racing and invalidating the plaintext token returned to the creator. `transferDeletionToken()` introduces another token-mutating path (read/set/remove across two pads) but does not participate in that serialization, so it can race with token creation on either the source or destination pad.

### Issue Context
- Token creation is triggered on creator connect (`PadMessageHandler`) and on API/group pad creation.
- `movePad` now runs `transferDeletionToken` after the destination pad has been created by `Pad.copy()` but before the source is removed.

### Fix Focus Areas
- src/node/db/PadDeletionManager.ts[13-63]
- src/node/handler/PadMessageHandler.ts[1322-1366]
- src/node/db/API.ts[754-776]

### Suggested change
- Introduce a shared per-pad token-operation queue/lock (not just for "create") and use it for **both** `createDeletionTokenIfAbsent()` and `transferDeletionToken()`.
- For transfer across two pads, acquire locks in a deterministic order (e.g., lexicographic by padId) to avoid deadlocks, then perform the `get`/`set` (and any cleanup) while holding the locks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used

Grey Divider


Remediation recommended

1. Premature token deletion 🐞 Bug ☼ Reliability ⭐ New
Description
transferDeletionToken() deletes the source pad's deletionToken record before movePad attempts
pad.remove(), so a pad.remove() failure can leave the source pad still present but without its
original deletion token. This regresses movePad's failure-mode behavior by potentially stranding
creators who rely on the saved token to delete the original pad.
Code

src/node/db/PadDeletionManager.ts[R61-62]

+  await DB.db.set(getDeletionTokenKey(dstPadId), stored);
+  await DB.db.remove(getDeletionTokenKey(srcPadId));
Evidence
movePad calls transferDeletionToken() before pad.remove(), and transferDeletionToken() deletes the
source token unconditionally; Pad.remove() batches many operations and can reject, potentially
leaving the source pad present but without its token.

src/node/db/API.ts[766-775]
src/node/db/PadDeletionManager.ts[53-63]
src/node/db/Pad.ts[819-878]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`transferDeletionToken()` removes the source deletion-token record (`pad:<src>:deletionToken`) before `pad.remove()` runs. If `pad.remove()` rejects (it executes multiple DB operations in parallel via `Promise.all()`), the source pad may remain (fully or partially) but its token is already gone.

### Issue Context
- `API.movePad()` does: `copy()` → `transferDeletionToken()` → `remove()`.
- `Pad.remove()` performs many removals concurrently; any failure rejects the whole operation.

### Fix Focus Areas
- Adjust token transfer so the source token is not deleted until the move is definitively complete.
- Prefer relying on `Pad.remove()` to delete the source token on success, or implement rollback/restore if the move fails after token manipulation.

### Fix Focus Areas (code refs)
- src/node/db/PadDeletionManager.ts[53-63]
- src/node/db/API.ts[766-775]
- src/node/db/Pad.ts[819-878]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. MovePad token race 🐞 Bug ≡ Correctness ⭐ New
Description
There is a window where Pad.copy() has written the destination pad records but movePad has not yet
transferred the deletion token. If the creator opens the destination during that window,
createDeletionTokenIfAbsent(destinationID) can mint and return a plaintext token that
transferDeletionToken later overwrites, leaving the user with an invalid saved token.
Code

src/node/db/API.ts[R769-774]

+  // A move is a rename, so the pad's deletion token travels with it: the token
+  // the creator saved keeps working, and returning to the renamed pad does not
+  // hand them a second one (issue #7995). Must run before remove(), which drops
+  // the source pad's token record.
+  await padDeletionManager.transferDeletionToken(sourceID, destinationID);
  await pad.remove();
Evidence
Pad.copy() writes destination records before movePad transfers the token; the creator CLIENT_READY
path can mint a token if none exists; transferDeletionToken then overwrites the destination token
record, invalidating any minted plaintext token from that window.

src/node/db/API.ts[766-775]
src/node/db/Pad.ts[634-673]
src/node/handler/PadMessageHandler.ts[1324-1366]
src/node/db/PadDeletionManager.ts[20-35]
src/node/db/PadDeletionManager.ts[53-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`movePad` currently performs `pad.copy()` (which creates the destination pad records) and only afterwards transfers the deletion token. During that interval, the destination pad can be opened and the creator path can call `createDeletionTokenIfAbsent(dst)`, returning a plaintext token that becomes invalid once the transfer overwrites the stored hash.

### Issue Context
- `Pad.copy()` writes `pad:${destinationID}...` records directly.
- `CLIENT_READY` can call `createDeletionTokenIfAbsent(padId)` and return a plaintext token exactly once.
- `transferDeletionToken()` overwrites the destination token record.

### Fix Focus Areas
- Eliminate or reduce the copy→transfer window by moving the token write into the copy/move operation (e.g., add a move-specific option/path in `Pad.copy()` or implement a dedicated `Pad.move()` that sets the token as part of the DB writes).
- Alternatively, introduce a move-in-progress guard so destination token creation cannot emit a plaintext token until after transfer is completed.

### Fix Focus Areas (code refs)
- src/node/db/API.ts[766-775]
- src/node/db/Pad.ts[634-673]
- src/node/db/PadDeletionManager.ts[20-35]
- src/node/db/PadDeletionManager.ts[53-63]
- src/node/handler/PadMessageHandler.ts[1324-1366]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Source token removed early 🐞 Bug ☼ Reliability
Description
PadDeletionManager.transferDeletionToken() deletes the source pad’s deletionToken before
API.movePad() calls pad.remove(); if pad.remove() fails (or the process crashes) after the transfer,
the source pad can remain but its saved token is already gone. This strands token-based deletion for
the surviving source pad even though Pad.remove() already handles token cleanup on successful
completion.
Code

src/node/db/PadDeletionManager.ts[R61-62]

+  await DB.db.set(getDeletionTokenKey(dstPadId), stored);
+  await DB.db.remove(getDeletionTokenKey(srcPadId));
Relevance

●●● Strong

They accept crash/partial-failure resilience changes; avoid deleting state early before the
operation completes.

PR-#7550

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
movePad performs copy → transferDeletionToken → remove; transferDeletionToken deletes the source
token, but pad.remove() is the operation that actually removes the source pad and already includes
deletion-token cleanup. If pad.remove() does not complete, the source token has already been deleted
by the transfer step.

src/node/db/API.ts[754-775]
src/node/db/PadDeletionManager.ts[47-66]
src/node/db/Pad.ts[821-878]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`transferDeletionToken()` currently removes the source pad’s deletion token immediately after writing the destination token. Because `API.movePad()` invokes this before `pad.remove()`, any failure after the transfer but before successful completion of `pad.remove()` can leave the source pad still present but without its deletion token.
### Issue Context
`Pad.remove()` already calls `padDeletionManager.removeDeletionToken(padID)` as part of the pad deletion flow, so removing the source token inside `transferDeletionToken()` is redundant on success but harmful on partial failure.
### Fix Focus Areas
- src/node/db/PadDeletionManager.ts[47-66]
- src/node/db/API.ts[754-776]
### Suggested change
- In `transferDeletionToken(src, dst)`, keep the `set(dstKey, stored)` but do **not** remove `srcKey` there; rely on `pad.remove()` to remove the source token when the move completes.
- If you still want eager cleanup, only remove the source token *after* `pad.remove()` succeeds (or implement rollback on failure).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
4. Token transfer not locked 🐞 Bug ☼ Reliability
Description
transferDeletionToken() is not serialized with createDeletionTokenIfAbsent(), so concurrent creator
connections can interleave token creation with a move and cause the transfer to observe “no token”
or be overwritten by a late token creation. This can still result in a moved pad with no usable
carried-over token (or a token different from what the creator was shown).
Code

src/node/db/PadDeletionManager.ts[R54-55]

+  const stored = await DB.db.get(getDeletionTokenKey(srcPadId));
+  if (stored == null) {
Relevance

●●● Strong

Team previously accepted per-key serialization/mutex to prevent deletion-token races; same
concurrency class here.

PR-#7546

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code explicitly documents and implements per-pad serialization for token creation to prevent
races, and token creation is triggered during normal creator connections. The newly added transfer
path performs token reads/writes without using that serialization mechanism, so it can interleave
with token creation on the same pad IDs.

src/node/db/PadDeletionManager.ts[13-36]
src/node/db/PadDeletionManager.ts[47-66]
src/node/handler/PadMessageHandler.ts[1322-1367]
src/node/db/API.ts[754-775]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`createDeletionTokenIfAbsent()` uses a per-pad in-memory queue (`inflightCreate`) to prevent concurrent token creations from racing and invalidating the plaintext token returned to the creator. `transferDeletionToken()` introduces another token-mutating path (read/set/remove across two pads) but does not participate in that serialization, so it can race with token creation on either the source or destination pad.
### Issue Context
- Token creation is triggered on creator connect (`PadMessageHandler`) and on API/group pad creation.
- `movePad` now runs `transferDeletionToken` after the destination pad has been created by `Pad.copy()` but before the source is removed.
### Fix Focus Areas
- src/node/db/PadDeletionManager.ts[13-63]
- src/node/handler/PadMessageHandler.ts[1322-1366]
- src/node/db/API.ts[754-776]
### Suggested change
- Introduce a shared per-pad token-operation queue/lock (not just for "create") and use it for **both** `createDeletionTokenIfAbsent()` and `transferDeletionToken()`.
- For transfer across two pads, acquire locks in a deterministic order (e.g., lexicographic by padId) to avoid deadlocks, then perform the `get`/`set` (and any cleanup) while holding the locks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/node/db/PadDeletionManager.ts Outdated
Comment thread src/node/db/API.ts
Comment on lines +769 to 774
// A move is a rename, so the pad's deletion token travels with it: the token
// the creator saved keeps working, and returning to the renamed pad does not
// hand them a second one (issue #7995). Must run before remove(), which drops
// the source pad's token record.
await padDeletionManager.transferDeletionToken(sourceID, destinationID);
await pad.remove();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

2. Movepad token race 🐞 Bug ≡ Correctness

There is a window where Pad.copy() has written the destination pad records but movePad has not yet
transferred the deletion token. If the creator opens the destination during that window,
createDeletionTokenIfAbsent(destinationID) can mint and return a plaintext token that
transferDeletionToken later overwrites, leaving the user with an invalid saved token.
Agent Prompt
### Issue description
`movePad` currently performs `pad.copy()` (which creates the destination pad records) and only afterwards transfers the deletion token. During that interval, the destination pad can be opened and the creator path can call `createDeletionTokenIfAbsent(dst)`, returning a plaintext token that becomes invalid once the transfer overwrites the stored hash.

### Issue Context
- `Pad.copy()` writes `pad:${destinationID}...` records directly.
- `CLIENT_READY` can call `createDeletionTokenIfAbsent(padId)` and return a plaintext token exactly once.
- `transferDeletionToken()` overwrites the destination token record.

### Fix Focus Areas
- Eliminate or reduce the copy→transfer window by moving the token write into the copy/move operation (e.g., add a move-specific option/path in `Pad.copy()` or implement a dedicated `Pad.move()` that sets the token as part of the DB writes).
- Alternatively, introduce a move-in-progress guard so destination token creation cannot emit a plaintext token until after transfer is completed.

### Fix Focus Areas (code refs)
- src/node/db/API.ts[766-775]
- src/node/db/Pad.ts[634-673]
- src/node/db/PadDeletionManager.ts[20-35]
- src/node/db/PadDeletionManager.ts[53-63]
- src/node/handler/PadMessageHandler.ts[1324-1366]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Review feedback on #8089:

- Don't remove the source token in `transferDeletionToken()`. `movePad`
  calls it before `pad.remove()`, which drops the record itself on
  success — so the eager removal only mattered when `remove()` failed,
  and there it stranded the creator of a source pad that survived.
- Don't overwrite a token the destination has already issued. `Pad.copy()`
  writes the destination records before the transfer runs, so a creator
  opening the new id in that window is shown a freshly minted token; that
  token has been handed out in plaintext and has to keep working.
- Run the transfer through the same per-pad queue as
  `createDeletionTokenIfAbsent()` (extracted as `withPadTokenLock`), so a
  concurrent mint can't interleave with the transfer's read-then-write.
- Drop the "source has no token" branch that cleared the destination's:
  a force-overwrite already drops the replaced pad's token via
  `Pad.remove()`, so the branch could only clobber a live token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 4, 2026 11:45
@JohnMcLear

Copy link
Copy Markdown
Member Author

Actioned the review feedback in ea49ba8 — both bots landed on the same two real issues.

1. Source token removed early (qodo ×2) — valid, fixed. transferDeletionToken() no longer removes the source record. movePad calls it before pad.remove(), which drops that record itself on success, so the eager removal only had an effect when remove() failed — and there it did the wrong thing, leaving a surviving source pad whose creator could no longer delete it by token.

2. copy → transfer window (qodo #2) — valid, fixed. Pad.copy() writes the destination records before the transfer runs, so a creator opening the new id in that window gets a freshly minted token from createDeletionTokenIfAbsent(). Overwriting it would hand them a token that silently stops working, which is worse than the bug being fixed. The transfer now refuses to clobber a destination token that already exists — in that (rare) race the creator simply keeps the token they were just shown. The transfer also runs through the same per-pad queue as createDeletionTokenIfAbsent() (extracted as withPadTokenLock) so a concurrent mint cannot interleave with its read-then-write.

Not taken: a lock across both pad ids. Only the destination's slot is read-then-written; the source is about to be deleted, and a two-id lock needs deterministic ordering for no benefit here.

3. Copilot — reword the stored == null comment. That branch is gone. It cleared the destination's token when the source had none, justified by the force-overwrite case — but Pad.copy() already removes the overwritten pad and its token via Pad.remove(), so the branch was redundant on the path it was written for and could only clobber a live token elsewhere. The movePad --force test still passes and now documents that copy() does that cleanup.

Added a regression test for the no-clobber guard (destination token issued mid-move survives; source token survives the transfer). Backend suite: 1618 passing, 0 failing. ts-check clean.

Copilot AI 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.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/node/db/PadDeletionManager.ts:30

  • withPadTokenLock() stores tracked (the promise returned by .finally()) in the inflight map but returns next. If fn() rejects, callers handle next’s rejection, but tracked is a separate rejecting promise with no handler, which can trigger an unhandled rejection. Returning tracked also removes the need for Promise<any> in the map type.
  return next;

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

moved pads don't keep, offer a new deletion token

2 participants