domma-cms 0.49.2 → 0.50.0

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/CLAUDE.md CHANGED
@@ -156,6 +156,8 @@ Menus live in `config/menus/<slug>.json`. The slot → menu map lives in `config
156
156
 
157
157
  **Floating panel surface.** `float` also takes `radius`, `shadow` (`none|sm|md|lg|xl` → `--dm-shadow-*`), `accent` (preset key or hex → border tint + leading-edge stripe via `--dm-menu-accent`) and `opacity` (20-100 → `--dm-menu-panel-opacity`, blurred translucent surface). `floatToCss` exists twice - `server/services/menus.js` and `public/js/menu-decor.mjs` - and `tests/services/menus-float.test.js` asserts the copies agree.
158
158
 
159
+ **Menus render on TWO paths - test both.** `buildMenuNav`/`buildFloatChrome` (server) draw the `[menu]` shortcode and overlay panels; the **public navbar is built in the browser** by Domma from `window.__CMS_NAV__` and never touches that code, so anything added to the server markup is simply absent there. `applyFloat` in `menu-decor.mjs` is the only channel to the client path - it stamps `data-float-*` attributes and `menu-float.js` builds the chrome from them. Fixed in 0.49.1 after `collapsible`/`movable` shipped dead on a floating navbar. Navbar CSS also needs `#site-navbar`-level specificity: `#site-navbar.dm-nav-vertical .navbar-container` outranks any class-only rule.
160
+
159
161
  **Floating panel behaviour.** `float` also takes `collapsible`, `collapsed`, `movable`, `resizable` and `title`. Any of the first two or `movable` makes `buildMenuNav` draw a chrome bar (grip + title + hamburger) via `buildFloatChrome`; `public/js/menu-float.js` (loaded from `page.html`, and called by `site.js` for the floating navbar) wires collapse/drag/resize. **Visitor state beats config**: position, size and collapsed state live in the visitor's own `S` storage under `dm_menu_float_<slug>` and are restored over `float` on load; double-clicking the bar clears them. A panel only stops tracking its `anchor` once actually moved or resized (`unanchor()` converts right/bottom/transform pinning to left/top on first interaction), so untouched panels stay responsive. `floatBehaviour` is the third function duplicated across `menus.js` / `menu-decor.mjs`, parity-tested alongside `floatToCss`. Below 768px site.css returns panels to the flow, so only collapse applies there.
160
162
 
161
163
  ## Admin sidebar (menu-data-driven)
@@ -212,6 +214,11 @@ holds `compareValues`/`sortEntries` - the same comparison ladder backs both the
212
214
  than 32 characters. Set it in `.env`.
213
215
  10. **Page visibility enforcement**: `server/routes/public.js` checks `page.visibility` against the visitor's role
214
216
  level - unauthenticated visitors cannot access private or role-restricted pages.
217
+ **Draft preview**: `status: draft` still 404s the public, but a viewer holding `pages.read` gets the real page plus
218
+ a banner (`buildDraftBanner` in `renderer.js`, passed via `renderPage(page, {preview})`). Draft renders bypass the
219
+ response cache and carry `no-store` + `noindex` - never route one through `cache.wrap`. Browser navigations
220
+ authenticate via the `dm_session` cookie (`server/services/viewerSession.js`), which is read **only** by the public
221
+ page renderer; `authenticate()` stays Bearer-only, so no write route accepts ambient credentials.
215
222
  11. **404 page**: create `content/pages/404.md` to customise the not-found response; it is auto-served on missing routes.
216
223
 
217
224
  ## Docs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.49.2",
3
+ "version": "0.50.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -26,6 +26,7 @@ import {
26
26
  validatePassword
27
27
  } from '../../services/users.js';
28
28
  import {getProfile, updateProfile} from '../../services/userProfiles.js';
29
+ import {buildClearedSessionCookie, buildSessionCookie} from '../../services/viewerSession.js';
29
30
  import {createTransport, sendEmail} from '../../services/email.js';
30
31
 
31
32
  const { accessTokenExpiry, refreshTokenExpiry } = config.auth;
@@ -111,6 +112,7 @@ export async function authRoutes(fastify) {
111
112
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
112
113
  hooks.emit('user:loggedIn', {userId: user.id, email: user.email, role: user.role});
113
114
  const { token, refreshToken } = signTokens(fastify, safeUser);
115
+ setSessionCookie(request, reply, token);
114
116
  return { token, refreshToken, user: safeUser };
115
117
  });
