mdshelf 1.0.8 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  Turn any folder of Markdown files into a fast, beautiful, browsable site. Point mdshelf at your OpenClaw, Hermes, GBrain, wikis, notes, docs, etc. — mount each one under its own URL (e.g. `/agent`, `/hermes`, `gbrain`, `/docs`, `/notes`, `/blog`) — and read them anywhere with hot reload, clean typography, and a mobile-first theme out of the box.
4
4
 
5
- Pairs beautifully with [Tailscale](https://tailscale.com): one command serves your shelf to every device on your tailnet — phone, iPad, laptop. Sharing with Tailscale is fully private by default: no port forwarding, no public DNS, nothing exposed to the open internet.
5
+ Pairs beautifully with [Tailscale](https://tailscale.com): one command serves your shelf to every device on your tailnet — phone, iPad, laptop. Serving over Tailscale is fully private by default: no port forwarding, no public DNS, nothing exposed to the open internet.
6
+
7
+ Putting a shelf on the open internet? `mdshelf serve --auth google` signs readers in with Google and shows each one only the pages their address is allowed to read — the rules live in each file's frontmatter. Signed-in readers can hand anyone a link to a single page that expires on its own and can be revoked at any time.
6
8
 
7
9
  One command installs it as a native system service (launchd, systemd, or Windows SCM) that runs quietly in the background and survives reboots.
8
10
 
@@ -24,6 +26,7 @@ Ships as a single static Rust binary with zero runtime dependencies.
24
26
  - Prev / next page navigation
25
27
  - Auto-generated index pages for folders that have no `index.md`
26
28
  - Home page listing all mounted sites with page counts
29
+ - Copy or download any page's Markdown source straight from its header
27
30
 
28
31
  **Theme**
29
32
  - Bundled `mdshelf-theme` — mobile-first, responsive, light/dark aware
@@ -32,6 +35,13 @@ Ships as a single static Rust binary with zero runtime dependencies.
32
35
  - Per-site accent colors — auto-assigned from a palette or configured explicitly
33
36
  - MiniJinja templates for layouts and partials — override any file without forking the whole theme
34
37
 
38
+ **Access**
39
+ - **Google sign-in** — `--auth google` puts every page behind sign-in with your own OAuth client
40
+ - Per-page access rules in frontmatter (`allow` / `deny`), inherited through folder `index.md` files; anything not granted is denied
41
+ - **Share links** — one page, no sign-in, expiring and revocable, from a Share button or `mdshelf share`
42
+ - Access log with `mdshelf audit`; rule debugging with `mdshelf acl explain` and `mdshelf acl doctor`
43
+ - Built-in HTTPS: Let's Encrypt, your own certificate, or TLS terminated by a proxy in front
44
+
35
45
  **Server**
36
46
  - **Hot reload** — WebSocket-based live reload; the browser refreshes automatically the moment you save a file
37
47
  - Response compression (gzip / brotli / zstd via tower-http)
@@ -112,10 +122,18 @@ Open `http://127.0.0.1:4444/` for the site index.
112
122
  | Command | Description |
113
123
  |---|---|
114
124
  | `mdshelf init [dir]` | Scaffold config and sample content |
115
- | `mdshelf serve` | Start the web server |
125
+ | `mdshelf serve` | Start the web server (`--auth google` for a private site) |
116
126
  | `mdshelf check` | Validate config and scan content |
117
127
  | `mdshelf export` | Export a static bundle of HTML and CSS files |
118
- | `mdshelf install` | Register as a native system service |
128
+ | `mdshelf auth setup` | Walk through creating a Google OAuth client, then verify it |
129
+ | `mdshelf acl explain <path> <email>` | Show why an address can or cannot read a page |
130
+ | `mdshelf acl doctor` | Report access-rule problems |
131
+ | `mdshelf acl grant <email> <path>` | Add an address to a page or folder's `allow` list |
132
+ | `mdshelf audit --path/--email` | Query the access log |
133
+ | `mdshelf share <path>` | Create a link that shares one page with anyone holding the URL |
134
+ | `mdshelf share list` | List share links (`--all` for dead ones, `--json` for an inventory) |
135
+ | `mdshelf share revoke <id>` | Revoke one link, or `--all` to revoke every live link |
136
+ | `mdshelf install` | Register `serve`, with any of its options, as a native system service |
119
137
  | `mdshelf start` | Start the installed service |
120
138
  | `mdshelf stop` | Stop the installed service |
121
139
  | `mdshelf restart` | Restart the installed service |
@@ -171,14 +189,276 @@ draft: false
171
189
  ---
172
190
  ```
173
191
 
192
+ Two further keys, `allow` and `deny`, control who may read a page — see
193
+ [Private sites with Google sign-in](#private-sites-with-google-sign-in). They are
194
+ stripped before rendering, so the addresses in them never reach the browser.
195
+
196
+ ## Private sites with Google sign-in
197
+
198
+ `mdshelf serve --auth google` puts every page behind Google sign-in and serves each
199
+ reader only what they have been invited to. Without the flag nothing changes: the
200
+ server behaves exactly as it always has.
201
+
202
+ ### Who may read what
203
+
204
+ Access rules live in the vault, as frontmatter, in the file they protect:
205
+
206
+ ```yaml
207
+ ---
208
+ title: Compensation 2026
209
+ allow:
210
+ - ana@corp.com
211
+ - hr-lead@corp.com
212
+ deny:
213
+ - intern@corp.com
214
+ ---
215
+ ```
216
+
217
+ Because the rule is part of the file, renaming, moving, or deleting it carries or
218
+ removes the rule with it. There is nothing to keep in sync.
219
+
220
+ Rules exist at three levels:
221
+
222
+ | Where | Governs |
223
+ |---|---|
224
+ | Any `.md` file | That file alone |
225
+ | A folder's `index.md` | **That folder, everything beneath it, and the index page itself** |
226
+ | The vault root's `index.md` | The whole site |
227
+
228
+ Both keys take a **list**. A bare string is an error, not a one-element list — so the
229
+ common typo `allow: a@x.com, b@y.com` is rejected rather than quietly granting access
230
+ to one malformed address.
231
+
232
+ Only `index.md` governs a folder. `readme.md` and `index.markdown` are ordinary pages to
233
+ mdshelf — served at `/readme` and `/index.markdown` — so a rule in one of them applies to
234
+ that page alone, not to everything beside it.
235
+
236
+ ### How a decision is reached
237
+
238
+ 1. Collect every rule that applies: the file's own, then each ancestor folder's
239
+ `index.md` from nearest to furthest, then the site root.
240
+ 2. The most specific level that **names the address** decides. A `deny` there beats an
241
+ inherited `allow`, and an `allow` there beats an inherited `deny`.
242
+ 3. If no level names the address, the answer is **deny**.
243
+
244
+ > **A site-level `allow` reaches into every folder.** A folder's `allow` *adds* people;
245
+ > it does not remove anyone. To keep somebody out of a subtree that a broader rule
246
+ > already grants, name them in that folder's `deny`. `mdshelf acl doctor` will not
247
+ > guess this for you — use `mdshelf acl explain <path> <email>` whenever the outcome
248
+ > surprises you.
249
+
250
+ ### Fail closed
251
+
252
+ A path that no rule names is invisible. **A vault with no rules at all shows nobody
253
+ anything** when `--auth google` is on — start by granting someone in the root
254
+ `index.md`. Likewise, a malformed `allow` or `deny` block makes that file unreadable by
255
+ everyone until it is fixed; `mdshelf check` fails on such a block so it never reaches a
256
+ running server, and `mdshelf acl doctor` reports it on a server already running.
257
+
258
+ Readers who are signed in but not invited get the same response for a restricted page
259
+ and for one that does not exist, so the site cannot be probed to learn what it contains.
260
+
261
+ ### Setting up Google credentials
262
+
263
+ mdshelf uses **your** Google OAuth client; no credentials ship with the binary.
264
+
265
+ ```bash
266
+ mdshelf auth setup
267
+ ```
268
+
269
+ The wizard prints the console steps, gives you the exact redirect URI to register,
270
+ stores the credentials in `~/.config/mdshelf/credentials.env` (mode 0600), and proves
271
+ with a real sign-in before it exits. `MDSHELF_GOOGLE_CLIENT_ID` and
272
+ `MDSHELF_GOOGLE_CLIENT_SECRET` override the file if you use a secret manager.
273
+
274
+ ### HTTPS
275
+
276
+ Google accepts a plain-HTTP redirect URI only for `localhost`, so any real deployment
277
+ needs a certificate. mdshelf refuses to serve authenticated sessions over plain HTTP on
278
+ a non-loopback address rather than warning and doing it anyway.
279
+
280
+ ```bash
281
+ # Let's Encrypt, obtained and renewed automatically (needs ports 80 and 443)
282
+ mdshelf serve --auth google --domain docs.acme.com
283
+
284
+ # A certificate you already have
285
+ mdshelf serve --auth google --tls-cert fullchain.pem --tls-key privkey.pem
286
+
287
+ # TLS terminated by an ALB, nginx, Caddy, or Cloudflare in front
288
+ mdshelf serve --auth google --behind-proxy --public-url https://docs.acme.com
289
+ ```
290
+
291
+ For LAN or offline testing, `mdshelf auth setup --self-signed <dir>` writes a
292
+ development certificate. Browsers will warn about it, and Google will not accept a
293
+ self-signed host as a redirect URI, so it is only useful before auth is switched on.
294
+
295
+ ### Sessions, revocation, and Google availability
296
+
297
+ Removing an address from a vault file takes effect on the **next request** — the
298
+ watcher reloads the rules and every request resolves them afresh.
299
+
300
+ Sessions are re-validated against Google when they have been idle for more than 30
301
+ minutes, so a suspended Google account loses access without a reader being interrupted
302
+ mid-page. Sessions expire absolutely after `session_max_age` (default 30 days).
303
+
304
+ > **Google is a hard dependency for re-validation.** If a re-validation attempt fails —
305
+ > whether Google explicitly rejects the grant *or* is simply unreachable — the session
306
+ > ends and the reader signs in again. A Google incident, or a self-hosted box that
307
+ > briefly loses its uplink, will therefore sign out readers whose sessions have gone
308
+ > idle. This is deliberate: it is what makes "revoked means revoked" true without
309
+ > qualification. Each such event is logged at WARN with the cause, so an outage is
310
+ > visible in the logs rather than presenting as mysterious sign-outs.
311
+
312
+ ### Exporting a private vault
313
+
314
+ A static bundle has no authentication, so exporting a vault that declares rules
315
+ requires saying whose view it is:
316
+
317
+ ```bash
318
+ mdshelf export --as ana@corp.com --output ./for-ana
319
+ ```
320
+
321
+ The bundle then contains exactly the pages, navigation, listings, and attachments that
322
+ address can see, and reports how many pages were skipped. `mdshelf export` on a vault
323
+ with no rules is unchanged. Note that the bundle itself is unprotected once you send it.
324
+
325
+ ### Shareable links
326
+
327
+ A **Share** control appears on every page a signed-in viewer can read. Pick how long the
328
+ link should live and you get an opaque URL:
329
+
330
+ ```
331
+ https://docs.acme.com/s/kJ8mQ2xR7pL4nT9v
332
+ ```
333
+
334
+ Anyone holding that URL reads **that one page** — no account, no sign-in — in a clean
335
+ reading view, until it expires, until you revoke it, or until your own access to the
336
+ page goes away. The URL reveals nothing to anyone who merely sees it: not the site, not
337
+ the folder, not the page title, not that the page exists.
338
+
339
+ Share links require `--auth google`. Without it every page is already public, so a link
340
+ would imply a protection that is not there.
341
+
342
+ The same thing from a terminal:
343
+
344
+ ```bash
345
+ # Create. Prints exactly one URL, and only once.
346
+ mdshelf share /docs/hr/comp --for 1d --as ana@corp.com
347
+
348
+ # Or until a specific instant. Read as UTC unless it carries an offset.
349
+ mdshelf share /docs/hr/comp --until 2026-09-01
350
+
351
+ # What am I exposing?
352
+ mdshelf share list
353
+ mdshelf share list --all --json # inventory, including dead links
354
+
355
+ # The incident lever.
356
+ mdshelf share revoke ab12cd
357
+ mdshelf share revoke --all
358
+ ```
359
+
360
+ `--for` and `--until` are alternatives, not a pair. Without either, `[links]
361
+ default_lifetime` applies. `--base-url` supplies the origin browsers use to reach the
362
+ server when the config binds an address nobody can visit. Signed-in viewers see their
363
+ own links on `/__shares`.
364
+
365
+ **The URL is shown once and cannot be recovered.** mdshelf stores only `sha256(token)`,
366
+ so nothing — not the database, not `share list`, not a support request — can produce a
367
+ lost URL again. Create another instead.
368
+
369
+ #### What a share link is not
370
+
371
+ - **Not a folder or site link.** One link reaches one page plus the images and files
372
+ that page references. Share several pages by making several links, or use
373
+ `mdshelf export --as`.
374
+ - **Not password-protected.** The URL is the whole credential.
375
+ - **Not a second copy of your access rules.** A link is a *window onto its issuer's own
376
+ live access*, revalidated on every request.
377
+ - **Not recoverable, and not extendable.** To change an expiry, revoke and re-mint.
378
+ - **Not analytics.** Reads land in the access log; querying it is `mdshelf audit`.
379
+
380
+ #### Link configuration
381
+
382
+ ```toml
383
+ [links]
384
+ enabled = true # incident kill switch; leaves the rows intact
385
+ prefix = "/s" # the route links live under
386
+ default_lifetime = "1d" # used when neither --for nor --until is given
387
+ max_lifetime = "30d" # the cap; a longer request is refused, not clamped
388
+ revoked_retention = "90d" # how long dead rows are kept before the sweep deletes them
389
+
390
+ [auth]
391
+ bad_link_retention = "7d" # how long requests with unknown tokens are kept
392
+ ```
393
+
394
+ A site mounted at the same place as `prefix` fails startup and `mdshelf check`: move the
395
+ share route rather than the site.
396
+
397
+ #### Security note
398
+
399
+ > **A share link is a bearer credential.** Whoever holds the URL has the access —
400
+ > forwarding the URL forwards the access, and mdshelf cannot tell the difference. Four
401
+ > consequences worth stating plainly:
402
+ >
403
+ > - **The audit names a link, not a person.** A read appears as `link:ab12cd`, because
404
+ > that is the honest answer to "who read this?" for a URL that anyone may have passed
405
+ > on. `mdshelf share list` prints the same id, so the two join by eye.
406
+ > - **The banner exposes the sharer's address.** The reading view says "Shared by
407
+ > ana@corp.com", and that reaches everyone the URL reaches, forwarded recipients
408
+ > included. It is your own address, never a third party's.
409
+ > - **A link tracks its issuer's live access.** Remove someone from an allow list and
410
+ > every link they made stops working on the next request — so offboarding needs no
411
+ > separate cleanup. The same mechanism can break a working link with no signal to
412
+ > either side when somebody changes teams; `share list` shows the link as dead so the
413
+ > sharer can see why.
414
+ > - **Link-preview bots fetch URLs.** A bearer URL posted in a chat channel may be
415
+ > fetched and rendered into that channel. Responses carry `Cache-Control: no-store`
416
+ > and `X-Robots-Tag: noindex, nofollow`, which limits what persists, but the fetch
417
+ > itself is inherent to bearer links.
418
+
419
+ ### Auth-related configuration
420
+
421
+ ```toml
422
+ [auth]
423
+ session_max_age = "30d" # absolute session ceiling
424
+ owner_email = "owner@corp.com" # request-access link on the deny page
425
+ audit_retention = "90d" # access-log retention, pruned hourly
426
+ bad_link_retention = "7d" # requests with unknown share tokens
427
+ # database = "./mdshelf.db" # sessions, access log, and share links
428
+ # key_file = "~/.config/mdshelf/secret.key" # encrypts refresh tokens, mode 0600
429
+ ```
430
+
431
+ **`mdshelf.db` is not disposable.** Sessions, the access log and the derived rule index
432
+ can all be rebuilt or lived without, but share links cannot: only their hashes are
433
+ stored, so deleting the database destroys every live link with no way to recreate one.
434
+ Keep `mdshelf share list --json` if you want an inventory of what a deletion would cost.
435
+
436
+ ### What mdshelf writes
437
+
438
+ `mdshelf acl grant` is the **only** command that writes to your vault, and it asks
439
+ before creating a folder's `index.md`. Serving, checking, exporting, and explaining are
440
+ strictly read-only.
441
+
174
442
  ## Theme customisation
175
443
 
176
444
  Override or extend templates by pointing `[theme].directory` at a local directory:
177
445
 
178
- - `layouts/` — `base.html`, `doc.html`, `home.html`, `error.html`
446
+ - `layouts/` — `base.html`, `doc.html`, `home.html`, `error.html`, `link.html`
179
447
  - `partials/` — `header.html`, `sidebar.html`, `toc.html`, `breadcrumbs.html`, `footer.html`
180
448
  - `assets/` — static files served from `/__assets/...`
181
449
 
450
+ ### Theme capabilities
451
+
452
+ Two parts of the theme are optional in a way worth knowing about:
453
+
454
+ - **`{{ share_control }}`** renders the Share popover. The bundled theme places it in
455
+ `partials/header.html`. A theme that never mentions the variable renders normally and
456
+ simply has no sharing — mdshelf never injects it into your markup, so removing that
457
+ line is a supported way to turn the button off for a site. The CLI still works.
458
+ - **`layouts/link.html`** is the reading view a share-link recipient sees. A theme
459
+ without it falls back to a self-contained page that still picks up the theme's
460
+ stylesheets, so recipients never see a template error.
461
+
182
462
  Syntax highlighting CSS is served from `/__mdshelf/syntax.css` (class-based, light/dark aware).
183
463
 
184
464
  ## System service
@@ -197,6 +477,31 @@ sudo mdshelf uninstall
197
477
 
198
478
  Use `--user` for a per-user service where the platform supports it.
199
479
 
480
+ `mdshelf install` takes every `serve` option and the service runs with exactly those, so a
481
+ private site installs the same way it serves:
482
+
483
+ ```bash
484
+ sudo mdshelf auth setup # store Google credentials for the user installing the service
485
+ sudo mdshelf install --auth google --behind-proxy --public-url https://docs.acme.com
486
+ sudo mdshelf start
487
+ ```
488
+
489
+ Install runs the checks `serve` runs before it binds — TLS flags, the `[auth]` and `[links]`
490
+ settings, and the stored credentials — and refuses to register a service that could not
491
+ start. It prints the redirect URI to register with Google.
492
+
493
+ - **Credentials come from the file, not your shell.** A service does not inherit the
494
+ installing shell's `MDSHELF_GOOGLE_*` variables, so install requires both in
495
+ `~/.config/mdshelf/credentials.env` (what `mdshelf auth setup` writes). Run `auth setup`
496
+ as the same user that runs `install` — with `sudo`, that is root.
497
+ - **The service gets the installer's `HOME`.** With `--auth` or `--domain`, the service is
498
+ given the installing user's `HOME`, so the credentials, the at-rest key
499
+ (`~/.config/mdshelf/secret.key`) and the ACME cache resolve to the paths install checked.
500
+ - **Relative paths are pinned** to the directory you ran install in: `--tls-cert`,
501
+ `--tls-key` and `--acme-cache` are stored as absolute paths.
502
+ - **Windows:** a Windows service cannot be given `HOME`, so `install` refuses `--auth`, and
503
+ `--domain` without `--acme-cache`, there.
504
+
200
505
  ## Serving on port 80
201
506
 
202
507
  Set `host` and `port` in your config:
@@ -233,7 +538,7 @@ Tailscale provisions a certificate automatically and makes the site available **
233
538
  tailscale serve status # confirm the URL
234
539
  ```
235
540
 
236
- Pair it with `sudo mdshelf install && sudo mdshelf start` (see [System service](#system-service)) so both services survive reboots.
541
+ Pair it with `sudo mdshelf install && sudo mdshelf start` (see [System service](#system-service)) so both services survive reboots; pass the same `--auth` and TLS flags to `install` as to `serve`.
237
542
 
238
543
  To stop serving:
239
544
 
@@ -243,7 +548,16 @@ tailscale serve off
243
548
 
244
549
  ### Public access via Tailscale Funnel
245
550
 
246
- To make the site reachable from the **public internet** (not just your tailnet), use Funnel instead:
551
+ To make the site reachable from the **public internet** (not just your tailnet), use Funnel instead.
552
+ Funnel publishes everything mdshelf serves to anyone, so serve a private site with sign-in on
553
+ and Tailscale's hostname as the public URL:
554
+
555
+ ```bash
556
+ mdshelf serve --auth google --behind-proxy --public-url https://<machine>.<tailnet>.ts.net
557
+ ```
558
+
559
+ Register `https://<machine>.<tailnet>.ts.net/auth/callback` as a redirect URI on your Google
560
+ OAuth client, then:
247
561
 
248
562
  ```bash
249
563
  tailscale funnel --bg 4444
@@ -255,7 +569,37 @@ The site becomes accessible at the same `*.ts.net` URL from anywhere, without an
255
569
  tailscale funnel off
256
570
  ```
257
571
 
258
- ### Static export
572
+ ## Copy and download a page as Markdown
573
+
574
+ Every page mdshelf serves is a `.md` file, and the page header carries a **Page actions**
575
+ button that hands you that file:
576
+
577
+ - **Copy as Markdown** — puts the source on the clipboard.
578
+ - **Download Markdown** — saves it under its original vault filename, e.g. `setup.md`,
579
+ so it round-trips straight back into Obsidian and sibling `.md` links keep resolving.
580
+
581
+ Both actions use the source already embedded in the page you are reading, so they are
582
+ instant, work offline, and work inside an exported bundle opened from `file://`.
583
+
584
+ **Frontmatter is not included.** The copied and downloaded Markdown is the body only — no
585
+ YAML block, and therefore no `allow` or `deny` list. If the body does not already open
586
+ with an `# H1`, the page title is prepended, so a pasted document always has one. Line
587
+ endings are normalized to LF with exactly one trailing newline.
588
+
589
+ > **Clipboard access is blocked over plain HTTP.** Browsers expose `navigator.clipboard`
590
+ > only in a secure context — HTTPS, or `localhost` / `127.0.0.1`. Over a plain `http://`
591
+ > LAN or tailnet address it is simply absent, so mdshelf falls back to a legacy copy, and
592
+ > if that is refused too it falls back to manual selection: a **Select and copy** panel
593
+ > opens with the text already selected, so Ctrl/Cmd-C always works. Serving over HTTPS
594
+ > removes the fallback entirely.
595
+
596
+ A generated folder-index page has no `.md` file behind it, so both actions appear
597
+ disabled with the reason. Downloads normally happen entirely in the browser; iOS and
598
+ iPadOS Safari mishandle that, so there they go through `GET /__mdshelf/md/<page>`, which
599
+ enforces exactly the same access rules as viewing the page — a page you may not read
600
+ returns the same response as one that does not exist.
601
+
602
+ ### Exporting a static site
259
603
 
260
604
  ```bash
261
605
  mdshelf export # all sites → ./dist
@@ -23,7 +23,7 @@
23
23
  "hasInstallScript": true,
24
24
  "license": "MIT OR Apache-2.0",
25
25
  "name": "mdshelf",
26
- "version": "1.0.8"
26
+ "version": "1.1.1"
27
27
  },
28
28
  "node_modules/@isaacs/cliui": {
29
29
  "engines": {
@@ -542,5 +542,5 @@
542
542
  }
543
543
  },
544
544
  "requires": true,
545
- "version": "1.0.8"
545
+ "version": "1.1.1"
546
546
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "artifactDownloadUrls": [
3
- "https://github.com/prbski/mdshelf/releases/download/v1.0.8"
3
+ "https://github.com/prbski/mdshelf/releases/download/v1.1.1"
4
4
  ],
5
5
  "author": "Patryk Porebski",
6
6
  "bin": {
@@ -100,7 +100,7 @@
100
100
  "zipExt": ".tar.xz"
101
101
  }
102
102
  },
103
- "version": "1.0.8",
103
+ "version": "1.1.1",
104
104
  "volta": {
105
105
  "node": "18.14.1",
106
106
  "npm": "9.5.0"