Shared notepad: BasicAuth + per-user state + explicit peer refresh

The smallest authenticated multi-user app: a textarea, per-user persistence, and multi-tab sync. The whole thing fits in a controller with four action methods, a handler that wires BasicAuthenticator as a default option, and ten lines of template — the rest is framework machinery you don't write.

What makes this recipe worth a page is the seam between three independently-useful primitives:

That last one is the v0.9.0 shape. Earlier versions of LiveTemplate auto-dispatched a controller method named Sync on every peer after every action; livetemplate#406 removed that auto-dispatch in favour of explicit broadcasts so authors control when peers refresh, not the framework.

Try it right here. Type some text and click Save:

Shared Notepad

Logged in as — Save syncs across all your tabs

3 characters — saved at 20:55:21
How it works
  • BasicAuth — each user gets an isolated session (alice's notes are separate from bob's)
  • BroadcastAction — Save in one tab refreshes the others for the same user
  • Notes persist across page refreshes within the same server session
Demo credentials: any username, password demo

To see the peer-refresh, open this page in a second browser tab — the embed there shares the same cookie-bound identity — and click Save in one. The other tab's content updates without you doing anything on it. For the cross-identity isolation story, open the page in a private window: different cookie, different ctx.UserID(), different session group, completely separate state.

A note on the embed's authenticator. The recipe text below shows NewDemoBasicAuth — that's what examples/shared-notepad/ and the e2e suite use, and it's the production-shaped wiring where the username from the Authorization header becomes ctx.UserID(). The embed above uses AnonymousAuthenticator instead — a cookie-bound session ID with no credential prompt — because tinkerdown's embed-lvt does a server-side prefetch to extract the LiveTemplate wrapper, and that prefetch can't forward Authorization headers. The controller code is identical either way; only the source of ctx.UserID() changes.

The state struct

State is pure data, cloned per session. The three lvt:"persist" tags keep the textarea content and metadata alive across reconnects via the framework's client-side state checksum:

// NotepadController holds per-user notepad state. The map is keyed by
// ctx.UserID() (the username from BasicAuth). A real app would back
// this with a database; the map is fine for a recipe.
type NotepadController struct {
	mu    sync.RWMutex
	notes map[string]NotepadState // userID -> latest state
}

// NotepadState is pure data, cloned per session. lvt:"persist" tags
// keep the textarea content and metadata alive across page refreshes
// (the framework round-trips them through a client-side state
// checksum). Username is derived from ctx.UserID() on Mount and isn't
// persisted — it would be wrong to trust a client-supplied identity.
type NotepadState struct {
	Username  string `json:"username"`
	Content   string `json:"content" lvt:"persist"`
	SavedAt   string `json:"saved_at" lvt:"persist"`
	CharCount int    `json:"char_count" lvt:"persist"`
}

controller.go:12-31

Username is intentionally not persisted — it's re-derived from ctx.UserID() on every Mount, and trusting a client-supplied username would be an authorization bug waiting to happen.

The handler: pick the authenticator at mount time

The handler exposes two authenticator flavours and lets the caller pick. Production-shaped is BasicAuth:

// NewDemoBasicAuth returns the authenticator the recipe text teaches:
// BasicAuth with password "demo", any username. The username becomes
// both ctx.UserID() (per-user state map key) and the session-group ID
// (BroadcastAction routing). This is the production-shaped wiring and
// what examples/shared-notepad + the e2e suite use.
//
// The docs-site mount (cmd/site) uses AnonymousAuthenticator instead
// of this — see the Handler doc comment for the reason.
func NewDemoBasicAuth() livetemplate.Authenticator {
	return livetemplate.NewBasicAuthenticator(func(_, password string) (bool, error) {
		return password == "demo", nil
	})
}

handler.go:78-91

BasicAuthenticator answers two questions for every request:

ctx.UserID() in any action handler returns whatever the authenticator decided — username here.

Mount: rehydrate from the per-user map

Mount runs on every fresh state — first page load, reconnect with stale state, or a state-restoring navigation. It binds Username to whoever just authenticated, then re-reads the textarea content from the per-user map:

// Mount runs on every fresh state (page load, reconnect with stale
// state). It binds Username to the authenticated user and rehydrates
// the textarea from the controller's per-user map.
func (c *NotepadController) Mount(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
	state.Username = ctx.UserID()
	c.mu.RLock()
	if saved, ok := c.notes[ctx.UserID()]; ok {
		state.Content = saved.Content
		state.CharCount = saved.CharCount
		state.SavedAt = saved.SavedAt
	}
	c.mu.RUnlock()
	return state, nil
}