116
118
 
@@ -238,9 +240,10 @@ export async function authRoutes(fastify) {
238
240
  });
239
241
 
240
242
  // POST /api/auth/logout - blacklists the refresh token (fire-and-forget safe: no auth required)
241
- fastify.post('/auth/logout', async (request) => {
243
+ fastify.post('/auth/logout', async (request, reply) => {
242
244
  const {refreshToken} = request.body || {};
243
245
  if (refreshToken) blacklistedRefreshTokens.add(refreshToken);
246
+ reply.header('set-cookie', buildClearedSessionCookie({secure: request.protocol === 'https'}));
244
247
  return {ok: true};
245
248
  });
246
249
 
@@ -273,10 +276,32 @@ export async function authRoutes(fastify) {
273
276
 
274
277
  const safeUser = { id: user.id, name: user.name, email: user.email, role: user.role, additionalRoles: user.additionalRoles || [], level: getRoleLevel(user.role) };
275
278
  const token = fastify.jwt.sign({ ...safeUser, type: 'access' }, { expiresIn: accessTokenExpiry });
279
+ setSessionCookie(request, reply, token);
276
280
  return { token };
277
281
  });
278
282
  }
279
283
 
284
+ /**
285
+ * Attach the access token to the response as a session cookie.
286
+ *
287
+ * The SPA already stores the token in `S` for its Bearer calls; the cookie
288
+ * exists purely so a *browser navigation* to a public page can be attributed
289
+ * to a signed-in user (draft previews, role-gated pages). It is read only by
290
+ * the public page renderer - never by an API guard - so it grants no ambient
291
+ * authority over any write route. See services/viewerSession.js.
292
+ *
293
+ * @param {import('fastify').FastifyRequest} request
294
+ * @param {import('fastify').FastifyReply} reply
295
+ * @param {string} token
296
+ * @returns {void}
297
+ */
298
+ function setSessionCookie(request, reply, token) {
299
+ reply.header('set-cookie', buildSessionCookie(token, {
300
+ secure: request.protocol === 'https',
301
+ maxAgeMs: parseDuration(accessTokenExpiry)
302
+ }));
303
+ }
304
+
280
305
  /**
281
306
  * Parse a duration string into milliseconds.
282
307
  * Supports "Nm" (minutes), "Nh" (hours), "Nd" (days).
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Public Site Routes
3
3
  * Catch-all that resolves URL paths to Markdown pages and renders them server-side.
4
- * Draft pages are not served publicly.
4
+ * Draft pages are not served publicly - but a signed-in user holding
5
+ * `pages.read` is shown the real page with a draft banner, so designers and
6
+ * developers can verify a page before it is released.
5
7
  * The admin panel is excluded (handled by static serving).
6
8
  */
7
9
  import {getPage, getPageMtime} from '../services/content.js';
@@ -9,6 +11,9 @@ import {getProjectForPage, isProjectEnabled} from '../services/projects.js';
9
11
  import {renderPage} from '../services/renderer.js';
10
12
  import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
11
13
  import {checkVisibility} from '../middleware/auth.js';
14
+ import {resolveViewer} from '../services/viewerSession.js';
15
+ import {getPermissionsFor} from '../services/roles.js';
16
+ import {getEffectiveRoles} from '../services/userRoles.js';
12
17
  import {hooks} from '../services/hooks.js';
13
18
  import {getConfig} from '../config.js';
14
19
  import * as cache from '../services/cache/index.js';
@@ -103,12 +108,6 @@ export async function publicRoutes(fastify) {
103
108
  return reply.type('text/html').send(await render404(urlPath));
104
109
  }
105
110
 
106
- // Don't serve draft pages publicly
107
- if (page.status !== 'published') {
108
- reply.status(404);
109
- return reply.type('text/html').send(await render404(urlPath));
110
- }
111
-
112
111
  // Hard kill-switch: a disabled project takes its pages off the public
113
112
  // site entirely - 404 (no oracle), regardless of page visibility.
114
113
  const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
@@ -117,14 +116,21 @@ export async function publicRoutes(fastify) {
117
116
  return reply.type('text/html').send(await render404(urlPath));
118
117
  }
119
118
 
119
+ // Draft preview: a page that is not published stays invisible to the
120
+ // public, but is served in full to a signed-in user who could read it
121
+ // in the admin anyway. Everyone else gets the same 404 as before - the
122
+ // response must not reveal that an unpublished page exists there.
123
+ const isDraft = page.status !== 'published';
124
+
120
125
  // Enforce page visibility - role only resolved for gated pages,
