Keep showing a guest link's URL so it can be copied again

The URL was shown once, in a box under the create button, and then gone: only
a hash of the token was stored, so the app genuinely could not produce it a
second time. Lose the message you sent the sitter and the only way back was to
mint a new link — which strands whoever is already holding the old one.

Settings now lists every live link with its URL and a Copy button, so re-sending
one is just copying it again.

That means keeping the token rather than only its hash, and it is worth being
plain about the trade. It is not the trade you would make for a password, which
the user has probably reused, or a session token, which grants everything
indefinitely. A guest link grants a strict subset of what the same database
already holds in plaintext, expires on a date the owner picked, and can be
revoked in one tap — so an attacker who can read puppy.db gains very little by
also being able to open it as a guest. The lookup column stays a hash and
remains the key redeem matches against; the secret sits in a new column beside
it, which also keeps the migration additive.

Links created before this have an empty secret. They keep working and stay
revocable — the migration touches nothing but the new column — and the list
says why their URL is missing rather than rendering a broken one.

The two tests that asserted the old contract now assert the new one: a listing
hands back a secret that really opens the link, and the lookup column is still
a hash. Added one for the legacy row, since "still works, just cannot be shown"
is the part a future change is most likely to break quietly.
This commit is contained in:
Alexander Heldt
2026-09-07 19:25:20 +00:00
parent babed44c25
commit 66f89b35a9
8 changed files with 183 additions and 76 deletions
+8 -4
View File
@@ -169,10 +169,14 @@ Guest access** mints a link that does exactly the first thing.
and every request re-checks that the link is still live — so revoking kicks and every request re-checks that the link is still live — so revoking kicks
whoever is already using it out on their very next request, not whenever their whoever is already using it out on their very next request, not whenever their
session happens to lapse. Revoking also deletes those session rows outright. session happens to lapse. Revoking also deletes those session rows outright.
- **The URL is shown once.** Only a hash of the token is stored, exactly as with - **The URL stays available.** Settings lists each live link by label, expiry
session tokens, so a leaked database yields no working links — and the app and when it was last used, with the URL and a *Copy* button, so a link can be
cannot show you the URL again later. Settings lists each live link by label, re-sent without minting a new one and stranding whoever holds the old. That
expiry and when it was last used. means the token is stored, not just its hash — a deliberate trade, and not the
one you would make for a password or a session token: a guest link grants a
subset of what the same database already holds in plaintext, so whoever can
read `puppy.db` gains little from it, and it expires and can be revoked
besides. The lookup column stays a hash; the secret sits beside it.
- **Events say who logged them.** An event created through a link carries that - **Events say who logged them.** An event created through a link carries that
link's label (badged in the History log) and its id (which is what authorises link's label (badged in the History log) and its id (which is what authorises
changes). The server stamps both from the session on insert and never reads changes). The server stamps both from the session on insert and never reads
+18 -10
View File
@@ -554,11 +554,17 @@ func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
// link it came from — so every data path downstream (sync, photos, config) keeps // link it came from — so every data path downstream (sync, photos, config) keeps
// working untouched, and only the capability checks differ by role. // working untouched, and only the capability checks differ by role.
// //
// The raw token is shown exactly once, at creation. Only its hash is stored, // The token is kept, not just its hash, so Settings can show the URL again
// the same way session tokens are, so a leaked database yields no usable links. // whenever the owner wants to re-send it. That is a deliberate trade the way it
// would not be for a password or a session token: a guest link grants a subset
// of what the same database already holds in plaintext, so whoever can read
// puppy.db gains very little from it, and the link expires and can be revoked
// besides. The `token` column stays a hash and remains the lookup key; `secret`
// is the copy handed back to the owner.
// ShareLink is the public shape of a guest link. Token is set only on the // ShareLink is the public shape of a guest link. Token carries the URL's secret
// response to the call that created it, and never stored in the clear. // and comes back on every listing, so the owner can copy the link again rather
// than having one chance at it when it is created.
type ShareLink struct { type ShareLink struct {
ID string `json:"id"` ID string `json:"id"`
Label string `json:"label"` Label string `json:"label"`
@@ -585,20 +591,22 @@ func (a *Auth) createShare(userID, label string, expires int64) (ShareLink, erro
Token: token, Token: token,
} }
_, err := a.db.Exec( _, err := a.db.Exec(
`INSERT INTO share_links (id, user_id, token, label, created, expires) VALUES (?, ?, ?, ?, ?, ?)`, `INSERT INTO share_links (id, user_id, token, secret, label, created, expires) VALUES (?, ?, ?, ?, ?, ?, ?)`,
link.ID, userID, hashToken(token), link.Label, link.Created, link.Expires) link.ID, userID, hashToken(token), token, link.Label, link.Created, link.Expires)
if err != nil { if err != nil {
return ShareLink{}, err return ShareLink{}, err
} }
return link, nil return link, nil
} }
// listShares returns the account's links that are still usable. Revoked and // listShares returns the account's links that are still usable, each with its
// URL secret so Settings can offer the link for copying at any time. Revoked and
// lapsed ones are of no interest to the UI — the point of the list is "who can // lapsed ones are of no interest to the UI — the point of the list is "who can
// get in right now". // get in right now". A link created before secrets were kept comes back with an
// empty Token; the UI says so rather than showing a broken URL.
func (a *Auth) listShares(userID string) ([]ShareLink, error) { func (a *Auth) listShares(userID string) ([]ShareLink, error) {
rows, err := a.db.Query(` rows, err := a.db.Query(`
SELECT id, label, created, expires, last_used SELECT id, label, created, expires, last_used, secret
FROM share_links FROM share_links
WHERE user_id = ? AND revoked = 0 AND expires > ? WHERE user_id = ? AND revoked = 0 AND expires > ?
ORDER BY created DESC`, userID, time.Now().UnixMilli()) ORDER BY created DESC`, userID, time.Now().UnixMilli())
@@ -609,7 +617,7 @@ func (a *Auth) listShares(userID string) ([]ShareLink, error) {
out := make([]ShareLink, 0) out := make([]ShareLink, 0)
for rows.Next() { for rows.Next() {
var l ShareLink var l ShareLink
if err := rows.Scan(&l.ID, &l.Label, &l.Created, &l.Expires, &l.LastUsed); err != nil { if err := rows.Scan(&l.ID, &l.Label, &l.Created, &l.Expires, &l.LastUsed, &l.Token); err != nil {
return nil, err return nil, err
} }
out = append(out, l) out = append(out, l)
+56 -8
View File
@@ -230,13 +230,36 @@ func TestListSharesHidesRevokedAndExpired(t *testing.T) {
if len(links) != 1 || links[0].ID != live.ID { if len(links) != 1 || links[0].ID != live.ID {
t.Fatalf("listed %d link(s), want only the live one", len(links)) t.Fatalf("listed %d link(s), want only the live one", len(links))
} }
if links[0].Token != "" { }
t.Error("listing leaked a raw token")
// Settings shows every live link's URL so it can be re-sent, which means a
// listing has to carry the same secret the link was created with — and that
// secret has to still work.
func TestListSharesReturnsAWorkingURL(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
links, err := a.listShares(ownerID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(links) != 1 {
t.Fatalf("listed %d link(s), want 1", len(links))
}
if links[0].Token != link.Token {
t.Fatalf("listing returned %q, want the issued secret %q", links[0].Token, link.Token)
}
if _, _, ok := a.redeemShare(links[0].Token); !ok {
t.Error("the secret handed back by the listing does not open the link")
} }
} }
// The raw token is only ever handed back once, at creation. // The lookup column stays a hash even though the secret is kept beside it, so
func TestOnlyTheTokenHashIsStored(t *testing.T) { // the token in a URL is never what is matched against directly.
func TestLookupIsStillByHash(t *testing.T) {
a := testAuth(t) a := testAuth(t)
ownerID := testOwner(t, a) ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24)) link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
@@ -247,11 +270,36 @@ func TestOnlyTheTokenHashIsStored(t *testing.T) {
if err := a.db.QueryRow(`SELECT token FROM share_links WHERE id = ?`, link.ID).Scan(&stored); err != nil { if err := a.db.QueryRow(`SELECT token FROM share_links WHERE id = ?`, link.ID).Scan(&stored); err != nil {
t.Fatalf("read back: %v", err) t.Fatalf("read back: %v", err)
} }
if stored == link.Token {
t.Error("the raw token is stored in the clear")
}
if stored != hashToken(link.Token) { if stored != hashToken(link.Token) {
t.Error("stored token is not the hash of the issued one") t.Error("the lookup column is not the hash of the issued token")
}
}
// A link made before secrets were kept has no URL to show. It must still work
// and still be revocable — only the copy-again affordance is unavailable.
func TestLinkWithoutAStoredSecretStillWorks(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Legacy", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
// What the migration leaves behind for a pre-existing row.
if _, err := a.db.Exec(`UPDATE share_links SET secret = '' WHERE id = ?`, link.ID); err != nil {
t.Fatalf("clear secret: %v", err)
}
links, err := a.listShares(ownerID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(links) != 1 || links[0].Token != "" {
t.Fatalf("want the link listed with an empty token, got %+v", links)
}
if _, _, ok := a.redeemShare(link.Token); !ok {
t.Error("a link whose secret was never stored stopped working")
}
if err := a.revokeShare(ownerID, link.ID); err != nil {
t.Errorf("could not revoke it: %v", err)
} }
} }
+13
View File
@@ -377,6 +377,7 @@ func openDB(path string) (*sql.DB, error) {
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE, token TEXT NOT NULL UNIQUE,
secret TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '', label TEXT NOT NULL DEFAULT '',
created INTEGER NOT NULL, created INTEGER NOT NULL,
expires INTEGER NOT NULL, expires INTEGER NOT NULL,
@@ -476,6 +477,18 @@ func migrateSchema(db *sql.DB) error {
return err return err
} }
} }
// Guest links are re-showable in Settings, which means keeping the token
// itself and not only its hash (see Auth.createShare). Links made before
// this have an empty secret and simply cannot be shown again.
hasSecret, err := columnExists(db, "share_links", "secret")
if err != nil {
return err
}
if !hasSecret {
if _, err := db.Exec(`ALTER TABLE share_links ADD COLUMN secret TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasShareID, err := columnExists(db, "sessions", "share_id") hasShareID, err := columnExists(db, "sessions", "share_id")
if err != nil { if err != nil {
return err return err
+56 -27
View File
@@ -3402,9 +3402,6 @@
const guestLabelIn = document.getElementById("guest-label"); const guestLabelIn = document.getElementById("guest-label");
const guestCreate = document.getElementById("guest-create"); const guestCreate = document.getElementById("guest-create");
const guestError = document.getElementById("guest-error"); const guestError = document.getElementById("guest-error");
const guestNew = document.getElementById("guest-new");
const guestNewURL = document.getElementById("guest-new-url");
const guestCopy = document.getElementById("guest-copy");
const guestListEl = document.getElementById("guest-list"); const guestListEl = document.getElementById("guest-list");
const guestEmpty = document.getElementById("guest-empty"); const guestEmpty = document.getElementById("guest-empty");
const guestExpires = document.getElementById("guest-expires"); const guestExpires = document.getElementById("guest-expires");
@@ -3463,10 +3460,12 @@
? `last used ${formatRelative(l.lastUsed)}` ? `last used ${formatRelative(l.lastUsed)}`
: "never used"; : "never used";
li.innerHTML = ` li.innerHTML = `
<span class="guest-item-main"> <div class="guest-item-row">
<span class="guest-item-label"></span> <span class="guest-item-main">
<span class="guest-item-sub"></span> <span class="guest-item-label"></span>
</span> <span class="guest-item-sub"></span>
</span>
</div>
`; `;
li.querySelector(".guest-item-label").textContent = l.label; li.querySelector(".guest-item-label").textContent = l.label;
li.querySelector(".guest-item-sub").textContent = `expires ${formatWhen(l.expires)} · ${used}`; li.querySelector(".guest-item-sub").textContent = `expires ${formatWhen(l.expires)} · ${used}`;
@@ -3475,13 +3474,42 @@
revoke.className = "linklike guest-revoke"; revoke.className = "linklike guest-revoke";
revoke.textContent = "Revoke"; revoke.textContent = "Revoke";
revoke.addEventListener("click", () => revokeGuestLink(l.id, l.label)); revoke.addEventListener("click", () => revokeGuestLink(l.id, l.label));
li.appendChild(revoke); li.querySelector(".guest-item-row").appendChild(revoke);
// The URL stays available for as long as the link does, so it can be
// re-sent without minting a new one and stranding whoever holds the old.
if (l.token) {
const url = guestURL(l.token);
const box = document.createElement("div");
box.className = "guest-item-url";
const code = document.createElement("code");
code.className = "guest-url";
code.textContent = url;
code.title = url; // the line is truncated; the tooltip isn't
// Tapping the URL selects the whole of it, so it can be copied by hand
// where the clipboard API isn't available (it needs a secure context).
code.addEventListener("click", () => selectContents(code));
const copy = document.createElement("button");
copy.type = "button";
copy.className = "ghost guest-copy";
copy.textContent = "Copy";
copy.addEventListener("click", () => copyGuestURL(copy, code));
box.appendChild(code);
box.appendChild(copy);
li.appendChild(box);
} else {
// Links minted before the URL was kept: only their hash was stored, so
// there is nothing to show. Revoking and making a new one is the fix.
const note = document.createElement("p");
note.className = "settings-hint guest-item-url";
note.textContent = "Made before links could be re-shown — revoke it and create a new one to get a copyable URL.";
li.appendChild(note);
}
guestListEl.appendChild(li); guestListEl.appendChild(li);
} }
} }
async function refreshGuestLinks() { async function refreshGuestLinks() {
guestNew.hidden = true;
guestError.hidden = true; guestError.hidden = true;
try { try {
const res = await fetch("api/shares"); const res = await fetch("api/shares");
@@ -3515,15 +3543,12 @@
body: JSON.stringify({ label: guestLabelIn.value.trim(), expires }), body: JSON.stringify({ label: guestLabelIn.value.trim(), expires }),
}); });
if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`); if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`);
const link = await res.json(); await res.json();
guestLabelIn.value = ""; guestLabelIn.value = "";
resetGuestExpiry(); resetGuestExpiry();
// The refresh clears any previously shown URL, so reveal this one after // Nothing special to do with the new link: the list shows every link's
// it, not before. // URL, so it simply appears there like the rest.
await refreshGuestLinks(); await refreshGuestLinks();
guestNewURL.textContent = guestURL(link.token);
guestCopy.textContent = "Copy link";
guestNew.hidden = false;
} catch (err) { } catch (err) {
guestError.textContent = err.message || "Couldn't create a link"; guestError.textContent = err.message || "Couldn't create a link";
guestError.hidden = false; guestError.hidden = false;
@@ -3532,21 +3557,25 @@
} }
}); });
guestCopy.addEventListener("click", async () => { function selectContents(el) {
const range = document.createRange();
range.selectNodeContents(el);
const sel = getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
async function copyGuestURL(btn, code) {
try { try {
await navigator.clipboard.writeText(guestNewURL.textContent); await navigator.clipboard.writeText(code.textContent);
guestCopy.textContent = "Copied"; btn.textContent = "Copied";
setTimeout(() => { guestCopy.textContent = "Copy link"; }, 1500); setTimeout(() => { btn.textContent = "Copy"; }, 1500);
} catch { } catch {
// Clipboard needs a secure context and can be refused; the URL is on // The clipboard API needs a secure context and can be refused outright.
// screen either way, so select it and let them copy by hand. // The URL is on screen either way, so select it and let them copy by hand.
const range = document.createRange(); selectContents(code);
range.selectNodeContents(guestNewURL);
const sel = getSelection();
sel.removeAllRanges();
sel.addRange(range);
} }
}); }
async function revokeGuestLink(id, label) { async function revokeGuestLink(id, label) {
if (!confirm(`Turn off the link for “${label}”? Whoever has it loses access straight away.`)) return; if (!confirm(`Turn off the link for “${label}”? Whoever has it loses access straight away.`)) return;
+1
View File
@@ -1,4 +1,5 @@
[ [
{ "date": "2026-09-07", "text": "Guest links can be copied whenever you want. Settings → Guest access now shows each live link's URL next to it with a Copy button, instead of showing it once when you created it and never again. If you lose the message you sent, or want to pass the same link to someone else, you can just take it again rather than making a new one and leaving whoever already had the old one locked out. Links you made before this change can't be shown — only a scrambled form of those was kept — so revoke one and create a fresh one if you need its URL back" },
{ "date": "2026-09-07", "text": "Fixed buttons that were meant to be hidden but showed anyway. A guest opening an entry the owner logged saw Delete and Save on it — they never worked (the server refuses the change) but they had no business being there. The same fault had been quietly affecting three other things for a while: the 🌳 pedigree button appeared before you had set a pedigree ID, “Send a test notification” appeared when reminders weren't available, and the exercise dialog offered Delete while you were adding a new exercise rather than editing one. One styling rule was overriding every one of them" }, { "date": "2026-09-07", "text": "Fixed buttons that were meant to be hidden but showed anyway. A guest opening an entry the owner logged saw Delete and Save on it — they never worked (the server refuses the change) but they had no business being there. The same fault had been quietly affecting three other things for a while: the 🌳 pedigree button appeared before you had set a pedigree ID, “Send a test notification” appeared when reminders weren't available, and the exercise dialog offered Delete while you were adding a new exercise rather than editing one. One styling rule was overriding every one of them" },
{ "date": "2026-09-07", "text": "A day can now be left out of the stats. Open the day, tap “⊘ Not counted” next to the overview heading, and it stops feeding the charts and averages — useful when someone else had the puppy and the record is thinner than the day really was, so it isn't fair to count it. Nothing is deleted or hidden: the day's own overview, history and sleep & wake list are exactly as they were, just dimmed and labelled, and you can switch it back at any time. In the day-by-day charts the day keeps its place but is drawn as a hatch instead of a bar, so a deliberate gap can't be misread as a day the puppy barely slept. Weigh-ins and notes still count wherever they fall — those are facts you recorded, not behaviour a sparse day distorts — so the weight curve and the Notes log are untouched. The Timing panel throws away gaps that reach across a skipped day rather than measuring them, which would otherwise turn two normal days into one enormous fake gap" }, { "date": "2026-09-07", "text": "A day can now be left out of the stats. Open the day, tap “⊘ Not counted” next to the overview heading, and it stops feeding the charts and averages — useful when someone else had the puppy and the record is thinner than the day really was, so it isn't fair to count it. Nothing is deleted or hidden: the day's own overview, history and sleep & wake list are exactly as they were, just dimmed and labelled, and you can switch it back at any time. In the day-by-day charts the day keeps its place but is drawn as a hatch instead of a bar, so a deliberate gap can't be misread as a day the puppy barely slept. Weigh-ins and notes still count wherever they fall — those are facts you recorded, not behaviour a sparse day distorts — so the weight curve and the Notes log are untouched. The Timing panel throws away gaps that reach across a skipped day rather than measuring them, which would otherwise turn two normal days into one enormous fake gap" },
{ "date": "2026-09-06", "text": "You can hand someone temporary access without giving them your login. Settings → “Guest access” creates a link — say who it's for and pick the last day it should work — and whoever opens it lands straight in the app on your dog, able to log events and read all the history and charts. They can't change your entries: a guest may fix up or delete what they logged themselves, but everything you logged is read-only to them, and so is the puppy profile, the pedigree ID, your reminders, the exercise list, other guest links and deleting the account. You can still edit anything on your own account, theirs included. The link is shown once when you make it, so copy it then; every live link is listed in Settings with when it expires and when it was last used, and Revoke cuts access off immediately, mid-session. Anything logged on a link is tagged with that link's name in the History log — “💧 Pee · Anna” — and the tag sticks even if you edit the entry afterwards" }, { "date": "2026-09-06", "text": "You can hand someone temporary access without giving them your login. Settings → “Guest access” creates a link — say who it's for and pick the last day it should work — and whoever opens it lands straight in the app on your dog, able to log events and read all the history and charts. They can't change your entries: a guest may fix up or delete what they logged themselves, but everything you logged is read-only to them, and so is the puppy profile, the pedigree ID, your reminders, the exercise list, other guest links and deleting the account. You can still edit anything on your own account, theirs included. The link is shown once when you make it, so copy it then; every live link is listed in Settings with when it expires and when it was last used, and Revoke cuts access off immediately, mid-session. Anything logged on a link is tagged with that link's name in the History log — “💧 Pee · Anna” — and the tag sticks even if you edit the entry afterwards" },
-8
View File
@@ -510,14 +510,6 @@
<button type="button" id="guest-create" class="ghost">Create link</button> <button type="button" id="guest-create" class="ghost">Create link</button>
<p id="guest-error" class="auth-error" hidden></p> <p id="guest-error" class="auth-error" hidden></p>
<!-- The URL is shown once, here, and never again: only its hash is
stored, so it cannot be read back later. -->
<div id="guest-new" class="guest-new" hidden>
<p class="settings-hint">Copy it now — for safety it isn't shown again.</p>
<code id="guest-new-url" class="guest-url"></code>
<button type="button" id="guest-copy" class="ghost">Copy link</button>
</div>
<ul id="guest-list" class="guest-list"></ul> <ul id="guest-list" class="guest-list"></ul>
<p id="guest-empty" class="settings-hint">No active links.</p> <p id="guest-empty" class="settings-hint">No active links.</p>
</div> </div>
+31 -19
View File
@@ -984,34 +984,20 @@ button.linklike:hover { text-decoration: underline; filter: none; }
} }
/* ---------- guest links ---------- */ /* ---------- guest links ---------- */
/* The one-time URL. Shown once and never again, so it gets a box of its own
rather than sitting inline where it could be missed. */
.guest-new {
background: var(--accent-soft);
border-radius: var(--radius);
padding: 10px 12px;
margin: 12px 0;
}
.guest-new .settings-hint { margin: 0 0 6px; }
.guest-url {
display: block;
font-size: 0.8rem;
word-break: break-all;
margin-bottom: 8px;
line-height: 1.4;
}
.guest-list { .guest-list {
list-style: none; list-style: none;
margin: 12px 0 0; margin: 12px 0 0;
padding: 0; padding: 0;
} }
/* A link is two stacked rows: who and when on top, the URL underneath. */
.guest-item { .guest-item {
padding: 10px 0;
border-top: 1px solid var(--border);
}
.guest-item-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 0;
border-top: 1px solid var(--border);
} }
.guest-item-main { .guest-item-main {
flex: 1; flex: 1;
@@ -1029,6 +1015,32 @@ button.linklike:hover { text-decoration: underline; filter: none; }
} }
button.guest-revoke { color: var(--danger); flex: none; } button.guest-revoke { color: var(--danger); flex: none; }
/* The URL, always available so the link can be re-sent. It is 90-odd characters
of hex, so it gets one truncated line rather than wrapping into a wall of it
the Copy button is the way it is meant to be taken, and tapping the text
selects the whole thing for anywhere the clipboard API is unavailable. */
.guest-item-url {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0 0;
}
.guest-url {
flex: 1;
min-width: 0;
font-size: 0.75rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: text;
}
button.guest-copy {
flex: none;
padding: 4px 10px;
font-size: 0.75rem;
}
/* Only a guest ever sees this, directly under the header. No bottom margin: /* Only a guest ever sees this, directly under the header. No bottom margin:
main's own top padding provides the gap to the first panel. */ main's own top padding provides the gap to the first panel. */
.guest-banner { .guest-banner {