controller.go:35-49

The c.mu.RLock is the only concurrency primitive in the recipe. Save takes the write lock; Mount, Refresh, and the implicit page-load reads take the read lock. For a production app this would be a database transaction, not a map[string]NotepadState — but the controller-shape is the same.

Save: write through, then broadcast

The interesting line is the last one before the return:

// Save writes the textarea content into the per-user map and broadcasts
// a "Refresh" action to peer connections in the same session group
// (other tabs of the same user). The framework drains the broadcast
// queue after this action's response is sent.
func (c *NotepadController) Save(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
	state.Content = ctx.GetString("content")
	state.CharCount = utf8.RuneCountInString(state.Content)
	state.SavedAt = time.Now().Format("15:04:05")

	c.mu.Lock()
	c.notes[ctx.UserID()] = state
	c.mu.Unlock()

	ctx.BroadcastAction("Refresh", nil)
	return state, nil
}

controller.go:53-69

ctx.BroadcastAction("Refresh", nil) doesn't run Refresh immediately on other connections — it enqueues the action for the framework's broadcast pipeline. After the current request's response is sent back to the originating tab, the framework drains the queue: for every peer connection in the same session group (other tabs of the same authenticated user), it dispatches Refresh against that connection's local state.

Two consequences worth knowing:

For the deeper model — when to broadcast versus when to use session.TriggerAction for server-owned work — see Broadcast & Server Push.

Refresh: a regular controller action

Refresh is the action peer tabs run when Save broadcasts. It's just a regular controller method — not a framework-reserved name:

// Refresh is the action peer tabs run when Save broadcasts. It re-reads
// the latest state from the per-user map. Note this is a regular
// controller action, not a framework-reserved name — pre-v0.9.0 the
// framework auto-dispatched a Sync() method; that was removed in
// livetemplate#406 in favour of explicit BroadcastAction("Refresh", nil)
// for clearer control over when peers actually refresh.
func (c *NotepadController) Refresh(state NotepadState, ctx *livetemplate.Context) (NotepadState, error) {
	c.mu.RLock()
	if saved, ok := c.notes[ctx.UserID()]; ok {
		state.Content = saved.Content
		state.CharCount = saved.CharCount
		state.SavedAt = saved.SavedAt
	}
	c.mu.RUnlock()
	return state, nil
}

controller.go:84-100

The Refresh name is convention; the framework doesn't care. What matters is the BroadcastAction("Refresh", nil) call in Save naming this same string. Renaming the method also requires updating the broadcast call — and every peer connection that's running an older controller will see the broadcast and fail to route it.

The form: persistence across re-render

The textarea form uses lvt-form:preserve, which tells the framework to retain the form's input values across a re-render:

<article>
    <header>
        <hgroup>
            <h1>Shared Notepad</h1>
            <p>Logged in as <strong>{{.Username}}</strong> — Save syncs across all your tabs</p>
        </hgroup>
    </header>

    <form method="POST" lvt-form:preserve>
        <label for="content">Your notes
            <textarea id="content" name="content" rows="12" placeholder="Start typing...">{{.Content}}</textarea>
        </label>
        <footer>
            <div class="grid">
                <small id="charcount">{{.CharCount}} characters{{if .SavedAt}} — saved at {{.SavedAt}}{{end}}</small>
                <button type="submit" name="save">Save</button>
            </div>
        </footer>
    </form>
</article>

notepad.tmpl:20-39

Without lvt-form:preserve, a Save would re-render the article with the new SavedAt timestamp and the textarea would briefly flash empty before the framework re-binds {{.Content}}. Preserve keeps the user's typing intact during the round-trip.

For the deeper pattern (including how preserve composes with Change() for live preview), see Patterns › Preserve inputs.

Where this recipe stops

Three production extensions that don't change the recipe's shape:

Concern This recipe Production shape
Persistence map[string]NotepadState in process memory File-backed SQLite or Postgres; per-user rows; Save becomes an UPDATE
Auth BasicAuth with hardcoded password check BasicAuthenticator with bcrypt password compare, or a custom Authenticator that validates session tokens
Multi-instance broadcast Single Fly machine, in-memory peer registry WithPubSubBroadcaster (Redis) so BroadcastAction("Refresh", ...) reaches peer instances
Audit No event log Append-only audit table; Save writes the event before returning

None of those changes the four action methods on NotepadController. The shape carries over.

What next?