121
126
  // so public pages share a single cache entry keyed `roleanon`.
122
127
  //
123
128
  // `visibility` may be a string ('public' | 'private' | role name) or
124
129
  // an array of role names - see checkVisibility() for full semantics.
125
130
  // Effectively-public values (missing, 'public', or an array containing
126
- // 'public') skip JWT verification entirely so anonymous traffic hits
127
- // the shared cache without auth cost.
131
+ // 'public') skip viewer resolution entirely so anonymous traffic hits
132
+ // the shared cache without auth cost - unless the page is a draft,
133
+ // which needs the viewer regardless of its visibility.
128
134
  // For per-role cache keying we use the primary role only - multi-role
129
135
  // users still see correctly-gated content (checkVisibility consults
130
136
  // additionalRoles too) but the cache key stays bounded to one entry
@@ -139,45 +145,74 @@ export async function publicRoutes(fastify) {
139
145
  || vis === 'public'
140
146
  || (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
141
147
 
142
- if (!isPublic) {
143
- try {
144
- const decoded = await request.jwtVerify();
145
- if (decoded.type === 'access') {
146
- userRole = decoded.role;
147
- userObj = { role: decoded.role, additionalRoles: decoded.additionalRoles || [] };
148
- }
149
- } catch { /* no token - treat as unauthenticated */ }
150
-
151
- if (!checkVisibility(userObj, vis)) {
152
- reply.status(403);
153
- return reply.type('text/html').send(accessDeniedHtml(urlPath));
154
- }
148
+ // A draft always needs the viewer resolved, whatever its visibility.
149
+ if (isDraft || !isPublic) {
150
+ userObj = await resolveViewer(request);
151
+ userRole = userObj ? userObj.role : null;
152
+ }
153
+
154
+ if (isDraft && !canPreviewDrafts(userObj)) {
155
+ reply.status(404);
156
+ return reply.type('text/html').send(await render404(urlPath));
157
+ }
158
+
159
+ if (!isPublic && !checkVisibility(userObj, vis)) {
160
+ reply.status(403);
161
+ return reply.type('text/html').send(accessDeniedHtml(urlPath));
155
162
  }
156
163
 
157
164
  const baseUrl = getBaseUrl(request);
165
+
166
+ // Re-parse with user context so the body's [menu] shortcode sees the
167
+ // visitor's role for visibility filtering. The first getPage() above
168
+ // was anonymous because we hadn't resolved the viewer yet.
169
+ const render = async () => {
170
+ const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
171
+ return renderPage(pageForRole, {
172
+ baseUrl,
173
+ user: userObj,
174
+ ...(isDraft && {preview: {status: page.status || 'draft', urlPath: page.urlPath || urlPath, user: userObj}})
175
+ });
176
+ };
177
+
178
+ // A draft render is private to one viewer and changes on every editor
179
+ // save. It must never enter the shared response cache - an entry
180
+ // written here would be served to the next anonymous visitor under
181
+ // the same key - and must never be indexed.
182
+ if (isDraft) {
183
+ reply.header('cache-control', 'no-store, private');
184
+ reply.header('x-robots-tag', 'noindex, nofollow');
185
+ return reply.type('text/html').send(await render());
186
+ }
187
+
158
188
  // mtime in the key: any change to the backing file - editor save,
159
189
  // script, git pull - yields a fresh entry even if no invalidation
160
190
  // hook fired. Stale-mtime entries age out via TTL/LRU.
161
191
  const pageMtime = await getPageMtime(page.urlPath || urlPath);
162
192
  const cacheKey = `page:${urlPath}:m${pageMtime}:role${userRole ?? 'anon'}:o${baseUrl}`;
163
193
  const cacheTags = [`page:${urlPath}`, ...(page.cacheTags || []), 'nav', 'site'];
164
- const html = await cache.wrap(
165
- cacheKey,
166
- async () => {
167
- // Re-parse with user context so the body's [menu] shortcode
168
- // sees the visitor's role for visibility filtering. The first
169
- // getPage() above was anonymous because we hadn't decoded the
170
- // JWT yet; this second pass uses the resolved userObj.
171
- const pageForRole = await getPage(page.urlPath, {user: userObj}) || page;
172
- return renderPage(pageForRole, {baseUrl, user: userObj});
173
- },
174
- {tags: cacheTags}
175
- );
194
+ const html = await cache.wrap(cacheKey, render, {tags: cacheTags});
176
195
  hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
177
196
  return reply.type('text/html').send(html);
178
197
  });
179
198
  }
