@acms-subir/core 0.3.23 → 0.3.25

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.25
4
+
5
+ ### Patch Changes
6
+
7
+ - c6a9129: With Clerk sign-in, saving in the full-screen editors no longer fails a minute after the page loads. Clerk's sign-in cookie lasts a minute, and the post, page and event editors never renewed it, so a change sent after that arrived signed out. Saving an event's hosts sent you through sign-in and dropped the change without a message, and autosave could show "Not saved — Not signed in." The editors now keep the sign-in fresh in the background like the other admin screens, and renew it right before saving hosts. If a save is still turned away because the sign-in lapsed, autosave renews it and tries once more, and a form brings you back to the same page with a note that the change wasn't saved, rather than to the dashboard.
8
+
9
+ ## 0.3.24
10
+
11
+ ### Patch Changes
12
+
13
+ - fdab131: `acms check --build`, `acms theme check --build` and `acms theme dev` no longer break a running `npm run dev`. The isolated test project links the project's `node_modules`, so it used to share Vite's dependency cache (`node_modules/.vite`) and Astro's build cache (`node_modules/.astro`) with the dev server. Pre-bundling in the test project replaced the files the dev server was serving, and admin pages went blank with 504 "Outdated Optimize Dep" errors until the dev server restarted. The test project now keeps both caches in its own temporary folder under `.acms/previews/`.
14
+
3
15
  ## 0.3.23
4
16
 
5
17
  ### Patch Changes
