Skip to main content

max / makenotwork

phase 1: UX polish, What's New modal, SSH break-glass doc Eight Phase 1 items closed across UX polish, trust-audit follow-ups, and infra hygiene. Three were resolved-by-current-state (the audit flagged them but the code/docs already covered the concern); five needed real code or doc. Toast stacking - `TOAST_MAX_VISIBLE = 5` cap in the showToast handler. Oldest toast is dropped before a new one appends so a burst of HTMX errors can't bury the viewport. CSS already supported visual stacking via flex column + gap. What's New modal + "New" badge - New `static/whats_new.js` owns both surfaces. `FEATURE_VERSION` is an opaque string controlled in the file — bump it when /changelog has something worth surfacing. Modal shows once per user (tracked in localStorage), reopens via the footer link. First-ever visitors don't see it. - "New" badge mechanism: any element with `data-new-until="YYYY-MM-DD"` gets a `.is-new` class while today is on or before that date. CSS renders a small violet dot. Self-cleaning past the date. - Deliberately decoupled from CARGO_PKG_VERSION so patch bumps don't fire the modal. Footer + landing - New "Shortcuts" footer link calls the existing `toggleShortcutsHelp()`. The `?` keybinding is unchanged. - New "What's new" footer link opens the modal on demand. - Landing page gets a "What's new →" link in the stats strip, more prominent than the buried footer entry. Tailscale break-glass SSH doc - New `server/deploy/SSH_ACCESS.md` documents the two-path split (public :22 git-shell only, admin :2200 tailnet only), the break-glass procedure via Hetzner Cloud Console, and what NOT to do. Cross-refs `feedback_tailscale_ssh` memory. Resolved-by-current-state (no code change) - Content archive guarantee: already explicitly "Planned, not yet implemented" in `about/guarantees.md`. - Creator status notifications on health transitions: already shipped in `monitor.rs` (queries `get_status_alert_subscribers`, sends via Postmark; R27-PERF-M2 added cooldown + 100ms pacing). Still deferred in Phase 1 - CF cache hit ratio + Hetzner egress (external API integrations). - Backblaze B2 offsite (needs credentials). - Creator storefront preview/demo (needs UI design). - Accessibility ARIA pair (moved to own deferred section pending dedicated push with outside expertise).
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-22 14:08 UTC
Commit: da13a0c7a2b65f7e7997880c6b6d2609ec44f928
Parent: 5a41936
6 files changed, +276 insertions, -0 deletions
@@ -0,0 +1,79 @@
1 + # SSH access — production server (Hetzner)
2 +
3 + Two SSH paths into the production server, with different audiences and
4 + different break-glass behavior. Read this before disabling either one.
5 +
6 + ## The two paths
7 +
8 + | Port | Interface | Audience | Allowed from |
9 + |------|-----------|----------|--------------|
10 + | `22` | public (eth0) | mnw-cli git operations | anywhere (firewall + sshd config) |
11 + | `2200` | Tailscale (tailscale0) | admin access | tailnet only (firewall blocks public) |
12 +
13 + - **Public :22** is intentionally open so creators can `git clone`/`push`
14 + over SSH against `ssh.makenot.work`. The sshd config on this port is
15 + locked to git-shell only — see `setup-git-ssh.sh` and `sshd-git.conf`.
16 + No interactive shell, no port forwarding, no admin access.
17 + - **Tailnet :2200** is the admin path. Full interactive shell, used for
18 + every `deploy.sh` invocation and any manual maintenance. Reachable only
19 + from devices on the tailnet (firewall rule `ufw allow in on tailscale0`
20 + in `setup-firewall.sh`).
21 +
22 + ## Why this split exists
23 +
24 + The audit-flagged risk was disabling Tailscale SSH (the admin path)
25 + without first verifying the public sshd was still functional. Tailscale
26 + runs its own SSH server when configured; if that goes down — Tailscale
27 + service crashes, ACL misconfiguration, accidental `tailscale down` —
28 + you can lose admin access entirely if you've also locked down public
29 + sshd.
30 +
31 + The split solves this: public :22 is always alive (firewalled to allow
32 + SSH from anywhere) but restricted to git-shell, so an attacker who
33 + probes :22 finds nothing but git commands. Admin :2200 lives on the
34 + tailnet, where the firewall blocks public access and the surface area
35 + is small.
36 +
37 + ## Break-glass procedure
38 +
39 + If the tailnet path stops working (Tailscale down, key revoked, ACL
40 + broken):
41 +
42 + 1. **Verify public sshd is up** from any machine:
43 + `ssh -p 22 root@5.78.144.244 -o BatchMode=yes -o ConnectTimeout=5 true`
44 + Expect a key-based prompt or a refused git-shell — both prove sshd
45 + is listening. A timeout or "Connection refused" means public sshd is
46 + ALSO down and you need Hetzner Cloud Console.
47 + 2. **Edit `/etc/ssh/sshd_config.d/git-shell.conf` from Hetzner Console**
48 + to temporarily restore an interactive shell for the `root` user on
49 + port `22`. Match `User root` block, set `ForceCommand` to nothing.
50 + 3. **Restart sshd**: `systemctl restart ssh`. Test from your laptop.
51 + 4. **Fix the tailnet path** (re-auth `tailscale up`, restore key, etc).
52 + 5. **Revert the sshd edit** and restart `ssh` again. Don't leave the
53 + interactive root shell on public :22 — it defeats the whole split.
54 +
55 + ## What NOT to do
56 +
57 + - **Do not** disable Tailscale SSH (`tailscale set --ssh=false`) without
58 + first proving public :22 is reachable and you have a working root key
59 + for it. Memory rule: `feedback_tailscale_ssh` — getting locked out
60 + requires Hetzner Console access, which costs time we don't always
61 + have.
62 + - **Do not** restrict public :22 to specific IPs without coordinating —
63 + the mnw-cli git endpoint serves users worldwide.
64 + - **Do not** open port 2200 to the public — it's the admin shell.
65 +
66 + ## Hetzner Console (last resort)
67 +
68 + If both paths are dead, the Hetzner Cloud Console provides KVM-style
69 + access to the server's serial console. Login at
70 + <https://console.hetzner.cloud>, select the project, select the server,
71 + "Console" tab. Slow but always available. Same root credentials.
72 +
73 + ## Key paths
74 +
75 + - Firewall: `deploy/setup-firewall.sh`
76 + - Public sshd (git-only): `deploy/sshd-git.conf`, `deploy/setup-git-ssh.sh`
77 + - Admin sshd (tailnet): `/etc/ssh/sshd_config` on the server
78 + - Deploy entry point: `deploy/deploy.sh` (uses `-p 2200`)
79 + - CI runner setup: `deploy/setup-ci.sh` (uses `-p 2200`)
@@ -23,9 +23,17 @@ document.addEventListener('DOMContentLoaded', function() {
23 23 TOAST NOTIFICATIONS
24 24 =========================================== */
25 25
26 + // Maximum simultaneously-visible toasts. Anything beyond this drops the
27 + // oldest first so a burst of HTMX errors can't bury the viewport.
28 + var TOAST_MAX_VISIBLE = 5;
29 +
26 30 document.body.addEventListener('showToast', function(evt) {
27 31 var container = document.getElementById('notifications');
28 32 if (!container) return;
33 + // Cap the stack: drop the oldest toast immediately when at capacity.
34 + while (container.childElementCount >= TOAST_MAX_VISIBLE) {
35 + container.firstElementChild.remove();
36 + }
29 37 var toast = document.createElement('div');
30 38 toast.className = 'toast toast-' + (evt.detail.type || 'info');
31 39 toast.textContent = evt.detail.message || 'Action completed';
@@ -4708,6 +4708,29 @@ footer {
4708 4708 }
4709 4709
4710 4710 /* ===========================================
4711 + "NEW" BADGE (data-new-until)
4712 + ===========================================
4713 + Applied by whats_new.js while today's date is <= the element's
4714 + data-new-until. Renders a small violet dot to the right of the
4715 + element's content. Pair with relative-positioned parents. */
4716 +
4717 + .is-new {
4718 + position: relative;
4719 + }
4720 +
4721 + .is-new::after {
4722 + content: "";
4723 + position: absolute;
4724 + top: -2px;
4725 + right: -8px;
4726 + width: 6px;
4727 + height: 6px;
4728 + border-radius: 50%;
4729 + background: var(--violet, #6c5ce7);
4730 + box-shadow: 0 0 0 2px var(--background, #fff);
4731 + }
4732 +
4733 + /* ===========================================
4711 4734 RESTART WARNING BANNER
4712 4735 =========================================== */
4713 4736
@@ -0,0 +1,158 @@
1 + /* What's New modal + "New" badge mechanism.
2 + *
3 + * Two related UI surfaces that share localStorage as their source of truth:
4 + *
5 + * 1. Auto-show modal on a "feature version" bump. FEATURE_VERSION is an
6 + * opaque string controlled here (not CARGO_PKG_VERSION). Edit it when
7 + * you want to fire the modal — typically at the end of a sprint when
8 + * the changelog has something worth surfacing. Skipping a bump is
9 + * fine: nothing happens if FEATURE_VERSION matches the last-seen
10 + * version in localStorage.
11 + *
12 + * 2. "New" badge on individual links/buttons. Mark any element with
13 + * data-new-until="YYYY-MM-DD" — JS adds a `is-new` class while today
14 + * is before the date. CSS renders the dot. Once the date passes the
15 + * class is removed at page load and the dot disappears.
16 + *
17 + * Both deliberately avoid server cooperation: no API to call, no template
18 + * plumbing, no version-detection brittleness. The whole feature lives in
19 + * this file plus the CSS for `.is-new`.
20 + */
21 +
22 + (function () {
23 + 'use strict';
24 +
25 + // ── What's New modal ──────────────────────────────────────────────
26 +
27 + /// Bump this when /changelog has shipped something users should see.
28 + /// Setting it to a NEW value triggers the modal once per user.
29 + var FEATURE_VERSION = 'v0.8-synckit-per-key';
30 +
31 + /// Summary shown in the modal body. Keep to 1–3 sentences; the link
32 + /// to /changelog is the canonical full list.
33 + var FEATURE_HEADLINE = 'SyncKit per-key storage';
34 + var FEATURE_BODY =
35 + "SyncKit apps now bill per developer-defined key rather than per app, " +
36 + "with mini-gauges in the dashboard and per-key warning emails. " +
37 + "Existing SyncKit clients keep working — the SDK signature is updated " +
38 + "for new integrations.";
39 +
40 + var STORAGE_KEY = 'mnw_seen_feature_version';
41 +
42 + function safeGet(key) {
43 + try { return localStorage.getItem(key); } catch (e) { return null; }
44 + }
45 + function safeSet(key, value) {
46 + try { localStorage.setItem(key, value); } catch (e) { /* ignore */ }
47 + }
48 +
49 + /// Show the modal regardless of seen state. Used by the "What's new"
50 + /// link in the footer.
51 + function showWhatsNewModal() {
52 + var existing = document.getElementById('whats-new-modal');
53 + if (existing) { existing.remove(); return; }
54 +
55 + var overlay = document.createElement('div');
56 + overlay.id = 'whats-new-modal';
57 + overlay.className = 'modal-overlay';
58 + overlay.style.display = 'flex';
59 + overlay.onclick = function (e) {
60 + if (e.target === overlay) {
61 + overlay.remove();
62 + safeSet(STORAGE_KEY, FEATURE_VERSION);
63 + }
64 + };
65 +
66 + var content = document.createElement('div');
67 + content.className = 'modal-content';
68 + content.style.maxWidth = '480px';
69 + content.style.padding = '2rem';
70 +
71 + var header = document.createElement('div');
72 + header.className = 'modal-header';
73 + header.style.marginBottom = '1rem';
74 + var h2 = document.createElement('h2');
75 + h2.textContent = "What's new: " + FEATURE_HEADLINE;
76 + header.appendChild(h2);
77 + var closeBtn = document.createElement('button');
78 + closeBtn.type = 'button';
79 + closeBtn.className = 'modal-close';
80 + closeBtn.setAttribute('aria-label', 'Dismiss');
81 + closeBtn.innerHTML = '&times;';
82 + closeBtn.onclick = function () {
83 + overlay.remove();
84 + safeSet(STORAGE_KEY, FEATURE_VERSION);
85 + };
86 + header.appendChild(closeBtn);
87 +
88 + var body = document.createElement('p');
89 + body.textContent = FEATURE_BODY;
90 +
91 + var link = document.createElement('a');
92 + link.href = '/changelog';
93 + link.textContent = 'Full changelog →';
94 + link.className = 'section-link';
95 + link.style.display = 'inline-block';
96 + link.style.marginTop = '0.75rem';
97 +
98 + content.appendChild(header);
99 + content.appendChild(body);
100 + content.appendChild(link);
101 + overlay.appendChild(content);
102 + document.body.appendChild(overlay);
103 + }
104 +
105 + /// Auto-show on first visit after a feature-version bump. Records the
106 + /// seen version in localStorage so dismissal sticks across reloads.
107 + function maybeAutoShowWhatsNew() {
108 + var seen = safeGet(STORAGE_KEY);
109 + if (seen === FEATURE_VERSION) return;
110 + // First-ever visit also seeds the storage — no modal flash for
111 + // genuinely new users (they're already getting onboarded).
112 + if (seen === null) {
113 + safeSet(STORAGE_KEY, FEATURE_VERSION);
114 + return;
115 + }
116 + showWhatsNewModal();
117 + }
118 +
119 + // Expose for footer link click + onboarding flows.
120 + window.showWhatsNewModal = showWhatsNewModal;
121 +
122 + // ── "New" badge ────────────────────────────────────────────────────
123 +
124 + /// Walk every `[data-new-until]` element. If today < that date, mark
125 + /// it `.is-new` so the CSS dot renders. Otherwise strip the attribute
126 + /// so it doesn't get re-evaluated on later page loads.
127 + function applyNewBadges() {
128 + var today = new Date();
129 + today.setHours(0, 0, 0, 0);
130 + var nodes = document.querySelectorAll('[data-new-until]');
131 + for (var i = 0; i < nodes.length; i++) {
132 + var el = nodes[i];
133 + var until = new Date(el.getAttribute('data-new-until'));
134 + if (isNaN(until.getTime())) {
135 + el.removeAttribute('data-new-until');
136 + continue;
137 + }
138 + if (today <= until) {
139 + el.classList.add('is-new');
140 + } else {
141 + el.removeAttribute('data-new-until');
142 + el.classList.remove('is-new');
143 + }
144 + }
145 + }
146 +
147 + // ── Init ──────────────────────────────────────────────────────────
148 +
149 + if (document.readyState === 'loading') {
150 + document.addEventListener('DOMContentLoaded', function () {
151 + applyNewBadges();
152 + maybeAutoShowWhatsNew();
153 + });
154 + } else {
155 + applyNewBadges();
156 + maybeAutoShowWhatsNew();
157 + }
158 + })();
@@ -22,6 +22,8 @@
22 22 <a href="/docs">Docs</a>
23 23 <a href="/policy">Legal</a>
24 24 <a href="/changelog">Changelog</a>
25 + <a href="#" onclick="event.preventDefault(); showWhatsNewModal();">What's new</a>
26 + <a href="#" onclick="event.preventDefault(); toggleShortcutsHelp();" title="Keyboard shortcuts (?)">Shortcuts</a>
25 27 </div>
26 28 <p>&copy; 2026 Makenotwork</p>
27 29 </footer>
@@ -32,6 +34,7 @@
32 34 <script src="/static/mnw.js?v=0514"></script>
33 35 <script src="/static/collections.js?v=0514"></script>
34 36 <script src="/static/synckit-billing.js?v=0620"></script>
37 + <script src="/static/whats_new.js?v=0522"></script>
35 38 {% block scripts %}{% endblock %}
36 39 </body>
37 40 </html>
@@ -100,6 +100,11 @@
100 100 </p>
101 101 {% endif %}
102 102
103 + <p class="landing-stats-line">
104 + <a href="/changelog">What's new →</a>
105 + </p>
106 +
107 +
103 108 <div class="landing-principles">
104 109 <h2 class="section-label">Why this exists</h2>
105 110 <ul class="how-list">