180
199
 
200
+ /**
201
+ * May this viewer see unpublished pages on the public site?
202
+ *
203
+ * Gated on `pages.read` - the same permission that opens the Pages list in the
204
+ * admin. Anyone who can already read the draft's source there loses nothing by
205
+ * seeing it rendered, and nobody else gains anything.
206
+ *
207
+ * @param {object|null} user - Resolved viewer (`{role, additionalRoles}`) or null
208
+ * @returns {boolean}
209
+ */
210
+ function canPreviewDrafts(user) {
211
+ if (!user) return false;
212
+ const allowed = getPermissionsFor('pages', 'read');
213
+ return getEffectiveRoles(user).some(role => allowed.includes(role));
214
+ }
215
+
181
216
  /**
182
217
  * Render a 404 response - tries content/pages/404.md first, falls back to
183
218
  * a minimal inline page so the site theme is applied when possible.
@@ -85,6 +85,9 @@ async function buildOverlayMenus(menuCtx, user) {
85
85
  * Falls back to `site.baseUrl` from config, then to no canonical at all.
86
86
  * @param {object|null} [opts.user] - Authenticated user (`{role, additionalRoles}`)
87
87
  * or null for anonymous. Used to filter menu items gated by `visibility`.
88
+ * @param {object} [opts.preview] - Set when rendering an unpublished page for a
89
+ * permitted viewer: `{status, urlPath, user}`. Draws the draft banner. Absent
90
+ * for every normal public render.
88
91
  * @returns {Promise<string>}
89
92
  */
90
93
  export async function renderPage(page, opts = {}) {
@@ -247,6 +250,10 @@ export async function renderPage(page, opts = {}) {
247
250
  headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
248
251
  headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
249
252
  bodyEndInject: [
253
+ // Draft banner - first, so it is the topmost fixed element and a
254
+ // floating overlay menu cannot bury the only affordance telling
255
+ // the viewer this page is not live.
256
+ opts.preview ? buildDraftBanner(opts.preview) : '',
250
257
  // Overlay menus - bound to the `overlay` slot and pinned by their own
251
258
  // float config, so they belong at the end of the body, clear of the
252
259
  // content flow.
@@ -428,6 +435,91 @@ function buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage})
428
435
  return tags.join('\n ');
429
436
  }
430
437
 
