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 two-step shape is the v0.10.0 surface. 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 peer fan-out. The v0.10.0 split made the receiver-side opt-in explicit too.

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

Shared Notepad

Logged in as — Save syncs across all your tabs

0 characters
How it works
  • BasicAuth — each user gets an isolated session (alice's notes are separate from bob's)
  • Subscribe + Publish — Mount-side opt-in plus an explicit Publish from Save refreshes the other tabs 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 SelfTopic() identity
// (Publish routing key — lvt:user:<UserID>). 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:79-92

BasicAuthenticator answers two questions for every request:

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

Mount: subscribe + rehydrate

Mount runs on every fresh state — first page load, reconnect with stale state, or a state-restoring navigation. Three things happen: opt this connection into peer fan-out via ctx.Subscribe(ctx.SelfTopic()), bind Username to whoever just authenticated, and re-read the textarea content from the per-user map:

// Mount runs on every fresh state (page load, reconnect with stale
// state). It subscribes the self-topic so peer tabs of the same user
// receive the Refresh dispatch from Save's Publish below, 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) {
	if err := ctx.Subscribe(ctx.SelfTopic()); err != nil {
		return state, err
	}
	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-54

The Subscribe line is the receiver-side opt-in. Without it, the Publish in Save would have no subscribers in this session group and the peer tab wouldn't refresh. SelfTopic() is ACL-exempt — it always succeeds — and the explicit _ = documents that we've considered the return value (a denied developer topic would surface as a *TopicForbiddenError; the self-topic can't be denied).

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 publish

The interesting line is the last one before the return:

// Save writes the textarea content into the per-user map and Publishes a
// "Refresh" action to peer connections subscribed to SelfTopic() (other
// tabs of the same user). The framework drains the publish 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()

	// Propagate Publish's error rather than log-and-swallow: the only errors
	// it can return are programmer errors (empty SelfTopic from a
	// misconfigured Authenticator, or the per-action publish cap exceeded).
	// Surfacing them loudly is a feature. Same pattern in every recipe app
	// that Publishes to SelfTopic().
	if err := ctx.Publish(ctx.SelfTopic(), "Refresh", nil); err != nil {
		return state, err
	}
	return state, nil
}

controller.go:58-81

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

Three consequences worth knowing:

For the deeper model — when to use Subscribe/Publish peer fan-out versus session.TriggerAction for server-owned work — see Pubsub and Server push.

Refresh: a regular controller action

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

// Refresh is the action peer tabs run when Save publishes. 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 Publish-to-SelfTopic() calls
// 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:96-112

The Refresh name is convention; the framework doesn't care. What matters is the Publish(SelfTopic(), "Refresh", nil) call in Save naming this same string. Renaming the method also requires updating the publish call — and every peer connection that's running an older controller will see the published action 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 fan-out Single Fly machine, in-memory peer registry WithPubSubBroadcaster (Redis) so Publish(SelfTopic(), "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?

source: livetemplate/docs · path: content/recipes/shared-notepad/index.md