@acms-subir/core 0.3.48 → 0.3.50

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.
@@ -0,0 +1,54 @@
1
+ import { userConfig } from 'virtual:acms-config';
2
+
3
+ // Astro's CSRF origin check (security.checkOrigin), taken over by core.
4
+ // Astro's own copy runs before any middleware and no route can opt out of it,
5
+ // so requests that never come from a browser on another site were refused
6
+ // too: Amazon SNS posts SES bounces as text/plain with no Origin header, and
7
+ // the apps send none either. integration.mjs turns Astro's copy off and the
8
+ // middleware runs this one first instead, in dev as well as on built sites.
9
+ //
10
+ // The rule is Astro 5's for anything a page on another site can send: a POST
11
+ // whose Origin isn't this site's (or that has none) is refused when its body
12
+ // is form-like (form-urlencoded, multipart, text/plain) or it has no
13
+ // Content-Type. JSON and other types need a CORS preflight and pass. What
14
+ // passes besides: the routes below, and requests a browser only sends to
15
+ // another site after a preflight (preflighted()).
16
+
17
+ const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
18
+ const FORM_TYPES = ['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'];
19
+
20
+ // Routes whose senders are servers that send no Origin and prove who they are
21
+ // with a signature the route checks itself (lib/email/webhooks.ts), keyed by
22
+ // route pattern as Astro matched it, so a lookalike path never qualifies. A
23
+ // site adds its own with acms({ signedRoutes }).
24
+ export const SIGNED_ROUTES = new Set([
25
+ '/api/email/webhooks/[provider]',
26
+ ...(userConfig.originCheck?.signedRoutes ?? []),
27
+ ]);
28
+
29
+ // The editor apps' OAuth endpoints (RFC 6749): form posts that carry their
30
+ // own code, refresh token or token to revoke, and never use cookies.
31
+ export const TOKEN_ROUTES = new Set(['/api/v1/auth/token', '/api/v1/auth/revoke']);
32
+
33
+ // A request no browser sends to another site without asking it first (a CORS
34
+ // preflight; ACMS never allows credentials in one, so no cookie rides along):
35
+ // any method but POST, or one carrying an app's own credential, a bearer
36
+ // token or a booking token. Basic auth doesn't count: a browser adds that to
37
+ // a form post by itself.
38
+ export function preflighted(request: Request): boolean {
39
+ return request.method !== 'POST' || /^bearer\s/i.test(request.headers.get('authorization') ?? '') || request.headers.has('booking-token');
40
+ }
41
+
42
+ // a site that set security.checkOrigin: false itself keeps the check off
43
+ export const ORIGIN_CHECK = { enabled: userConfig.originCheck?.enabled !== false };
44
+
45
+ type CheckedContext = { request: Request; url: URL; routePattern: string; isPrerendered: boolean };
46
+
47
+ /** Astro's 403 for a cross-site form submission, or null when the request may go on. */
48
+ export function crossSiteRefusal({ request, url, routePattern, isPrerendered }: CheckedContext): Response | null {
49
+ if (!ORIGIN_CHECK.enabled || isPrerendered || SAFE_METHODS.includes(request.method)) return null;
50
+ if (request.headers.get('origin') === url.origin || SIGNED_ROUTES.has(routePattern) || TOKEN_ROUTES.has(routePattern) || preflighted(request)) return null;
51
+ const type = request.headers.get('content-type');
52
+ if (type !== null && !FORM_TYPES.some((form) => type.toLowerCase().includes(form))) return null;
53
+ return new Response(`Cross-site ${request.method} form submissions are forbidden`, { status: 403 });
54
+ }
package/src/middleware.ts CHANGED
@@ -15,6 +15,7 @@ import { processPushQueue, runPushJobs } from './lib/push/service';
15
15
  import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
16
16
  import { applyCachePolicy } from './lib/cache/policy';
17
17
  import { bindExecutionContext, syncCachePurge } from './lib/cache/purge';
18
+ import { crossSiteRefusal } from './lib/origin-check';
18
19
 
19
20
  // Legacy content-URL redirects, checked only on would-be 404s (below) so they
20
21
  // can never shadow real content. Tags moved from `${base}/tag/` to a