438
+ /**
439
+ * Build the draft banner shown over an unpublished page.
440
+ *
441
+ * Self-contained on purpose - its CSS and JS are inline rather than in
442
+ * public/css/site.css, because the updater replaces public/ wholesale and a
443
+ * site running a stale minified stylesheet would render the banner unstyled,
444
+ * i.e. as an invisible warning. It is also why nothing here depends on a
445
+ * Domma component: the banner has to work on a page whose CSS is broken,
446
+ * since verifying broken CSS is exactly what it is for.
447
+ *
448
+ * The Publish button authenticates with the Bearer token from `S` storage,
449
+ * never with the session cookie - keeping every state-changing call
450
+ * explicitly credentialled and the cookie free of write authority.
451
+ *
452
+ * @param {{status?: string, urlPath?: string, user?: object|null}} preview
453
+ * @returns {string}
454
+ */
455
+ function buildDraftBanner(preview) {
456
+ const urlPath = preview.urlPath || '/';
457
+ const status = preview.status || 'draft';
458
+ const user = preview.user || {};
459
+ const who = escapeHtml(user.name || user.email || 'an authorised user');
460
+ const role = escapeHtml(user.role || '');
461
+ const editHref = '/admin/#/pages/edit' + escapeHtml(urlPath);
462
+ const pathAttr = escapeHtml(urlPath);
463
+ const label = escapeHtml(status.charAt(0).toUpperCase() + status.slice(1));
464
+
465
+ return `<div id="dm-draft-banner" role="status" aria-live="polite" data-url-path="${pathAttr}">
466
+ <style>
467
+ #dm-draft-banner{position:fixed;left:0;right:0;bottom:0;z-index:2147483000;display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;
468
+ padding:.6rem .9rem;font:500 .8125rem/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;color:#1c1601;
469
+ background:repeating-linear-gradient(135deg,#ffcf33,#ffcf33 14px,#f5b800 14px,#f5b800 28px);box-shadow:0 -2px 12px rgba(0,0,0,.28)}
470
+ #dm-draft-banner[hidden]{display:none}
471
+ #dm-draft-banner .dmdb-pill{flex:none;padding:.2rem .55rem;border-radius:999px;background:#1c1601;color:#ffcf33;
472
+ font-weight:700;font-size:.6875rem;letter-spacing:.08em;text-transform:uppercase}
473
+ #dm-draft-banner .dmdb-text{flex:1 1 16rem;min-width:0}
474
+ #dm-draft-banner .dmdb-actions{flex:none;display:flex;align-items:center;gap:.4rem}
475
+ #dm-draft-banner button,#dm-draft-banner a.dmdb-btn{font:inherit;cursor:pointer;border:1px solid rgba(28,22,1,.45);
476
+ border-radius:.3rem;padding:.3rem .7rem;background:rgba(255,255,255,.55);color:#1c1601;text-decoration:none}
477
+ #dm-draft-banner button:hover,#dm-draft-banner a.dmdb-btn:hover{background:#fff}
478
+ #dm-draft-banner button[disabled]{opacity:.55;cursor:default}
479
+ #dm-draft-banner .dmdb-publish{background:#1c1601;color:#ffcf33;border-color:#1c1601}
480
+ #dm-draft-banner .dmdb-publish:hover{background:#000;color:#ffe27a}
481
+ #dm-draft-banner .dmdb-close{padding:.2rem .5rem;line-height:1;font-size:1rem;background:transparent;border-color:transparent}
482
+ @media print{#dm-draft-banner{display:none}}
483
+ </style>
484
+ <span class="dmdb-pill">${label}</span>
485
+ <span class="dmdb-text">Not published - the public gets a 404 here. You can see it because you are signed in as <strong>${who}</strong>${role ? ` (${role})` : ''}.</span>
486
+ <span class="dmdb-actions">
487
+ <a class="dmdb-btn" href="${editHref}">Edit page</a>
488
+ <button type="button" class="dmdb-publish" data-dmdb-publish>Publish</button>
489
+ <button type="button" class="dmdb-close" data-dmdb-close aria-label="Hide draft banner" title="Hide">&times;</button>
490
+ </span>
491
+ <script>
492
+ (function(){
493
+ var bar = document.getElementById('dm-draft-banner');
494
+ if (!bar) return;
495
+ var path = bar.getAttribute('data-url-path') || '/';
496
+ bar.querySelector('[data-dmdb-close]').addEventListener('click', function(){ bar.hidden = true; });
497
+ bar.querySelector('[data-dmdb-publish]').addEventListener('click', function(){
498
+ var btn = this;
499
+ var token = '';
500
+ try { token = (window.S && S.get('auth_token')) || ''; } catch (e) { token = ''; }
501
+ if (!token) { window.location.href = '/admin/'; return; }
502
+ if (!window.confirm('Publish this page? It becomes visible to everyone.')) return;
503
+ btn.disabled = true;
504
+ btn.textContent = 'Publishing…';
505
+ fetch('/api/pages' + path, {
506
+ method: 'PUT',
507
+ headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token},
508
+ body: JSON.stringify({frontmatter: {status: 'published'}})
509
+ }).then(function(res){
510
+ if (!res.ok) throw new Error('HTTP ' + res.status);
511
+ window.location.reload();
512
+ }).catch(function(){
513
+ btn.disabled = false;
514
+ btn.textContent = 'Publish';
515
+ window.alert('Could not publish. Try again from the admin editor.');
516
+ });
517
+ });
518
+ })();
519
+ </script>
520
+ </div>`;
521
+ }
522
+
431
523
  function escapeHtml(str) {
432
524
  return String(str)
433
525
  .replace(/&/g, '&amp;')
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Viewer Session
3
+ *
4
+ * Resolves the human behind a *public page navigation*.
5
+ *
6
+ * The API authenticates with `Authorization: Bearer <jwt>` and the client keeps
7
+ * that token in `S` storage - fine for `fetch`, useless for a browser typing a
8
+ * URL into the address bar, which sends no such header. Draft previews and
9
+ * role-gated pages both need to know who is looking during a plain navigation,
10
+ * so login additionally drops the access token into a session cookie and this
11
+ * module reads it back.
12
+ *
13
+ * SCOPE - read this before reusing anything here:
14
+ * The cookie is consulted ONLY by the public page renderer (GET, no side
15
+ * effects). `authenticate()` in middleware/auth.js stays Bearer-only, so no
16
+ * state-changing route is reachable with ambient credentials and the CSRF
17
+ * surface stays at zero. Do not wire `resolveViewer()` into a write path.
18
+ */
19
+
20
+ /** Name of the session cookie carrying the access token. */
21
+ export const SESSION_COOKIE = 'dm_session';
22
+
23
+ /**
24
+ * Parse a Cookie request header into a plain object.
25
+ *
26
+ * @param {string} [header]
27
+ * @returns {Record<string, string>}
28
+ */
29
+ function parseCookies(header) {
30
+ const out = {};
31
+ if (!header) return out;
32
+ for (const part of header.split(';')) {
33
+ const eq = part.indexOf('=');
34
+ if (eq < 0) continue;
35
+ const key = part.slice(0, eq).trim();
36
+ if (!key) continue;
37
+ try {
38
+ out[key] = decodeURIComponent(part.slice(eq + 1).trim());
39
+ } catch {
40
+ out[key] = part.slice(eq + 1).trim();
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Build the Set-Cookie value that carries an access token for page navigation.
48
+ *
49
+ * HttpOnly because nothing client-side needs to read it (the SPA keeps its own
50
+ * copy in `S`), SameSite=Lax so a link from an email or chat still arrives
51
+ * authenticated, and Secure only over https so localhost development works.
52
+ *
53
+ * @param {string} token - Signed access token
54
+ * @param {object} [opts]
55
+ * @param {boolean} [opts.secure] - Emit the Secure attribute
56
+ * @param {number} [opts.maxAgeMs]- Cookie lifetime; defaults to one hour
57
+ * @returns {string}
58
+ */
59
+ export function buildSessionCookie(token, {secure = false, maxAgeMs = 3_600_000} = {}) {
60
+ const attrs = [
61
+ `${SESSION_COOKIE}=${encodeURIComponent(token)}`,
62
+ 'Path=/',
63
+ 'HttpOnly',
64
+ 'SameSite=Lax',
65
+ `Max-Age=${Math.max(0, Math.floor(maxAgeMs / 1000))}`
66
+ ];
67
+ if (secure) attrs.push('Secure');
68
+ return attrs.join('; ');
69
+ }
70
+
71
+ /**
72
+ * Build the Set-Cookie value that removes the session cookie.
73
+ *
74
+ * @param {object} [opts]
75
+ * @param {boolean} [opts.secure]
76
+ * @returns {string}
77
+ */
78
+ export function buildClearedSessionCookie({secure = false} = {}) {
79
+ const attrs = [`${SESSION_COOKIE}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
80
+ if (secure) attrs.push('Secure');
81
+ return attrs.join('; ');
82
+ }
83
+
84
+ /**
85
+ * Read the raw session token from the request cookie, if present.
86
+ *
87
+ * @param {import('fastify').FastifyRequest} request
88
+ * @returns {string} '' when absent
89
+ */
90
+ export function readSessionToken(request) {
91
+ return parseCookies(request.headers?.cookie)[SESSION_COOKIE] || '';
92
+ }
93
+
94
+ /**
95
+ * Resolve the viewer of a public page from either credential channel.
96
+ *
97
+ * Tries the Bearer header first (so API-style and test clients keep working
98
+ * unchanged), then the session cookie. Anything that is not a valid, unexpired
99
+ * *access* token is ignored - a refresh token presented here resolves to
100
+ * anonymous rather than to its subject.
101
+ *
102
+ * @param {import('fastify').FastifyRequest} request
103
+ * @returns {Promise<{id?: string, name: string, email: string, role: string, additionalRoles: string[]}|null>}
104
+ */
105
+ export async function resolveViewer(request) {
106
+ const header = request.headers?.authorization || '';
107
+ const bearer = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
108
+ const jwt = request.server?.jwt;
109
+ if (!jwt) return null;
110
+
111
+ for (const raw of [bearer, readSessionToken(request)]) {
112
+ if (!raw) continue;
113
+ try {
114
+ const decoded = jwt.verify(raw);
115
+ if (decoded?.type !== 'access' || !decoded.role) continue;
116
+ return {
117
+ id: decoded.id,
118
+ name: decoded.name || '',
119
+ email: decoded.email || '',
120
+ role: decoded.role,
121
+ additionalRoles: Array.isArray(decoded.additionalRoles) ? decoded.additionalRoles : []
122
+ };
123
+ } catch {
124
+ // Malformed or expired - fall through to the next channel.
125
+ }
126
+ }
127
+ return null;
128
+ }