Locks, identity, and CSRF: soft page locks with takeover, X-Remote-User authorship #13

Closed
opened 2026-07-11 13:01:09 +02:00 by dominik.polakovics · 2 comments

What to build

Concurrency and identity behind the auth proxy (docs/DECISIONS.md D19, D20): session-scoped soft per-page locks — opening a page for editing claims it, a heartbeat keeps the claim alive, a second session sees "Someone is editing — take over?" and takeover always succeeds with the other side notified and its state auto-saved. If the proxy sends X-Remote-User, use it for lock display and as git commit author; otherwise use the configured default author. Add CSRF protection to all mutating endpoints (the tool itself has no login).

Acceptance criteria

  • Two browser sessions on the same page: second sees the lock notice with holder identity (or "someone") and can take over; first is notified and its unsaved state is preserved via auto-save
  • Heartbeat expiry (closed tab) releases the lock within a bounded time
  • With X-Remote-User: karin, publish commits are authored by karin; without it, the configured default author is used
  • All mutating API endpoints reject requests without a valid CSRF token
  • The tool refuses to bind to non-localhost interfaces unless explicitly overridden with a flag documented as dangerous

Blocked by

## What to build Concurrency and identity behind the auth proxy (docs/DECISIONS.md D19, D20): session-scoped soft per-page locks — opening a page for editing claims it, a heartbeat keeps the claim alive, a second session sees "Someone is editing — take over?" and takeover always succeeds with the other side notified and its state auto-saved. If the proxy sends `X-Remote-User`, use it for lock display and as git commit author; otherwise use the configured default author. Add CSRF protection to all mutating endpoints (the tool itself has no login). ## Acceptance criteria - [ ] Two browser sessions on the same page: second sees the lock notice with holder identity (or "someone") and can take over; first is notified and its unsaved state is preserved via auto-save - [ ] Heartbeat expiry (closed tab) releases the lock within a bounded time - [ ] With `X-Remote-User: karin`, publish commits are authored by karin; without it, the configured default author is used - [ ] All mutating API endpoints reject requests without a valid CSRF token - [ ] The tool refuses to bind to non-localhost interfaces unless explicitly overridden with a flag documented as dangerous ## Blocked by - #10
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Make the tool safe for more than one person behind an auth proxy: session-scoped soft per-page locks with heartbeat and always-succeeding takeover (loser notified, loser's state auto-saved), X-Remote-User as lock-holder identity and git commit author (configured default author otherwise), CSRF protection on every mutating endpoint, and an explicitly-dangerous override flag for the existing localhost-only bind refusal.

Dependency note: this issue is hard-blocked by #10 (git save and per-page publish), which provides the save path the takeover auto-save flows through and the publish commits whose authorship this issue personalizes. The scheduler handles ordering; do not start until #10 is merged. The issue body's docs/DECISIONS.md references (D19, D20) point at a file that is not committed — this brief is self-contained.

Current behavior:
The tool has no concept of sessions, users, or concurrency: two browsers editing the same page silently overwrite each other — last save wins, the loser's work vanishes without either side ever knowing. Mutating endpoints (after #10: save, publish, discard) accept any request that reaches them with no CSRF defense, so any website open in an editor's browser could forge writes against the localhost API. Publish commits are authored by a single configured/default author regardless of who actually edited (per #10). One piece already exists: the harness CLI validates its listen address and refuses to bind any non-loopback host outright — there is currently no override at all, and the SPA is only reachable directly on localhost or through whatever reverse proxy the operator puts in front.

Desired behavior:

Deployment contract. The tool itself has no login. In multi-user setups it sits behind an authenticating reverse proxy that forwards requests to the localhost-bound harness and sets an X-Remote-User header identifying the authenticated user. The tool trusts that header precisely because of the loopback-only bind: nothing but the proxy (or a local user) can reach it.

Soft per-page locks. Locks are session-scoped: each browser session (tab/client instance, independent of user identity — the same person in two tabs is two sessions) is distinguishable to the server. Opening a page for editing claims a soft lock on that page for the session. A periodic heartbeat keeps the claim alive; when heartbeats stop (closed tab, crashed browser, lost network), the lock expires and is released within a bounded, configurable-or-constant time window. Lock state is in-memory session state — it does not need to survive a harness restart.

Takeover. When a second session opens a page that is locked, it is not blocked: it sees a notice — "Someone is editing — take over?" — showing the holder's identity (their X-Remote-User name, or a generic "someone" when identity is unknown). Choosing takeover always succeeds; a soft lock can never permanently deny access. On takeover: the previous holder is notified in its UI that it lost the page (and stops being able to save it as the holder), and its unsaved editing state is preserved by auto-save — the loser's most recent edit state is persisted through the save path from #10 (working tree + wip safety commit) before the winner's edits can overwrite anything, so no work is silently destroyed. If the losing session is unreachable (tab already closed), the most recently synced state is what gets preserved; the design must ensure that a reachable loser's in-browser dirty state is flushed, whether by piggybacking state on heartbeats, by a save-on-takeover-notification round trip, or another mechanism of the implementer's choice.

Identity and authorship. When requests carry X-Remote-User, that name is used wherever a human is shown or recorded: the lock-holder display, takeover notifications, and — from this issue on — as the git commit author of publish commits made on that user's behalf (author name from the header; the email may be synthesized from a configured pattern or default). Without the header, the configured default author from #10 is used, and lock displays fall back to "someone". The header is read per-request; it is never persisted as tool state beyond the session.

CSRF protection. Every mutating endpoint — save, publish, discard, lock claim/heartbeat/release/takeover, and any future state-changing route — requires a valid CSRF token and rejects requests without one (an explicit 4xx with a JSON error, consistent with the existing API error shape). The SPA obtains the token at load (bootstrap response, cookie+header double-submit, or equivalent) and sends it automatically on all mutating calls; the editor never sees or manages it. Read-only GET endpoints are unaffected. The protection must hold for the threat that matters here: a malicious external website triggering cross-site requests against the localhost-bound API from the editor's own browser.

Bind safety valve. The existing hard refusal to bind non-loopback hosts stays the default. New: an explicit override flag whose name and help text make the danger unmistakable (in the spirit of --dangerously-allow-non-loopback), letting an operator who understands the consequences bind other interfaces. Without the flag, non-loopback binds keep failing exactly as today; with it, the bind proceeds and a prominent warning is printed at startup.

Key interfaces:

  • A lock-manager component (claim / heartbeat / release / takeover / query, TTL-based expiry) independent of HTTP details, unit-testable with a controllable clock.
  • Session identification contract between SPA and harness (how a browser session presents a stable session ID) — implementer's choice of mechanism, but takeover and heartbeat semantics depend on it being stable per tab/client and distinct across tabs.
  • Lock state surface for the SPA: current holder identity and lock status for the open page, plus a way for the losing session to learn promptly that it was taken over (the existing status-polling pattern in the SPA is an acceptable transport; something push-based is fine too).
  • The save and publish contracts from #10 gain an author/identity dimension: publish commit authorship derives from X-Remote-User when present, else the configured default author.
  • CSRF token issuance and verification applied uniformly to all mutating routes — a middleware-style choke point, not per-handler copies.
  • The harness CLI's address validation gains the dangerous override flag; the loopback check itself is unchanged.

Acceptance criteria:

  • Two browser sessions open the same page: the second sees the lock notice with the holder's identity (or "someone" without X-Remote-User) and can take over; the first is notified it lost the lock and its unsaved state is preserved via auto-save (recoverable from the working tree / wip ref)
  • Takeover always succeeds — there is no state in which a session is permanently denied editing a page
  • Stopping heartbeats (e.g. closing the tab) releases the lock within a bounded time, after which a new session can claim the page without a takeover prompt
  • The same user in two tabs is treated as two sessions and gets the same lock/takeover behavior
  • With X-Remote-User: karin on the requests, publish commits are authored by karin; without the header, the configured default author is used
  • Lock displays and takeover notifications show the X-Remote-User name when present and "someone" otherwise
  • Every mutating API endpoint rejects requests lacking a valid CSRF token with a clear JSON error, and the SPA transparently supplies the token so normal editing is unaffected; read-only GETs need no token
  • Lock lifecycle endpoints are themselves CSRF-protected
  • The harness still refuses non-loopback binds by default; a new explicitly-dangerous, documented override flag allows them and prints a prominent startup warning
  • Lock manager semantics (claim, heartbeat expiry, takeover, identity fallback) and CSRF enforcement are covered by tests

Out of scope:

  • Authentication itself — login, user management, verifying X-Remote-User — that is the auth proxy's job by design
  • Publish/CI feedback (#11) and the git mechanics of save/publish/discard (#10) beyond threading authorship through them
  • Element-level or field-level locking — the lock unit is the page
  • Hard locks or admin-only takeover policies — locks are deliberately soft
  • Lock persistence across harness restarts or multi-instance lock coordination
  • TLS termination, rate limiting, or other reverse-proxy concerns
> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Make the tool safe for more than one person behind an auth proxy: session-scoped soft per-page locks with heartbeat and always-succeeding takeover (loser notified, loser's state auto-saved), `X-Remote-User` as lock-holder identity and git commit author (configured default author otherwise), CSRF protection on every mutating endpoint, and an explicitly-dangerous override flag for the existing localhost-only bind refusal. **Dependency note:** this issue is **hard-blocked by #10** (git save and per-page publish), which provides the save path the takeover auto-save flows through and the publish commits whose authorship this issue personalizes. The scheduler handles ordering; do not start until #10 is merged. The issue body's `docs/DECISIONS.md` references (D19, D20) point at a file that is not committed — this brief is self-contained. **Current behavior:** The tool has no concept of sessions, users, or concurrency: two browsers editing the same page silently overwrite each other — last save wins, the loser's work vanishes without either side ever knowing. Mutating endpoints (after #10: save, publish, discard) accept any request that reaches them with no CSRF defense, so any website open in an editor's browser could forge writes against the localhost API. Publish commits are authored by a single configured/default author regardless of who actually edited (per #10). One piece already exists: the harness CLI validates its listen address and refuses to bind any non-loopback host outright — there is currently no override at all, and the SPA is only reachable directly on localhost or through whatever reverse proxy the operator puts in front. **Desired behavior:** *Deployment contract.* The tool itself has no login. In multi-user setups it sits behind an authenticating reverse proxy that forwards requests to the localhost-bound harness and sets an `X-Remote-User` header identifying the authenticated user. The tool trusts that header precisely because of the loopback-only bind: nothing but the proxy (or a local user) can reach it. *Soft per-page locks.* Locks are session-scoped: each browser session (tab/client instance, independent of user identity — the same person in two tabs is two sessions) is distinguishable to the server. Opening a page for editing claims a soft lock on that page for the session. A periodic heartbeat keeps the claim alive; when heartbeats stop (closed tab, crashed browser, lost network), the lock expires and is released within a bounded, configurable-or-constant time window. Lock state is in-memory session state — it does not need to survive a harness restart. *Takeover.* When a second session opens a page that is locked, it is not blocked: it sees a notice — "Someone is editing — take over?" — showing the holder's identity (their `X-Remote-User` name, or a generic "someone" when identity is unknown). Choosing takeover **always succeeds**; a soft lock can never permanently deny access. On takeover: the previous holder is notified in its UI that it lost the page (and stops being able to save it as the holder), and its unsaved editing state is preserved by auto-save — the loser's most recent edit state is persisted through the save path from #10 (working tree + wip safety commit) before the winner's edits can overwrite anything, so no work is silently destroyed. If the losing session is unreachable (tab already closed), the most recently synced state is what gets preserved; the design must ensure that a reachable loser's in-browser dirty state is flushed, whether by piggybacking state on heartbeats, by a save-on-takeover-notification round trip, or another mechanism of the implementer's choice. *Identity and authorship.* When requests carry `X-Remote-User`, that name is used wherever a human is shown or recorded: the lock-holder display, takeover notifications, and — from this issue on — as the git commit author of publish commits made on that user's behalf (author name from the header; the email may be synthesized from a configured pattern or default). Without the header, the configured default author from #10 is used, and lock displays fall back to "someone". The header is read per-request; it is never persisted as tool state beyond the session. *CSRF protection.* Every mutating endpoint — save, publish, discard, lock claim/heartbeat/release/takeover, and any future state-changing route — requires a valid CSRF token and rejects requests without one (an explicit 4xx with a JSON error, consistent with the existing API error shape). The SPA obtains the token at load (bootstrap response, cookie+header double-submit, or equivalent) and sends it automatically on all mutating calls; the editor never sees or manages it. Read-only GET endpoints are unaffected. The protection must hold for the threat that matters here: a malicious external website triggering cross-site requests against the localhost-bound API from the editor's own browser. *Bind safety valve.* The existing hard refusal to bind non-loopback hosts stays the default. New: an explicit override flag whose name and help text make the danger unmistakable (in the spirit of `--dangerously-allow-non-loopback`), letting an operator who understands the consequences bind other interfaces. Without the flag, non-loopback binds keep failing exactly as today; with it, the bind proceeds and a prominent warning is printed at startup. **Key interfaces:** - A lock-manager component (claim / heartbeat / release / takeover / query, TTL-based expiry) independent of HTTP details, unit-testable with a controllable clock. - Session identification contract between SPA and harness (how a browser session presents a stable session ID) — implementer's choice of mechanism, but takeover and heartbeat semantics depend on it being stable per tab/client and distinct across tabs. - Lock state surface for the SPA: current holder identity and lock status for the open page, plus a way for the losing session to learn promptly that it was taken over (the existing status-polling pattern in the SPA is an acceptable transport; something push-based is fine too). - The save and publish contracts from #10 gain an author/identity dimension: publish commit authorship derives from `X-Remote-User` when present, else the configured default author. - CSRF token issuance and verification applied uniformly to all mutating routes — a middleware-style choke point, not per-handler copies. - The harness CLI's address validation gains the dangerous override flag; the loopback check itself is unchanged. **Acceptance criteria:** - [ ] Two browser sessions open the same page: the second sees the lock notice with the holder's identity (or "someone" without `X-Remote-User`) and can take over; the first is notified it lost the lock and its unsaved state is preserved via auto-save (recoverable from the working tree / wip ref) - [ ] Takeover always succeeds — there is no state in which a session is permanently denied editing a page - [ ] Stopping heartbeats (e.g. closing the tab) releases the lock within a bounded time, after which a new session can claim the page without a takeover prompt - [ ] The same user in two tabs is treated as two sessions and gets the same lock/takeover behavior - [ ] With `X-Remote-User: karin` on the requests, publish commits are authored by karin; without the header, the configured default author is used - [ ] Lock displays and takeover notifications show the `X-Remote-User` name when present and "someone" otherwise - [ ] Every mutating API endpoint rejects requests lacking a valid CSRF token with a clear JSON error, and the SPA transparently supplies the token so normal editing is unaffected; read-only GETs need no token - [ ] Lock lifecycle endpoints are themselves CSRF-protected - [ ] The harness still refuses non-loopback binds by default; a new explicitly-dangerous, documented override flag allows them and prints a prominent startup warning - [ ] Lock manager semantics (claim, heartbeat expiry, takeover, identity fallback) and CSRF enforcement are covered by tests **Out of scope:** - Authentication itself — login, user management, verifying `X-Remote-User` — that is the auth proxy's job by design - Publish/CI feedback (#11) and the git mechanics of save/publish/discard (#10) beyond threading authorship through them - Element-level or field-level locking — the lock unit is the page - Hard locks or admin-only takeover policies — locks are deliberately soft - Lock persistence across harness restarts or multi-instance lock coordination - TLS termination, rate limiting, or other reverse-proxy concerns
Author
Owner

Closing as superseded: the maintainer has decided on a clean-slate reset of the project (issue #29, PR #30). The v1 implementation this issue builds on is being removed and the plan will be re-drawn from scratch; this spec is retired rather than actioned.

Closing as superseded: the maintainer has decided on a clean-slate reset of the project (issue #29, PR #30). The v1 implementation this issue builds on is being removed and the plan will be re-drawn from scratch; this spec is retired rather than actioned.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#13
No description provided.