package/cli/preview.mjs CHANGED
@@ -15,7 +15,9 @@ export async function prepareFixture(p,dir,slug){
15
15
  await mkdir(join(root,'src/pages/acms-theme-preview'),{recursive:true});await mkdir(join(root,'src/actions'),{recursive:true});
16
16
  await writeFile(join(root,'src/actions/index.ts'),"export { server } from '@acms-subir/core/actions';\n");
17
17
  await writeFile(join(root,'package.json'),JSON.stringify({name:'acms-isolated-preview',type:'module',private:true}));
18
- await writeFile(join(root,'astro.config.mjs'),`import {defineConfig,passthroughImageService} from 'astro/config';\nimport react from '@astrojs/react';\nimport node from '@astrojs/node';\nimport cloudflare from '@astrojs/cloudflare';\nimport acms from '@acms-subir/core';\nexport default defineConfig({output:'server',vite:{resolve:{preserveSymlinks:true}},integrations:[react(),acms({seed:{siteTitle:'Theme preview — synthetic content'}})],adapter:process.env.DEPLOY_TARGET==='cloudflare'?cloudflare({platformProxy:{enabled:false},imageService:'passthrough'}):node({mode:'standalone'}),image:{service:passthroughImageService()}});\n`);
18
+ // node_modules is the project's own, so the default caches (node_modules/.vite, node_modules/.astro) would be too:
19
+ // the fixture's dependency pre-bundling would replace the files a running `npm run dev` serves. Keep them in the fixture.
20
+ await writeFile(join(root,'astro.config.mjs'),`import {defineConfig,passthroughImageService} from 'astro/config';\nimport react from '@astrojs/react';\nimport node from '@astrojs/node';\nimport cloudflare from '@astrojs/cloudflare';\nimport acms from '@acms-subir/core';\nexport default defineConfig({output:'server',cacheDir:${JSON.stringify(join(root,'.astro-cache'))},vite:{cacheDir:${JSON.stringify(join(root,'.vite'))},resolve:{preserveSymlinks:true}},integrations:[react(),acms({seed:{siteTitle:'Theme preview — synthetic content'}})],adapter:process.env.DEPLOY_TARGET==='cloudflare'?cloudflare({platformProxy:{enabled:false},imageService:'passthrough'}):node({mode:'standalone'}),image:{service:passthroughImageService()}});\n`);
19
21
  await writeFile(join(root,'src/pages/acms-theme-preview/setup.astro'),`---
20
22
  import {getRepo} from ${JSON.stringify(join(CORE,'src/lib/repo/index.ts'))};
21
23
  const repo=await getRepo(Astro.locals);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.23",
3
+ "version": "0.3.25",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -29,6 +29,22 @@ if (AUTH.provider === 'clerk') {
29
29
 
30
30
  const meta = document.querySelector<HTMLMetaElement>('meta[name="acms-clerk"][data-keep-alive]');
31
31
  if (meta) {
32
+ // a form that reached the server after the cookie lapsed comes back here
33
+ // (middleware.ts expiredPostReturn): say so, once, instead of losing it quietly
34
+ const here = new URL(location.href);
35
+ if (here.searchParams.get('signin') === 'expired') {
36
+ here.searchParams.delete('signin');
37
+ history.replaceState(history.state, '', here);
38
+ const note = document.createElement('p');
39
+ note.className = 'flash error';
40
+ note.setAttribute('role', 'alert');
41
+ note.title = 'Dismiss';
42
+ note.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:1000;width:max-content;max-width:calc(100vw - 24px);margin:0;cursor:pointer;box-shadow:0 6px 24px rgb(0 0 0 / 14%)';
43
+ note.textContent = 'Your sign-in had expired, so that change wasn’t saved. Please make it again.';
44
+ note.addEventListener('click', () => note.remove());
45
+ document.body.append(note);
46
+ }
47
+
32
48
  const signedOutUrl = meta.dataset.signedOutUrl ?? '/admin/login';
33
49
  const start = () => loadClerk().then((clerk) => {
34
50
  // ended elsewhere (signed out in another tab, revoked by an owner):
@@ -36,6 +36,7 @@ import { EventSheet } from './event/EventSheet';
36
36
  import { focusEventCard } from './event/Section';
37
37
  import type { MapsStatus } from '../../lib/maps/types';
38
38
  import { fetchRoomStatus, NO_HOLD, RoomConflictDialog } from '../calendar/RoomHolds';
39
+ import { refreshSession } from '../auth/clerk';
39
40
 
40
41
  // The Ghost-style writing surface, shared by posts AND pages: distraction-free
41
42
  // column, cards via "/", autosave, a collapsible settings sidebar, and an
@@ -47,6 +48,16 @@ import { fetchRoomStatus, NO_HOLD, RoomConflictDialog } from '../calendar/RoomHo
47
48
 
48
49
  type SaveState = 'saved' | 'dirty' | 'saving' | 'error';
49
50
 
51
+ // Clerk's sign-in cookie lasts a minute and is renewed in the background
52
+ // (ClerkSession on the editor page). A write that still lands after it lapsed
53
+ // (a laptop waking up before the renewal ran) renews it and goes once more.
54
+ async function signedIn<T extends { error?: { code: string } }>(send: () => Promise<T>): Promise<T> {
55
+ const r = await send();
56
+ if (r.error?.code !== 'UNAUTHORIZED') return r;
57
+ await refreshSession();
58
+ return send();
59
+ }
60
+
50
61
  interface Props {
51
62
  kind?: 'post' | 'page' | 'event';
52
63
  post?: Post;
@@ -395,7 +406,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
395
406
  const bodyHtml = langRef.current && mainBody.current ? mainBody.current.html : ed.getHTML();
396
407
  const translations = languages.length ? trsRef.current : undefined;
397
408
  if (kind === 'event') {
398
- const r = await actions.events.save({
409
+ const r = await signedIn(() => actions.events.save({
399
410
  id: docId,
400
411
  title: m.title,
401
412
  slug: m.slug,
@@ -425,7 +436,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
425
436
  roomHold: m.roomHold,
426
437
  scheduledAt: m.scheduledAt,
427
438
  translations,
428
- });
439
+ }));
429
440
  if (r.error) {
430
441
  setSave('error');
431
442
  setSaveError(r.error.message);
@@ -455,7 +466,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
455
466
  return next;
456
467
  });
457
468
  } else if (kind === 'page') {
458
- const r = await actions.pages.save({
469
+ const r = await signedIn(() => actions.pages.save({
459
470
  id: docId,
460
471
  title: m.title,
461
472
  slug: m.slug,
@@ -468,7 +479,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
468
479
  isHome: m.isHome,
469
480
  locale: m.locale,
470
481
  translations,
471
- });
482
+ }));
472
483
  if (r.error) {
473
484
  setSave('error');
474
485
  return;
@@ -483,7 +494,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
483
494
  return next;
484
495
  });
485
496
  } else {
486
- const r = await actions.posts.save({
497
+ const r = await signedIn(() => actions.posts.save({
487
498
  id: docId,
488
499
  title: m.title,
489
500
  slug: m.slug,
@@ -503,7 +514,7 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
503
514
  publishedAt: m.publishedAt,
504
515
  scheduledAt: m.scheduledAt,
505
516
  translations,
506
- });
517
+ }));
507
518
  if (r.error) {
508
519
  setSave('error');
509
520
  return;
@@ -808,10 +819,10 @@ export default function PostEditor({ kind = 'post', post, page, event, calendars
808
819
  if (saveTimer.current) { clearTimeout(saveTimer.current); saveTimer.current = null; }
809
820
  setSave('saved');
810
821
  const r = kind === 'page'
811
- ? await actions.pages.discardChanges({ id: docId })
822
+ ? await signedIn(() => actions.pages.discardChanges({ id: docId }))
812
823
  : kind === 'event'
813
- ? await actions.events.discardChanges({ id: docId })
814
- : await actions.posts.discardChanges({ id: docId });
824
+ ? await signedIn(() => actions.events.discardChanges({ id: docId }))
825
+ : await signedIn(() => actions.posts.discardChanges({ id: docId }));
815
826
  if (r.error) {
816
827
  setSave('error');
817
828
  return false;
@@ -16,6 +16,8 @@ export interface RequestAuth {
16
16
  headers: Headers;
17
17
  error: 'configuration' | 'unavailable' | 'access_denied' | null;
18
18
  reverified: boolean;
19
+ /** a Clerk session cookie that lapsed: Clerk renews it on a page GET, never on a POST */
20
+ expired: boolean;
19
21
  }
20
22
  const requests = new WeakMap<Request, Promise<RequestAuth>>();
21
23
  const environments = new WeakMap<Request, unknown>();
@@ -81,7 +83,7 @@ export async function activeClerkSession(client: ClerkClient, binding: ClerkBind
81
83
  return active;
82
84
  }
83
85
  export function getRequestAuth(request: Request, cookies: Cookies, localsOrRepo?: unknown): Promise<RequestAuth> {
84
- if (AUTH.provider === 'builtin') return validatePerson(cookies.get(SESSION_COOKIE)?.value, localsOrRepo).then(person => ({ userId: person && isStaffRole(person.role) ? person.id : null, person, provider: 'builtin', clerk: null, headers: new Headers(), error: null, reverified: false }));
86
+ if (AUTH.provider === 'builtin') return validatePerson(cookies.get(SESSION_COOKIE)?.value, localsOrRepo).then(person => ({ userId: person && isStaffRole(person.role) ? person.id : null, person, provider: 'builtin', clerk: null, headers: new Headers(), error: null, reverified: false, expired: false }));
85
87
  let cached = requests.get(request);
86
88
  if (!cached) {
87
89
  if (!(localsOrRepo && typeof localsOrRepo === 'object' && 'email' in localsOrRepo)) environments.set(request, localsOrRepo);
@@ -96,7 +98,7 @@ export async function validatePreviewSession(cookies: Cookies, locals?: unknown)
96
98
  return locals && typeof locals === 'object' ? (await contexts.get(locals))?.userId ?? null : null;
97
99
  }
98
100
  async function resolveClerk(request: Request, localsOrRepo?: unknown): Promise<RequestAuth> {
99
- const result: RequestAuth = { userId: null, person: null, provider: 'clerk', clerk: null, headers: new Headers(), error: null, reverified: false };
101
+ const result: RequestAuth = { userId: null, person: null, provider: 'clerk', clerk: null, headers: new Headers(), error: null, reverified: false, expired: false };
100
102
  if (!shouldResolveClerk(request)) return result;
101
103
  let config: ReturnType<typeof clerkConfig>;
102
104
  try { config = clerkConfig(authEnvironment(request, localsOrRepo)); } catch { return { ...result, error: 'configuration' }; }
@@ -112,6 +114,7 @@ async function resolveClerk(request: Request, localsOrRepo?: unknown): Promise<R
112
114
  if (authUrl.pathname.startsWith('/api/')) authUrl.search = '';
113
115
  const state = await clerkOperation(() => client.authenticateRequest(new Request(authUrl, { method: request.method, headers }), { authorizedParties: config.authorizedParties, acceptsToken: 'session_token' }));
114
116
  result.headers = state.headers;
117
+ result.expired = !state.isAuthenticated && String(state.reason ?? '').startsWith('session-token-expired');
115
118
  const auth = state.toAuth();
116
119
  if (!auth || !auth.userId || !auth.sessionId || !state.isAuthenticated) return result;
117
120
  if (auth.sessionClaims.iss !== config.issuer || typeof auth.sessionClaims.azp !== 'string' || !config.authorizedParties.includes(auth.sessionClaims.azp)) return result;
package/src/middleware.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { defineMiddleware } from 'astro:middleware';
2
2
  import type { APIContext, MiddlewareNext } from 'astro';
3
3
  import { I18N } from './lib/i18n';
4
- import { bindAuthEnvironment, getRequestAuth, requestPerson, shouldResolveClerk, validateRequestSession, withClerkHeaders } from './lib/auth/request';
4
+ import { bindAuthEnvironment, getRequestAuth, requestPerson, shouldResolveClerk, validateRequestSession, withClerkHeaders, type RequestAuth } from './lib/auth/request';
5
5
  import { roleHome } from './lib/roles';
6
6
  import { AUTH } from './lib/auth/config';
7
7
  import { getRepo } from './lib/repo';
@@ -47,6 +47,18 @@ export function legacyContentRedirect(pathname: string): string | null {
47
47
  return null;
48
48
  }
49
49
 
50
+ // An admin form sent after the minute-long Clerk sign-in cookie lapsed (a page
51
+ // whose script couldn't renew it in time): Clerk renews it only on a page GET,
52
+ // so the POST arrives signed out and what it carried is gone. Back to the same
53
+ // page, whose GET renews the cookie, flagged so the page can say nothing was
54
+ // saved (ClerkSession.astro), rather than through sign-in to the dashboard.
55
+ export function expiredPostReturn(auth: Pick<RequestAuth, 'expired'> | null, method: string, url: URL): string | null {
56
+ if (!auth?.expired || method !== 'POST') return null;
57
+ const back = new URL(url.pathname + url.search, url.origin);
58
+ back.searchParams.set('signin', 'expired');
59
+ return back.pathname + back.search;
60
+ }
61
+
50
62
  // Scheduled publishing fallback: on normal page traffic, at most once a
51
63
  // minute per isolate, flip due scheduled posts to published. On Workers the
52
64
  // cron trigger in src/worker.ts is the primary path; this sweep covers the
@@ -110,6 +122,8 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
110
122
  if (pathname.startsWith('/admin') && pathname !== '/admin/login' && pathname !== '/admin/logout') {
111
123
  if (auth?.error === 'configuration' || auth?.error === 'unavailable') return new Response('Sign-in is temporarily unavailable. Check the authentication configuration or try again later.', { status: 503, headers: { 'Cache-Control': 'no-store' } });
112
124
  if (!(await validateRequestSession(context.request, context.cookies, context.locals))) {
125
+ const expired = expiredPostReturn(auth, context.request.method, context.url);
126
+ if (expired) return withClerkHeaders(context.redirect(expired, 303), auth, context.request);
113
127
  // signed in, but in a site role without admin access: their own home
114
128
  // page, never the admin (the editor-app sign-in explains on the login page)
115
129
  const person = await requestPerson(context.request, context.cookies, context.locals);
@@ -10,6 +10,7 @@ import { BRAND } from '../../../../lib/brand';
10
10
  import { CONTENT, collectionUrl } from '../../../../lib/content';
11
11
  import { mergePostDraft } from '../../../../lib/drafts';
12
12
  import ThemeScript from '../../../../components/ThemeScript.astro';
13
+ import ClerkSession from '../../../../components/auth/ClerkSession.astro';
13
14
  import PostEditor from '../../../../components/editor/PostEditor';
14
15
  import '../../../../styles/admin.css';
15
16
  import '../../../../styles/editor.css';
@@ -51,6 +52,7 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
51
52
  <meta charset="utf-8" />
52
53
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual" />
53
54
  <ThemeScript />
55
+ <ClerkSession keepAlive />
54
56
  <meta name="robots" content="noindex" />
55
57
  <title>{post.title || 'Untitled'} · {BRAND.name} editor</title>
56
58
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -15,6 +15,7 @@ import type { Permission } from '../../../../lib/permissions/catalog';
15
15
  import { ApiError } from '../../../../lib/api';
16
16
  import { roleLabel } from '../../../../lib/roles';
17
17
  import ThemeScript from '../../../../components/ThemeScript.astro';
18
+ import ClerkSession from '../../../../components/auth/ClerkSession.astro';
18
19
  import Modal from '../../../../components/Modal.astro';
19
20
  import PostEditor from '../../../../components/editor/PostEditor';
20
21
  import '../../../../styles/admin.css';
@@ -108,6 +109,7 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
108
109
  <meta charset="utf-8" />
109
110
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual" />
110
111
  <ThemeScript />
112
+ <ClerkSession keepAlive />
111
113
  <meta name="robots" content="noindex" />
112
114
  <title>{event.title || 'Untitled event'} · {BRAND.name} editor</title>
113
115
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -230,6 +232,8 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
230
232
  )}
231
233
 
232
234
  <script>
235
+ import { refreshSession } from '../../../../components/auth/clerk';
236
+
233
237
  const bar = document.getElementById('evreq');
234
238
  // the editor's settings sidebar is fixed under its own top bar: move it below this bar too
235
239
  if (bar) new ResizeObserver(() => document.body.style.setProperty('--evreq-h', `${bar.offsetHeight}px`)).observe(bar);
@@ -242,7 +246,9 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
242
246
  history.replaceState(history.state, '', url);
243
247
  }
244
248
 
245
- // let autosave finish before leaving, then skip the editor's leave prompt
249
+ // let autosave finish before leaving, then skip the editor's leave prompt.
250
+ // A straight submit renews the sign-in cookie first (ClerkSession); after
251
+ // waiting, form.submit() skips that listener, so renew it here.
246
252
  const busy = () => document.querySelector('.pe-savestate.dirty, .pe-savestate.saving');
247
253
  const w = window as unknown as { __evreqLeaving?: boolean };
248
254
  document.addEventListener('submit', async (e) => {
@@ -253,6 +259,7 @@ const approveLabel = pending?.kind === 'changes' ? 'Approve changes' : isLive ?
253
259
  e.preventDefault();
254
260
  for (let i = 0; i < 50 && busy(); i++) await new Promise((r) => setTimeout(r, 120));
255
261
  if (busy()) { alert('Your latest edits haven’t saved yet. Check the editor, then try again.'); return; }
262
+ await refreshSession();
256
263
  w.__evreqLeaving = true;
257
264
  form.submit();
258
265
  });
@@ -11,6 +11,7 @@ import { mergePageDraft } from '../../../lib/drafts';
11
11
  import { localeHomeUrl } from '../../../lib/i18n';
12
12
  import { homeSource } from '../../../lib/home-source';
13
13
  import ThemeScript from '../../../components/ThemeScript.astro';
14
+ import ClerkSession from '../../../components/auth/ClerkSession.astro';
14
15
  import PostEditor from '../../../components/editor/PostEditor';
15
16
  import '../../../styles/admin.css';
16
17
  import '../../../styles/editor.css';
@@ -47,6 +48,7 @@ const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSett
47
48
  <meta charset="utf-8" />
48
49
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-visual" />
49
50
  <ThemeScript />
51
+ <ClerkSession keepAlive />
50
52
  <meta name="robots" content="noindex" />
51
53
  <title>{page.title || 'Untitled'} · {BRAND.name} editor</title>
52
54
  <link rel="preconnect" href="https://fonts.googleapis.com" />