@@ -81,6 +82,10 @@ export const onRequest = defineMiddleware(async (context, next) =>
81
82
 
82
83
  async function handle(context: APIContext, next: MiddlewareNext): Promise<Response> {
83
84
  const { pathname } = context.url;
85
+ // cross-site form submissions, before anything else runs (Astro's own
86
+ // check, which signed webhooks and the apps couldn't pass: lib/origin-check.ts)
87
+ const refused = crossSiteRefusal(context);
88
+ if (refused) return refused;
84
89
  bindAuthEnvironment(context.request, context.locals);
85
90
  bindExecutionContext(context.request, context.locals);
86
91
 
@@ -0,0 +1,30 @@
1
+ ---
2
+ import DisplayPage from '../../components/display/DisplayPage.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { CALENDAR } from '../../lib/calendar/config';
5
+ import { I18N, localePath, requestLocale } from '../../lib/i18n';
6
+ import { siteOrigin } from '../../lib/seo';
7
+ import { siteLang } from '../../lib/dates';
8
+ import { readDisplaySettings } from '../../lib/display/settings';
9
+ import { loadDisplayBoard } from '../../lib/display/board';
10
+ import { displayHeaders } from '../../lib/display/http';
11
+
12
+ // A TV display from a quick link: everything about it is in the address
13
+ // (/display/calendar?view=week&hours=8-22…, /es/display/calendar for
14
+ // Spanish). Nothing is saved, and whatever the parameters say it shows only
15
+ // what calendar.ics shows. Saved screens with private details live at
16
+ // /display/s/<key> (Admin → Calendar → Screens).
17
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
18
+ const repo = await getRepo(Astro.locals);
19
+ const site = await repo.getSettings();
20
+ const settings = readDisplaySettings(Astro.url.searchParams);
21
+ const lang = (I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '') || siteLang(site);
22
+ const board = await loadDisplayBoard(repo, site, {
23
+ settings, privateDetails: false, lang,
24
+ phoneLink: siteOrigin(site, Astro.url) + localePath(CALENDAR.base, lang),
25
+ });
26
+ displayHeaders(Astro.response.headers, { shared: true });
27
+ const prefix = I18N.enabled && lang !== I18N.defaultLocale ? `/${lang}` : '';
28
+ ---
29
+
30
+ <DisplayPage site={site} board={board} settings={settings} title={settings.title || site.siteTitle} lang={lang} live sw={`${prefix}/display/sw.js`} />
@@ -0,0 +1,71 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { CALENDAR } from '../../lib/calendar/config';
3
+
4
+ // The TV display's service worker (registered by DisplayPage.astro, scope
5
+ // /display/, or /es/display/ through the language rewrite). AbleSign reloads
6
+ // a website every time its playlist plays it and keeps no offline copy, so
7
+ // during an outage a reload would show an error page. Network first: each
8
+ // display page that loads (or refreshes its board) is kept, and served when a
9
+ // page load can't reach the site or gets a server error. A page that answers
10
+ // 404 (a rotated or removed screen) loses its copy, so an old link stops
11
+ // showing events. At most 20 pages are kept, none older than a week is served.
12
+ const SCRIPT = `
13
+ var CACHE = 'acms-display-v1';
14
+ var KEEP = 20, MAX_AGE = 7 * 86400000;
15
+ var scope = new URL(self.registration.scope).pathname;
16
+ self.addEventListener('install', function () { self.skipWaiting(); });
17
+ self.addEventListener('activate', function (e) { e.waitUntil(self.clients.claim()); });
18
+ function keep(key, res) {
19
+ return caches.open(CACHE).then(function (c) {
20
+ return c.put(key, res).then(function () { return c.keys(); }).then(function (keys) {
21
+ // the oldest first: at most KEEP pages (a player adding ?t= to each load can't fill the disk)
22
+ return Promise.all(keys.slice(0, Math.max(0, keys.length - KEEP)).map(function (k) { return c.delete(k); }));
23
+ });
24
+ });
25
+ }
26
+ function kept(key) {
27
+ return caches.open(CACHE).then(function (c) { return c.match(key); }).then(function (hit) {
28
+ var at = hit && Date.parse(hit.headers.get('date') || '');
29
+ // a week-old board is worse than an error: it would show the wrong week
30
+ return hit && (!at || Date.now() - at < MAX_AGE) ? hit : null;
31
+ });
32
+ }
33
+ self.addEventListener('fetch', function (e) {
34
+ var req = e.request;
35
+ if (req.method !== 'GET') return;
36
+ var url = new URL(req.url);
37
+ if (url.origin !== location.origin || url.pathname.indexOf(scope) !== 0 || /\\/sw\\.js$/.test(url.pathname)) return;
38
+ var key = url.origin + url.pathname + url.search;
39
+ // only a page load falls back: the page's own refresh must fail, so it
40
+ // keeps its board and says when it was last updated
41
+ var page = req.mode === 'navigate';
42
+ e.respondWith(fetch(req).then(function (res) {
43
+ if (res.ok && res.type === 'basic') {
44
+ e.waitUntil(keep(key, res.clone()));
45
+ return res;
46
+ }
47
+ if (res.status === 404 || res.status === 410) {
48
+ e.waitUntil(caches.open(CACHE).then(function (c) { return c.delete(key); }));
49
+ return res;
50
+ }
51
+ // a deploy, an outage or a rate limit: the last board beats an error page
52
+ if (page && (res.status >= 500 || res.status === 429)) return kept(key).then(function (hit) { return hit || res; });
53
+ return res;
54
+ }, function (err) {
55
+ if (!page) throw err;
56
+ return kept(key).then(function (hit) { if (hit) return hit; throw err; });
57
+ }));
58
+ });
59
+ `;
60
+
61
+ export const GET: APIRoute = () => {
62
+ if (!CALENDAR.enabled) return new Response(null, { status: 404 });
63
+ return new Response(SCRIPT, {
64
+ headers: {
65
+ 'Content-Type': 'text/javascript; charset=utf-8',
66
+ // browsers check for a new worker on every registration; never keep an old one
67
+ 'Cache-Control': 'no-cache',
68
+ 'X-Content-Type-Options': 'nosniff',
69
+ },
70
+ });
71
+ };
@@ -0,0 +1,195 @@
1
+ /* The TV display (/display/…; lib/display/render.ts). Inlined into the page
2
+ before the theme's CSS, so a theme restyles it with equal-specificity rules
3
+ or, better, by setting these variables (core never sets them itself):
4
+ --display-bg, --display-fg, --display-muted, --display-line,
5
+ --display-panel, --display-today, --display-now, --display-event,
6
+ --display-font, --display-heading-font, --display-radius
7
+ e.g. :root { --display-bg: #101820 } or
8
+ .acms-display[data-theme='light'] { --display-bg: #fffaf2 }
9
+ Every length is a multiple of --dsp-u (viewport height × text size), matching
10
+ METRICS in render.ts. Flat blocks only: no accent stripes, no gradients. */
11
+
12
+ html, body { margin: 0; height: 100%; overflow: hidden; }
13
+ body { background: #000; }
14
+
15
+ .acms-display {
16
+ --dsp-u: calc(min(1vh, 1.7778vw) * var(--dsp-scale, 1));
17
+ --d-bg: #0c0e13; --d-fg: #f3f4f7; --d-muted: #9ca3af; --d-line: rgba(255, 255, 255, 0.09);
18
+ --d-panel: rgba(255, 255, 255, 0.06); --d-today: rgba(255, 255, 255, 0.055); --d-now: #ff5a4f; --d-event: #4f6bed;
19
+ --dsp-bg: var(--display-bg, var(--d-bg));
20
+ --dsp-fg: var(--display-fg, var(--d-fg));
21
+ --dsp-muted: var(--display-muted, var(--d-muted));
22
+ --dsp-line: var(--display-line, var(--d-line));
23
+ --dsp-r: var(--display-radius, calc(0.7 * var(--dsp-u)));
24
+ position: fixed;
25
+ inset: 0;
26
+ box-sizing: border-box;
27
+ padding: calc(2.5 * var(--dsp-u)) calc(3 * var(--dsp-u));
28
+ display: flex;
29
+ flex-direction: column;
30
+ gap: calc(2 * var(--dsp-u));
31
+ background: var(--dsp-bg);
32
+ color: var(--dsp-fg);
33
+ font-family: var(--display-font, var(--font-body, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif));
34
+ font-size: calc(2.4 * var(--dsp-u));
35
+ line-height: 1.25;
36
+ overflow: hidden;
37
+ cursor: none;
38
+ -webkit-font-smoothing: antialiased;
39
+ user-select: none;
40
+ }
41
+ .acms-display[data-theme='light'] {
42
+ --d-bg: #f6f6f3; --d-fg: #15171c; --d-muted: #5b6270; --d-line: rgba(0, 0, 0, 0.1);
43
+ --d-panel: rgba(0, 0, 0, 0.045); --d-today: rgba(0, 0, 0, 0.045); --d-now: #d9342b;
44
+ }
45
+ .acms-display *, .acms-display *::before { box-sizing: border-box; }
46
+
47
+ /* header: heading, offline note, QR, clock (8u tall) */
48
+ .display-head { flex: 0 0 calc(8 * var(--dsp-u)); display: flex; align-items: center; gap: calc(3 * var(--dsp-u)); min-height: 0; }
49
+ .display-heading { flex: 1 1 auto; min-width: 0; }
50
+ .display-title {
51
+ margin: 0; font-size: calc(4.4 * var(--dsp-u)); line-height: 1.1; font-weight: 700; letter-spacing: -0.01em;
52
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
53
+ font-family: var(--display-heading-font, var(--font-heading, inherit));
54
+ }
55
+ .display-sub { margin: calc(0.3 * var(--dsp-u)) 0 0; font-size: calc(2.4 * var(--dsp-u)); color: var(--dsp-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
56
+ .display-clock { margin: 0; font-size: calc(4.4 * var(--dsp-u)); font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
57
+ .display-status {
58
+ margin: 0; padding: calc(0.5 * var(--dsp-u)) calc(1.4 * var(--dsp-u)); border-radius: 999px; white-space: nowrap;
59
+ font-size: calc(2 * var(--dsp-u)); color: var(--dsp-muted); background: var(--display-panel, var(--d-panel));
60
+ }
61
+ .display-status[hidden], .display-now[hidden], .display-page[hidden] { display: none; }
62
+ .display-qr { display: flex; align-items: center; gap: calc(1.2 * var(--dsp-u)); height: 100%; }
63
+ .display-qr svg { height: 100%; width: auto; display: block; background: #fff; padding: calc(0.5 * var(--dsp-u)); border-radius: calc(0.5 * var(--dsp-u)); }
64
+ .display-qr-note { font-size: calc(1.7 * var(--dsp-u)); color: var(--dsp-muted); max-width: calc(13 * var(--dsp-u)); line-height: 1.2; }
65
+
66
+ .display-board { flex: 1 1 0; min-height: 0; position: relative; }
67
+ .display-legend { flex: 0 0 calc(3 * var(--dsp-u)); display: flex; align-items: center; gap: calc(2.6 * var(--dsp-u)); font-size: calc(2 * var(--dsp-u)); color: var(--dsp-muted); overflow: hidden; white-space: nowrap; }
68
+ .display-key { display: inline-flex; align-items: center; gap: calc(0.8 * var(--dsp-u)); }
69
+
70
+ /* shared by every event shape */
71
+ .display-event { min-width: 0; }
72
+ .display-event.is-past { opacity: 0.45; }
73
+ .display-event.is-cancelled { opacity: 0.6; }
74
+ .display-event.is-cancelled .display-event-title { text-decoration: line-through; }
75
+ .display-event-title { display: block; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
76
+ .display-event-detail, .display-event-meta { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
77
+ .display-event-meta { opacity: 0.85; }
78
+ .display-flag { font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.88em; }
79
+ .display-was { opacity: 0.9; }
80
+ .display-dot { display: inline-block; flex: none; width: calc(1.4 * var(--dsp-u)); height: calc(1.4 * var(--dsp-u)); border-radius: calc(0.35 * var(--dsp-u)); background: var(--ev, var(--display-event, var(--d-event))); }
81
+
82
+ /* hour grids: week, day, rooms */
83
+ .display-grid { --axis: calc(7.5 * var(--dsp-u)); height: 100%; display: flex; flex-direction: column; }
84
+ .display-heads, .display-top, .display-cols { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); }
85
+ .display-heads { flex: 0 0 calc(4.5 * var(--dsp-u)); margin-left: var(--axis); gap: calc(0.4 * var(--dsp-u)); }
86
+ .display-dayhead {
87
+ display: flex; align-items: baseline; justify-content: center; gap: 0.35em; min-width: 0; padding: 0 0.4em;
88
+ border-radius: var(--dsp-r); font-size: calc(2.2 * var(--dsp-u)); color: var(--dsp-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
89
+ align-self: stretch; align-items: center;
90
+ }
91
+ .display-dayname { overflow: hidden; text-overflow: ellipsis; }
92
+ .display-daynum { font-size: calc(2.8 * var(--dsp-u)); font-weight: 700; color: var(--dsp-fg); }
93
+ .display-dayhead.is-today { color: var(--dsp-fg); font-weight: 700; background: var(--display-today, var(--d-today)); }
94
+ .display-dayhead.is-past { opacity: 0.6; }
95
+ .display-roomhead { color: var(--dsp-fg); font-weight: 700; background: var(--display-panel, var(--d-panel)); }
96
+ .display-top {
97
+ flex: none; margin: calc(0.6 * var(--dsp-u)) 0 0 var(--axis);
98
+ grid-template-rows: repeat(var(--lines), calc(3.3 * var(--dsp-u))); gap: 0 calc(0.4 * var(--dsp-u));
99
+ }
100
+ .display-hours { flex: 1 1 0; min-height: 0; display: flex; position: relative; margin-top: calc(0.8 * var(--dsp-u)); }
101
+ .display-axis { flex: 0 0 var(--axis); position: relative; }
102
+ .display-hour { position: absolute; right: calc(1 * var(--dsp-u)); transform: translateY(-50%); font-size: calc(1.8 * var(--dsp-u)); color: var(--dsp-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
103
+ .display-cols { flex: 1 1 auto; position: relative; gap: calc(0.4 * var(--dsp-u)); }
104
+ .display-lines { position: absolute; inset: 0; pointer-events: none; }
105
+ .display-line { position: absolute; left: 0; right: 0; border-top: 1px solid var(--dsp-line); }
106
+ .display-col { position: relative; min-width: 0; border-radius: var(--dsp-r); }
107
+ .display-col.is-today { background: var(--display-today, var(--d-today)); }
108
+ .display-grid.is-rooms .display-col.is-today { background: none; }
109
+ /* timed blocks: render.ts gives each only the lines it can show whole
110
+ (is-mid: one title line and the meta; is-short: the title alone) */
111
+ .display-block {
112
+ position: absolute; display: flex; flex-direction: column;
113
+ padding: calc(0.35 * var(--dsp-u)) calc(0.7 * var(--dsp-u)); overflow: hidden;
114
+ border-radius: var(--dsp-r); border: calc(0.15 * var(--dsp-u)) solid var(--dsp-bg);
115
+ background: var(--ev, var(--display-event, var(--d-event))); color: var(--ev-ink, #fff);
116
+ font-size: calc(1.7 * var(--dsp-u)); line-height: 1.1;
117
+ }
118
+ .display-block .display-event-title { font-size: calc(2.15 * var(--dsp-u)); line-height: 1.05; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
119
+ .display-block.is-mid .display-event-title, .display-block.is-short .display-event-title { display: block; white-space: nowrap; }
120
+ .display-block[data-cut='start'], .display-block[data-cut='both'] { border-top-left-radius: 0; border-top-right-radius: 0; }
121
+ .display-block[data-cut='end'], .display-block[data-cut='both'] { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
122
+ .display-cut { font-size: 0.75em; margin: 0 0.3em; opacity: 0.9; }
123
+ .display-bar, .display-chip {
124
+ margin: calc(0.25 * var(--dsp-u)) 0; padding: 0 calc(0.6 * var(--dsp-u)); border-radius: calc(0.5 * var(--dsp-u));
125
+ font-size: calc(1.9 * var(--dsp-u)); line-height: calc(2.8 * var(--dsp-u)); z-index: 1; overflow: hidden;
126
+ }
127
+ .display-bar { background: var(--ev, var(--display-event, var(--d-event))); color: var(--ev-ink, #fff); }
128
+ .display-bar .display-event-title, .display-chip .display-event-title { font-weight: 600; }
129
+ .display-bar-time, .display-chip-time { font-weight: 500; opacity: 0.85; font-variant-numeric: tabular-nums; }
130
+ .display-chip { display: flex; align-items: center; gap: calc(0.6 * var(--dsp-u)); }
131
+ .display-chip::before { content: ''; flex: none; width: calc(1.1 * var(--dsp-u)); height: calc(1.1 * var(--dsp-u)); border-radius: calc(0.3 * var(--dsp-u)); background: var(--ev, var(--display-event, var(--d-event))); }
132
+ .display-more { font-size: calc(1.8 * var(--dsp-u)); color: var(--dsp-muted); padding: 0 calc(0.6 * var(--dsp-u)); align-self: center; z-index: 1; white-space: nowrap; }
133
+ /* the now line keeps an outline in the page colour, so it shows over a block of its own colour */
134
+ .display-now {
135
+ position: absolute; left: 0; right: 0; height: calc(0.35 * var(--dsp-u)); margin-top: calc(-0.175 * var(--dsp-u)); z-index: 3; pointer-events: none;
136
+ background: var(--display-now, var(--d-now)); box-shadow: 0 0 0 calc(0.2 * var(--dsp-u)) var(--dsp-bg);
137
+ }
138
+ .display-now::before {
139
+ content: ''; position: absolute; left: calc(-0.7 * var(--dsp-u)); top: calc(-0.5 * var(--dsp-u)); width: calc(1.35 * var(--dsp-u)); height: calc(1.35 * var(--dsp-u));
140
+ border-radius: 50%; background: var(--display-now, var(--d-now)); box-shadow: 0 0 0 calc(0.2 * var(--dsp-u)) var(--dsp-bg);
141
+ }
142
+
143
+ /* month: rows fill the height; each row has a date line and --lines lines */
144
+ .display-month { height: 100%; display: grid; grid-template-rows: calc(3.5 * var(--dsp-u)) repeat(var(--rows), minmax(0, 1fr)); }
145
+ .display-dows, .display-weekrow { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); }
146
+ .display-dows { align-items: center; font-size: calc(1.9 * var(--dsp-u)); color: var(--dsp-muted); text-transform: uppercase; letter-spacing: 0.05em; }
147
+ .display-dow { padding: 0 calc(0.6 * var(--dsp-u)); }
148
+ .display-weekrow {
149
+ grid-template-rows: calc(3.4 * var(--dsp-u)) repeat(var(--lines), calc(3.3 * var(--dsp-u))) minmax(0, 1fr);
150
+ border-top: 1px solid var(--dsp-line); overflow: hidden; min-height: 0;
151
+ }
152
+ .display-cell { grid-row: 1 / -1; padding: calc(0.3 * var(--dsp-u)) calc(0.6 * var(--dsp-u)); border-left: 1px solid var(--dsp-line); min-width: 0; }
153
+ .display-cell:first-child { border-left: 0; }
154
+ .display-cell.is-today { background: var(--display-today, var(--d-today)); }
155
+ .display-cell.is-other .display-date { opacity: 0.4; }
156
+ .display-cell.is-past .display-date { opacity: 0.6; }
157
+ .display-date { font-size: calc(2.2 * var(--dsp-u)); font-weight: 600; font-variant-numeric: tabular-nums; }
158
+ .display-cell.is-today .display-date { color: var(--display-now, var(--d-now)); font-weight: 800; }
159
+ .display-month .display-bar, .display-month .display-chip { margin-left: calc(0.3 * var(--dsp-u)); margin-right: calc(0.3 * var(--dsp-u)); }
160
+
161
+ /* list: columns, then pages (the script turns them) */
162
+ .display-list { height: 100%; overflow: hidden; }
163
+ .display-list-cols { height: 100%; column-count: var(--dsp-list-cols, 3); column-gap: calc(3.5 * var(--dsp-u)); column-fill: auto; }
164
+ .display-day { margin: 0 0 calc(2.2 * var(--dsp-u)); }
165
+ .display-day-title { margin: 0 0 calc(0.6 * var(--dsp-u)); font-size: calc(2.3 * var(--dsp-u)); font-weight: 700; color: var(--dsp-muted); break-after: avoid; }
166
+ .display-day.is-today .display-day-title { color: var(--dsp-fg); }
167
+ .display-row {
168
+ display: grid; grid-template-columns: calc(1.4 * var(--dsp-u)) calc(9.5 * var(--dsp-u)) minmax(0, 1fr); gap: calc(1.2 * var(--dsp-u));
169
+ align-items: baseline; padding: calc(0.75 * var(--dsp-u)) 0; border-top: 1px solid var(--dsp-line); break-inside: avoid;
170
+ }
171
+ .display-row .display-dot { align-self: center; }
172
+ .display-row-time { color: var(--dsp-muted); font-size: calc(2.1 * var(--dsp-u)); white-space: nowrap; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; }
173
+ .display-row-body { min-width: 0; }
174
+ .display-row .display-event-title { font-size: calc(2.4 * var(--dsp-u)); white-space: normal; }
175
+ .display-row .display-event-meta, .display-row .display-event-detail { font-size: calc(1.9 * var(--dsp-u)); color: var(--dsp-muted); opacity: 1; }
176
+ .display-page { position: absolute; right: 0; bottom: calc(-0.5 * var(--dsp-u)); font-size: calc(1.8 * var(--dsp-u)); color: var(--dsp-muted); }
177
+
178
+ /* now and next */
179
+ .display-next { height: 100%; display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: calc(3 * var(--dsp-u)); }
180
+ .display-slot { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: calc(1.4 * var(--dsp-u)); overflow: hidden; }
181
+ .display-slot-title { margin: 0; font-size: calc(2.6 * var(--dsp-u)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--dsp-muted); }
182
+ .display-card {
183
+ display: flex; flex-direction: column; gap: calc(0.6 * var(--dsp-u)); padding: calc(2 * var(--dsp-u)) calc(2.4 * var(--dsp-u));
184
+ border-radius: calc(1.2 * var(--dsp-u)); background: var(--ev, var(--display-event, var(--d-event))); color: var(--ev-ink, #fff);
185
+ }
186
+ .display-card .display-event-title { font-size: calc(3.4 * var(--dsp-u)); line-height: 1.15; white-space: normal; }
187
+ .display-card.is-big .display-event-title { font-size: calc(6 * var(--dsp-u)); line-height: 1.08; }
188
+ .display-card .display-event-meta, .display-card .display-event-detail { font-size: calc(2.4 * var(--dsp-u)); white-space: normal; }
189
+ .display-card.is-big .display-event-meta { font-size: calc(3 * var(--dsp-u)); }
190
+ .display-empty { margin: 0; color: var(--dsp-muted); font-size: calc(3 * var(--dsp-u)); }
191
+ .display-empty.is-board { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
192
+
193
+ /* a screen that isn't there (wrong or rotated key) */
194
+ .display-missing { justify-content: center; align-items: center; text-align: center; }
195
+ .display-missing p { margin: 0; max-width: 60ch; color: var(--dsp-muted); }
package/src/virtual.d.ts CHANGED
@@ -24,6 +24,8 @@ declare module 'virtual:acms-config' {
24
24
  permissions?: { key: string; label: string; description: string; defaults: string[]; sensitive: boolean }[];
25
25
  members?: { enabled: boolean; base: string; notify: string[] };
26
26
  announcements?: { enabled: boolean; base: string; pages: boolean; topics: Partial<Record<'event' | 'registration' | 'help' | 'info', string>> };
27
+ // set in astro:config:setup (lib/origin-check.ts): off only when the site turned security.checkOrigin off
28
+ originCheck?: { enabled: boolean; signedRoutes: string[] };
27
29
  // resolved by integration.mjs (lib/calendar/config.ts documents the shape)
28
30
  calendar?: {
29
31
  enabled: boolean;