@commonpub/layer 0.23.3 → 0.25.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.
Files changed (81) hide show
  1. package/README.md +41 -12
  2. package/components/LayoutRow.vue +944 -0
  3. package/components/LayoutSection.vue +1028 -0
  4. package/components/LayoutSlot.vue +104 -162
  5. package/components/PageFrame.vue +116 -0
  6. package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
  7. package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
  8. package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
  9. package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
  10. package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
  11. package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
  12. package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
  13. package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
  14. package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
  15. package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
  16. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
  17. package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
  18. package/components/blocks/BlockDividerView.vue +52 -2
  19. package/components/homepage/ContentGridSection.vue +23 -1
  20. package/components/homepage/HeroSection.vue +69 -8
  21. package/components/sections/SectionCta.vue +175 -0
  22. package/components/sections/SectionLearning.vue +232 -0
  23. package/composables/autoFormSchema.ts +319 -0
  24. package/composables/useAdminSidebar.ts +116 -0
  25. package/composables/useEditorChrome.ts +56 -0
  26. package/composables/useFeatures.ts +32 -5
  27. package/composables/useLayout.ts +46 -43
  28. package/composables/useLayoutAnnouncer.ts +332 -0
  29. package/composables/useLayoutAutoSave.ts +117 -0
  30. package/composables/useLayoutDrag.ts +290 -0
  31. package/composables/useLayoutEditor.ts +593 -0
  32. package/composables/useLayoutHistory.ts +583 -0
  33. package/composables/useLayoutHotkeys.ts +366 -0
  34. package/composables/useLayoutResize.ts +783 -0
  35. package/layouts/admin.vue +137 -24
  36. package/middleware/admin-layouts.ts +29 -0
  37. package/nuxt.config.ts +14 -0
  38. package/package.json +8 -5
  39. package/pages/[...customPath].vue +154 -0
  40. package/pages/admin/homepage.vue +46 -0
  41. package/pages/admin/index.vue +16 -0
  42. package/pages/admin/layouts/[id].vue +1110 -0
  43. package/pages/admin/layouts/index.vue +356 -0
  44. package/pages/explore.vue +16 -6
  45. package/sections/builtin/content-feed.ts +18 -29
  46. package/sections/builtin/contests.ts +30 -0
  47. package/sections/builtin/cta.ts +46 -0
  48. package/sections/builtin/custom-html.ts +36 -0
  49. package/sections/builtin/divider.ts +15 -17
  50. package/sections/builtin/editorial.ts +29 -0
  51. package/sections/builtin/embed.ts +31 -0
  52. package/sections/builtin/gallery.ts +29 -0
  53. package/sections/builtin/heading.ts +14 -19
  54. package/sections/builtin/hero.ts +16 -51
  55. package/sections/builtin/hubs.ts +30 -0
  56. package/sections/builtin/image.ts +12 -49
  57. package/sections/builtin/learning.ts +30 -0
  58. package/sections/builtin/markdown.ts +29 -0
  59. package/sections/builtin/paragraph.ts +14 -17
  60. package/sections/builtin/stats.ts +35 -0
  61. package/sections/builtin/video.ts +30 -0
  62. package/sections/registry.ts +38 -7
  63. package/server/api/admin/homepage/sections.put.ts +52 -1
  64. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  65. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  66. package/server/api/admin/layouts/[id].delete.ts +33 -1
  67. package/server/api/admin/layouts/[id].put.ts +78 -0
  68. package/server/api/admin/layouts/index.post.ts +60 -4
  69. package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
  70. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  71. package/server/api/layouts/by-route.get.ts +64 -12
  72. package/server/plugins/feature-flags-prime.ts +39 -0
  73. package/server/utils/layoutCache.ts +37 -1
  74. package/server/utils/validateSectionConfigs.ts +123 -0
  75. package/theme/base.css +1 -0
  76. package/components/sections/SectionContentFeed.vue +0 -160
  77. package/components/sections/SectionDivider.vue +0 -55
  78. package/components/sections/SectionHeading.vue +0 -78
  79. package/components/sections/SectionHero.vue +0 -164
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
@@ -15,7 +15,7 @@ import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
15
15
  export default defineEventHandler(async (event): Promise<{ ok: true; id: string }> => {
16
16
  requireFeature('admin');
17
17
  requireFeature('layoutEngine');
18
- requireAdmin(event);
18
+ const admin = requireAdmin(event);
19
19
  const db = useDB();
20
20
 
21
21
  const id = getRouterParam(event, 'id');
@@ -28,6 +28,38 @@ export default defineEventHandler(async (event): Promise<{ ok: true; id: string
28
28
  throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
29
29
  }
30
30
 
31
+ // R4 audit P1 fix: homepage scope special-case. Deleting the
32
+ // ('route', '/') layout nukes the homepage + its entire publish
33
+ // history. The list-page UI already confirm()s before DELETE, but
34
+ // the API can also be called directly — require an explicit
35
+ // X-Cpub-Confirm-Homepage-Delete: 1 header for the homepage scope
36
+ // as defense-in-depth. Surfaces operator footgun loudly + audit log
37
+ // still captures the action when the header is set.
38
+ const isHomepage =
39
+ existing.scope.type === 'route' && existing.scope.path === '/';
40
+ if (isHomepage && getHeader(event, 'x-cpub-confirm-homepage-delete') !== '1') {
41
+ throw createError({
42
+ statusCode: 409,
43
+ statusMessage: 'Refusing to delete the homepage layout without explicit confirmation',
44
+ data: {
45
+ code: 'HOMEPAGE_DELETE_NEEDS_CONFIRM',
46
+ hint: 'Set X-Cpub-Confirm-Homepage-Delete: 1 header to override.',
47
+ },
48
+ });
49
+ }
50
+
51
+ // Audit log (session 160 audit P2). Layout deletion is destructive +
52
+ // not recoverable; structured stdout line gives operators a forensic
53
+ // trail when an admin reports "the homepage layout disappeared".
54
+ console.info('cpub.audit.layout.delete', JSON.stringify({
55
+ at: new Date().toISOString(),
56
+ adminId: admin.id,
57
+ layoutId: id,
58
+ scope: existing.scope,
59
+ name: existing.name,
60
+ state: existing.state,
61
+ }));
62
+
31
63
  await deleteLayout(db, id);
32
64
  invalidateLayoutsByRouteCache();
33
65
  return { ok: true, id };
@@ -8,12 +8,21 @@
8
8
  * Scope CANNOT be changed via PUT (it's immutable per layout). A scope
9
9
  * change would mean a new layout — POST instead.
10
10
  *
11
+ * Optimistic concurrency (Phase 3a.6): if the request includes an
12
+ * `If-Match` header, it must equal the layout's current `updatedAt`.
13
+ * Mismatch → 412 Precondition Failed (or 409 if we want to match
14
+ * the editor's existing handler — RFC 7232 says 412, but pragmatic
15
+ * web editors use 409. We follow editor convention: 409 conflict).
16
+ * Omit the header (or pass empty string) to force an unconditional
17
+ * write (the "overwrite" path from the conflict modal).
18
+ *
11
19
  * Admin + features.admin + features.layoutEngine.
12
20
  * Invalidates the layouts-by-route cache on success.
13
21
  */
14
22
  import { layoutCreateSchema } from '@commonpub/schema';
15
23
  import { getLayoutById, saveLayout } from '@commonpub/server';
16
24
  import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
25
+ import { validateSectionConfigs } from '../../../utils/validateSectionConfigs';
17
26
 
18
27
  export default defineEventHandler(async (event) => {
19
28
  requireFeature('admin');
@@ -31,8 +40,59 @@ export default defineEventHandler(async (event) => {
31
40
  throw createError({ statusCode: 404, statusMessage: 'Layout not found' });
32
41
  }
33
42
 
43
+ // Optimistic concurrency check — client sends If-Match: <updatedAt>;
44
+ // mismatch means someone else saved in between. The client's auto-save
45
+ // catches the 409 and pops a conflict modal (3a.6).
46
+ const ifMatch = getHeader(event, 'if-match');
47
+ if (ifMatch && ifMatch.trim() !== '' && ifMatch !== existing.updatedAt) {
48
+ throw createError({
49
+ statusCode: 409,
50
+ statusMessage: 'Layout was modified by another session',
51
+ data: {
52
+ code: 'LAYOUT_CONFLICT',
53
+ clientUpdatedAt: ifMatch,
54
+ serverUpdatedAt: existing.updatedAt,
55
+ },
56
+ });
57
+ }
58
+
59
+ // Audit log: client signals deliberate force-save via X-Cpub-Force-Save
60
+ // when the user clicks "Overwrite their changes" in the conflict modal.
61
+ // Forensic trail captures who overwrote what + when (audit P2 from
62
+ // session 160). Structured for greppability — operators can `docker
63
+ // logs ... | grep cpub.audit.layout.force-save`.
64
+ if (getHeader(event, 'x-cpub-force-save') === '1') {
65
+ console.info('cpub.audit.layout.force-save', JSON.stringify({
66
+ at: new Date().toISOString(),
67
+ adminId: admin.id,
68
+ layoutId: id,
69
+ scope: existing.scope,
70
+ previousUpdatedAt: existing.updatedAt,
71
+ }));
72
+ }
73
+
34
74
  const body = await parseBody(event, layoutCreateSchema);
35
75
 
76
+ // Per-section configSchema enforcement (R2 P1 deferred → wired session 161
77
+ // once schemas moved to @commonpub/schema/sectionConfigs, removing the
78
+ // .vue transitive import that broke the Nitro bundle on the R2 attempt).
79
+ // On failure logs an audit event + re-throws the validator's 400.
80
+ try {
81
+ validateSectionConfigs(body.zones);
82
+ } catch (err) {
83
+ const e = err as { data?: { code?: string; sectionErrors?: unknown[] } };
84
+ if (e?.data?.code === 'SECTION_CONFIG_INVALID') {
85
+ console.info('cpub.audit.layout.config-rejected', JSON.stringify({
86
+ at: new Date().toISOString(),
87
+ adminId: admin.id,
88
+ layoutId: id,
89
+ scope: existing.scope,
90
+ errorCount: e.data.sectionErrors?.length ?? 0,
91
+ }));
92
+ }
93
+ throw err;
94
+ }
95
+
36
96
  // Scope is immutable — reject if the client tries to change it. This
37
97
  // catches an "edit the wrong layout" bug at the API surface rather
38
98
  // than silently moving sections to a new route.
@@ -59,6 +119,24 @@ export default defineEventHandler(async (event) => {
59
119
  { id, userId: admin.id },
60
120
  );
61
121
 
122
+ // Audit log: every successful update goes through cpub.audit.layout.update.
123
+ // Per session 163 deep audit: the gap between "5 mutations should log
124
+ // (create/update/publish/delete/migrate)" (session 160 R3 plan) and the
125
+ // current implementation (force-save + config-rejected only) was a
126
+ // forensic blind spot — regular auto-saves left zero trail. Operators
127
+ // can grep + filter; the `source` header lets them separate manual saves
128
+ // from auto-saves vs beacons vs force-saves. Default 'auto' for legacy
129
+ // clients that don't send the header.
130
+ const saveSource = getHeader(event, 'x-cpub-save-source') ?? 'auto';
131
+ console.info('cpub.audit.layout.update', JSON.stringify({
132
+ at: new Date().toISOString(),
133
+ adminId: admin.id,
134
+ layoutId: id,
135
+ scope: existing.scope,
136
+ source: saveSource,
137
+ savedUpdatedAt: saved.updatedAt,
138
+ }));
139
+
62
140
  invalidateLayoutsByRouteCache();
63
141
  return saved;
64
142
  });
@@ -12,8 +12,9 @@
12
12
  * Invalidates the layouts-by-route cache on success.
13
13
  */
14
14
  import { layoutCreateSchema } from '@commonpub/schema';
15
- import { getLayoutByScope, saveLayout } from '@commonpub/server';
15
+ import { getLayoutByScope, saveLayout, validateCustomPageScope } from '@commonpub/server';
16
16
  import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
17
+ import { validateSectionConfigs } from '../../../utils/validateSectionConfigs';
17
18
 
18
19
  export default defineEventHandler(async (event) => {
19
20
  requireFeature('admin');
@@ -23,18 +24,62 @@ export default defineEventHandler(async (event) => {
23
24
 
24
25
  const body = await parseBody(event, layoutCreateSchema);
25
26
 
26
- const existing = await getLayoutByScope(db, body.scope);
27
+ // Per-section configSchema enforcement (R2 P1 deferred → wired session 161
28
+ // once schemas moved to @commonpub/schema/sectionConfigs). On failure logs
29
+ // an audit event + re-throws the validator's 400; otherwise no-ops.
30
+ try {
31
+ validateSectionConfigs(body.zones);
32
+ } catch (err) {
33
+ const e = err as { data?: { code?: string; sectionErrors?: unknown[] } };
34
+ if (e?.data?.code === 'SECTION_CONFIG_INVALID') {
35
+ console.info('cpub.audit.layout.config-rejected', JSON.stringify({
36
+ at: new Date().toISOString(),
37
+ adminId: admin.id,
38
+ layoutId: null, // POST = create; no id yet
39
+ scope: body.scope,
40
+ errorCount: e.data.sectionErrors?.length ?? 0,
41
+ }));
42
+ }
43
+ throw err;
44
+ }
45
+
46
+ // Custom-page paths get extra validation: pathNormalize + file-route
47
+ // conflict + duplicate detection (Phase 2). Returns 400 for malformed
48
+ // paths, 409 for collisions. The route-scope + virtual paths go
49
+ // straight to the existing exists-check below — those types aren't
50
+ // operator-creatable in v1 anyway.
51
+ let scopeToSave = body.scope;
52
+ if (body.scope.type === 'custom-page') {
53
+ const validation = await validateCustomPageScope(db, body.scope.path);
54
+ if (!validation.ok) {
55
+ // 'has-query', 'has-fragment', 'has-dot-segment', 'invalid-char',
56
+ // 'empty' → 400 (malformed)
57
+ // 'reserved-prefix', 'file-route-conflict',
58
+ // 'custom-page-already-exists' → 409 (collision)
59
+ const status = (validation.reason === 'reserved-prefix'
60
+ || validation.reason === 'file-route-conflict'
61
+ || validation.reason === 'custom-page-already-exists') ? 409 : 400;
62
+ throw createError({
63
+ statusCode: status,
64
+ statusMessage: validation.message,
65
+ data: { reason: validation.reason },
66
+ });
67
+ }
68
+ scopeToSave = validation.scope;
69
+ }
70
+
71
+ const existing = await getLayoutByScope(db, scopeToSave);
27
72
  if (existing) {
28
73
  throw createError({
29
74
  statusCode: 409,
30
- statusMessage: `A layout already exists for scope ${JSON.stringify(body.scope)}; PUT to /api/admin/layouts/${existing.id} to update it`,
75
+ statusMessage: `A layout already exists for scope ${JSON.stringify(scopeToSave)}; PUT to /api/admin/layouts/${existing.id} to update it`,
31
76
  });
32
77
  }
33
78
 
34
79
  const saved = await saveLayout(
35
80
  db,
36
81
  {
37
- scope: body.scope,
82
+ scope: scopeToSave,
38
83
  name: body.name,
39
84
  pageMeta: body.pageMeta,
40
85
  zones: body.zones,
@@ -43,6 +88,17 @@ export default defineEventHandler(async (event) => {
43
88
  { userId: admin.id },
44
89
  );
45
90
 
91
+ // Audit log (round 3): every new layout creation is forensically
92
+ // significant — admins creating routes/custom-pages from scratch.
93
+ console.info('cpub.audit.layout.create', JSON.stringify({
94
+ at: new Date().toISOString(),
95
+ adminId: admin.id,
96
+ layoutId: saved.id,
97
+ scope: saved.scope,
98
+ name: saved.name,
99
+ state: saved.state,
100
+ }));
101
+
46
102
  invalidateLayoutsByRouteCache();
47
103
  return saved;
48
104
  });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * POST /api/admin/layouts/migrate-homepage
3
+ *
4
+ * Converts the operator's legacy `instance_settings.homepage.sections`
5
+ * JSON into a real `layouts` row at scope ('route', '/').
6
+ *
7
+ * Body (all optional):
8
+ * { force?: boolean }
9
+ *
10
+ * Default: skip when a layout already exists at the route (returns
11
+ * `{migrated:false, reason:'layout-already-exists', layoutId}`). With
12
+ * `force: true`, the existing layout + its rows / sections / versions
13
+ * are deleted via FK cascade and replaced.
14
+ *
15
+ * Intended canary flow (replaces the older seed-homepage path for
16
+ * instances that have customised their homepage):
17
+ * 1. Operator runs migration 0005 (if not already applied)
18
+ * 2. Operator POSTs here → layouts row matching the live homepage
19
+ * is created + published
20
+ * 3. Operator flips `features.layoutEngine` ON
21
+ * 4. Homepage SSR renders via LayoutSlot — visually identical, but
22
+ * now sourced from the layouts table instead of homepage.sections
23
+ * 5. Once stable, the legacy `homepage.sections` setting can be
24
+ * removed (separate operator action — this endpoint doesn't
25
+ * delete legacy data)
26
+ *
27
+ * Admin + features.admin + features.layoutEngine. Invalidates the
28
+ * layouts-by-route cache on a successful migration (or force-replace).
29
+ */
30
+ import { z } from 'zod';
31
+ import { migrateHomepageSectionsToLayout } from '@commonpub/server';
32
+ import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
33
+
34
+ const bodySchema = z.object({
35
+ force: z.boolean().optional().default(false),
36
+ });
37
+
38
+ export default defineEventHandler(async (event) => {
39
+ requireFeature('admin');
40
+ requireFeature('layoutEngine');
41
+ const admin = requireAdmin(event);
42
+
43
+ const body = await readBody(event).catch(() => ({}));
44
+ const { force } = bodySchema.parse(body ?? {});
45
+
46
+ const db = useDB();
47
+ const result = await migrateHomepageSectionsToLayout(db, {
48
+ adminId: admin.id,
49
+ force,
50
+ });
51
+
52
+ // Audit log (round 3): migrate-homepage is the one-time destructive
53
+ // transformation that lifts an instance from the legacy renderer to
54
+ // the layout engine. Forensic trail for "the migration ate my homepage".
55
+ console.info('cpub.audit.layout.migrate-homepage', JSON.stringify({
56
+ at: new Date().toISOString(),
57
+ adminId: admin.id,
58
+ migrated: result.migrated,
59
+ force,
60
+ layoutId: (result as { layoutId?: string }).layoutId ?? null,
61
+ reason: (result as { reason?: string }).reason ?? null,
62
+ }));
63
+
64
+ if (result.migrated) {
65
+ invalidateLayoutsByRouteCache();
66
+ }
67
+ return result;
68
+ });
@@ -28,7 +28,16 @@ export default defineEventHandler(async (event) => {
28
28
  const db = useDB();
29
29
 
30
30
  const result = await seedHomepageLayout(db, { adminId: admin.id });
31
+
32
+ // Audit log (round 3): seed is idempotent but the first-call creates
33
+ // the initial homepage layout — significant for "when did the layout
34
+ // engine first come online on this instance?".
31
35
  if (result.created) {
36
+ console.info('cpub.audit.layout.seed-homepage', JSON.stringify({
37
+ at: new Date().toISOString(),
38
+ adminId: admin.id,
39
+ layoutId: (result as { layoutId?: string }).layoutId ?? null,
40
+ }));
32
41
  invalidateLayoutsByRouteCache();
33
42
  }
34
43
  return result;
@@ -2,9 +2,22 @@
2
2
  * GET /api/layouts/by-route?path=/some-path
3
3
  *
4
4
  * Public endpoint — resolves the active layout for a route, used by the
5
- * `<LayoutSlot>` renderer on every page request. Returns the published
6
- * version when available, otherwise the draft (during pre-publish
7
- * editing admins see drafts, end users get a 404 until published).
5
+ * `<LayoutSlot>` renderer on every page request.
6
+ *
7
+ * **Admins-only drafts (session 160 audit P0 fix)**: anonymous +
8
+ * non-admin authenticated requests see the layout ONLY when its
9
+ * `state === 'published'`. Drafts return null (404 in the cache, no
10
+ * layout in the response). Admins see the live draft state regardless
11
+ * — this is what enables WYSIWYG editing via `<LayoutSlot
12
+ * previewOverride>`.
13
+ *
14
+ * The check uses `getOptionalUser` (does not throw on unauthenticated)
15
+ * because the endpoint MUST stay public for published-state layouts;
16
+ * 401-ing anonymous users would break SSR for any logged-out visitor.
17
+ *
18
+ * Cache key includes the "admin?" boolean so admins + anonymous don't
19
+ * cross-contaminate (an admin's draft-aware response would leak to an
20
+ * anonymous hit on the same key).
8
21
  *
9
22
  * Cached server-side for `LAYOUT_CACHE_TTL_MS` per path (see
10
23
  * `server/utils/layoutCache.ts`). Invalidated on any layout write —
@@ -42,8 +55,26 @@ export default defineEventHandler(async (event): Promise<PublicLayoutSlice | nul
42
55
  throw createError({ statusCode: 404, statusMessage: 'Layout engine not enabled' });
43
56
  }
44
57
 
45
- const { path } = parseQueryParams(event, layoutsByRoutePathSchema);
46
- const cacheKey = path;
58
+ const { path: rawPath } = parseQueryParams(event, layoutsByRoutePathSchema);
59
+ // Session 163 deep audit: normalize trailing slash so `/blog` and
60
+ // `/blog/` hit the SAME cache entry + DB lookup. Without this, the
61
+ // cache stores both forms separately AND a layout saved under `/blog`
62
+ // is unreachable when requested as `/blog/`. Root is special-cased
63
+ // (no slash to strip). Future tightening: enforce canonical form at
64
+ // POST/PUT write time too — for now the read-side normalize covers
65
+ // the actual user-visible bug (cached duplication + accidental misses).
66
+ const path = rawPath === '/' ? '/' : rawPath.replace(/\/+$/, '');
67
+
68
+ // Session 160 audit: admins see drafts + admin-access layouts;
69
+ // members see published members-and-public layouts; anonymous only
70
+ // sees public published layouts. Cache key trifurcates on the
71
+ // requester's access tier so a layout served to a higher tier can't
72
+ // leak via cache to a lower tier on the same path.
73
+ const user = getOptionalUser(event);
74
+ const isAdmin = user?.role === 'admin';
75
+ const tier: 'admin' | 'members' | 'anon' = isAdmin ? 'admin' : user ? 'members' : 'anon';
76
+ const cacheKey = `${tier}:${path}`;
77
+
47
78
  const hit = getLayoutCacheEntry<PublicLayoutSlice>(cacheKey);
48
79
  const now = Date.now();
49
80
  if (hit && now - hit.at < LAYOUT_CACHE_TTL_MS) {
@@ -57,13 +88,34 @@ export default defineEventHandler(async (event): Promise<PublicLayoutSlice | nul
57
88
  : { type: 'route', path };
58
89
 
59
90
  const layout = await getLayoutByScope(db, scope);
60
- const value: PublicLayoutSlice | null = layout
61
- ? {
62
- zones: layout.zones,
63
- pageMeta: layout.pageMeta,
64
- state: layout.state,
65
- }
66
- : null;
91
+
92
+ // Multi-guard returns null when ANY check fails:
93
+ // 1. Draft-leak guard (audit round 2 P0): a non-admin must NOT see
94
+ // a layout whose state is 'draft'.
95
+ // 2. pageMeta.access guard (audit round 3): if pageMeta.access ===
96
+ // 'admin', only admins see the payload. The catch-all page does
97
+ // a parallel client-side check, but the SERVER is the trust
98
+ // boundary — a malicious authenticated user could hit this
99
+ // endpoint directly and exfiltrate admin-only layout payloads.
100
+ // 3. pageMeta.access === 'members': any authenticated user passes;
101
+ // anonymous see null (catch-all redirects to /auth/login).
102
+ // Returning null surfaces as "no layout for this route" to the
103
+ // catch-all + the homepage v-if fallback.
104
+ const access = layout?.pageMeta?.access ?? 'public';
105
+ const isAuthenticated = !!user;
106
+ const accessOk =
107
+ access === 'public' ||
108
+ (access === 'members' && isAuthenticated) ||
109
+ (access === 'admin' && isAdmin);
110
+
111
+ const value: PublicLayoutSlice | null =
112
+ layout && accessOk && (isAdmin || layout.state === 'published')
113
+ ? {
114
+ zones: layout.zones,
115
+ pageMeta: layout.pageMeta,
116
+ state: layout.state,
117
+ }
118
+ : null;
67
119
 
68
120
  setLayoutCacheEntry(cacheKey, value, now);
69
121
  return value;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Nitro plugin — prime each SSR request with DB-merged feature flags.
3
+ *
4
+ * The Vue `useFeatures()` composable (layers/base/composables/useFeatures.ts)
5
+ * initialises its `useState('feature-flags')` from `useRuntimeConfig().public.features`,
6
+ * which is the BUILD-TIME config baked into the bundle. Admin-UI flag
7
+ * overrides land in `instance_settings.features.overrides` and are
8
+ * picked up by `useConfig()` (server-side, DB-merged with 60s cache),
9
+ * but the layer composable never queries that at SSR time — only AFTER
10
+ * client mount, via `$fetch('/api/features')`.
11
+ *
12
+ * Effect of the gap: SSR renders with stale (build-time) flag values.
13
+ * An admin flipping `layoutEngine: true` from off-by-default through the
14
+ * admin UI doesn't take effect for SSR; the first paint shows the
15
+ * v-else-if branch, then hydration replaces it with the v-if branch.
16
+ * Bad UX and breaks curl-based canary verification.
17
+ *
18
+ * Fix: read the merged config at request-start and attach it to
19
+ * `event.context.cpubFeatureFlags`. The Vue composable's `getInitialFlags`
20
+ * (modified alongside this plugin) reads from `useRequestEvent()` context
21
+ * on the server, falling back to the runtime config when context is
22
+ * absent (e.g. island components, error pages).
23
+ *
24
+ * Cost: one call to `useConfig()` per request. The merged config is
25
+ * itself cached (60s TTL on DB overrides), so this is a Map lookup,
26
+ * not a DB hit on the hot path.
27
+ */
28
+ export default defineNitroPlugin((nitroApp) => {
29
+ nitroApp.hooks.hook('request', (event) => {
30
+ try {
31
+ const config = useConfig();
32
+ event.context.cpubFeatureFlags = config.features;
33
+ } catch {
34
+ // useConfig() throws at startup when the DB isn't ready yet.
35
+ // Leave context.cpubFeatureFlags unset → composable falls back to
36
+ // build-time runtime config (same behavior as before this plugin).
37
+ }
38
+ });
39
+ });
@@ -15,6 +15,13 @@
15
15
  * performance pass (`docs/plans/layout-and-pages.md` §10) replaces this
16
16
  * with ETag-based revalidation; the in-memory cache is sufficient for
17
17
  * Phase 1c volumes.
18
+ *
19
+ * **R4 audit fix (session 160)**: bounded LRU. Previously the cache was
20
+ * an unbounded Map — an adversarial client (or a misbehaving crawler)
21
+ * hitting `/api/layouts/by-route?path=/<random>` thousands of times
22
+ * grew the map without limit. After the R3 cache-key trifurcation
23
+ * (admin/members/anon), each unique path could land 3 entries. Bounded
24
+ * here to MAX_CACHE_ENTRIES with LRU eviction.
18
25
  */
19
26
  export interface LayoutCacheEntry<T> {
20
27
  value: T | null;
@@ -22,14 +29,43 @@ export interface LayoutCacheEntry<T> {
22
29
  }
23
30
 
24
31
  export const LAYOUT_CACHE_TTL_MS = 60_000;
32
+
33
+ /**
34
+ * Maximum entries the cache will hold. At ~512 bytes per key + value,
35
+ * this caps memory at ~512KB per pod — comfortably small. Past this
36
+ * cap, oldest-inserted entries are evicted.
37
+ *
38
+ * Map's natural insertion-order semantics make LRU-on-set trivial:
39
+ * delete + reinsert moves an entry to the "newest" end. Eviction
40
+ * iterates from the oldest end.
41
+ */
42
+ export const MAX_CACHE_ENTRIES = 1000;
43
+
25
44
  const cache = new Map<string, LayoutCacheEntry<unknown>>();
26
45
 
27
46
  export function getLayoutCacheEntry<T>(key: string): LayoutCacheEntry<T> | undefined {
28
- return cache.get(key) as LayoutCacheEntry<T> | undefined;
47
+ const entry = cache.get(key) as LayoutCacheEntry<T> | undefined;
48
+ if (entry !== undefined) {
49
+ // LRU touch: move this key to the "newest" end of insertion order.
50
+ // The cache stays in oldest-first iteration order so eviction is O(1).
51
+ cache.delete(key);
52
+ cache.set(key, entry);
53
+ }
54
+ return entry;
29
55
  }
30
56
 
31
57
  export function setLayoutCacheEntry<T>(key: string, value: T | null, at: number = Date.now()): void {
58
+ // If key exists, drop it first so the re-insert lands at the newest end.
59
+ if (cache.has(key)) cache.delete(key);
32
60
  cache.set(key, { value, at });
61
+
62
+ // Evict oldest entries past the cap. Map preserves insertion order, so
63
+ // the first key in iteration is the least-recently-touched.
64
+ while (cache.size > MAX_CACHE_ENTRIES) {
65
+ const oldestKey = cache.keys().next().value;
66
+ if (oldestKey === undefined) break;
67
+ cache.delete(oldestKey);
68
+ }
33
69
  }
34
70
 
35
71
  /**
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Per-section config validation — runs every section's registered
3
+ * Zod schema against the user-submitted config blob.
4
+ *
5
+ * P1 security fix from session 160 audit, finally wired in session 161
6
+ * after the schemas moved to `@commonpub/schema/sectionConfigs`. The
7
+ * `layoutCreateSchema` top-level Zod only validates the SHAPE of
8
+ * `section.config` as `z.record(z.unknown())` — it doesn't enforce
9
+ * per-type rules (URL guards on hrefs, size caps on arrays, etc)
10
+ * declared in each section's `configSchema`. Without this check,
11
+ * an admin can bypass those guards by sending arbitrary config —
12
+ * limited blast radius (admin auth required) but every CMS treats
13
+ * admin-tier input as semi-trusted and validates anyway.
14
+ *
15
+ * Throws an h3-compatible 400 with a structured `data.sectionErrors`
16
+ * payload listing every invalid section + the Zod issue. Used by:
17
+ * - POST /api/admin/layouts (create)
18
+ * - PUT /api/admin/layouts/[id] (update)
19
+ *
20
+ * Unknown section types ALSO 400 — same handler — so a typo'd type
21
+ * surfaces as a validation error instead of silently rendering a
22
+ * placeholder on the public page.
23
+ *
24
+ * Server-safe because `@commonpub/schema` has zero Vue imports. The
25
+ * previous attempt to wire this (session 160 R2) imported the section
26
+ * registry, which transitively pulled `.vue` components into the Nitro
27
+ * bundle and broke the build. The proper fix — moving schemas to the
28
+ * schema package — was deferred then; this is that fix.
29
+ */
30
+ import { SECTION_CONFIG_SCHEMAS } from '@commonpub/schema';
31
+
32
+ /**
33
+ * Throw an h3/Nuxt-compatible HTTP error WITHOUT depending on h3
34
+ * directly (h3 isn't a direct layer dep + isn't resolvable from
35
+ * vitest). Nitro's error handler treats any thrown Error with
36
+ * `statusCode` + `statusMessage` + `data` as the equivalent of
37
+ * createError() — same wire format.
38
+ */
39
+ function httpError(opts: { statusCode: number; statusMessage: string; data?: unknown }): Error {
40
+ const err = new Error(opts.statusMessage) as Error & {
41
+ statusCode: number;
42
+ statusMessage: string;
43
+ data?: unknown;
44
+ };
45
+ err.statusCode = opts.statusCode;
46
+ err.statusMessage = opts.statusMessage;
47
+ err.data = opts.data;
48
+ return err;
49
+ }
50
+
51
+ interface InputZone {
52
+ zone: string;
53
+ rows: Array<{
54
+ config?: unknown;
55
+ sections: Array<{
56
+ type: string;
57
+ config: Record<string, unknown>;
58
+ }>;
59
+ }>;
60
+ }
61
+
62
+ interface SectionError {
63
+ zone: string;
64
+ rowIndex: number;
65
+ sectionIndex: number;
66
+ type: string;
67
+ // Zod 4's issue.path is PropertyKey[] (string | number | symbol);
68
+ // symbol paths in user-submitted JSON are not reachable but the type
69
+ // must accept them.
70
+ issues: Array<{ path: PropertyKey[]; message: string }>;
71
+ }
72
+
73
+ /**
74
+ * Validate every section in a layout's zones against its registered
75
+ * Zod configSchema. Throws an h3-compatible 400 on any failure.
76
+ *
77
+ * No `registry` parameter — schema lookup is done via
78
+ * `SECTION_CONFIG_SCHEMAS` from `@commonpub/schema`, which is the
79
+ * canonical type → schema map. Keep that map in sync when adding
80
+ * new section types (see `packages/schema/src/sectionConfigs.ts`).
81
+ */
82
+ export function validateSectionConfigs(zones: InputZone[]): void {
83
+ const errors: SectionError[] = [];
84
+
85
+ for (const zone of zones) {
86
+ zone.rows.forEach((row, rowIndex) => {
87
+ row.sections.forEach((section, sectionIndex) => {
88
+ const schema = SECTION_CONFIG_SCHEMAS[section.type];
89
+ if (!schema) {
90
+ errors.push({
91
+ zone: zone.zone,
92
+ rowIndex,
93
+ sectionIndex,
94
+ type: section.type,
95
+ issues: [{ path: ['type'], message: `Unknown section type: ${section.type}` }],
96
+ });
97
+ return;
98
+ }
99
+ const result = schema.safeParse(section.config);
100
+ if (!result.success) {
101
+ errors.push({
102
+ zone: zone.zone,
103
+ rowIndex,
104
+ sectionIndex,
105
+ type: section.type,
106
+ issues: result.error.issues.map((i) => ({
107
+ path: [...i.path],
108
+ message: i.message,
109
+ })),
110
+ });
111
+ }
112
+ });
113
+ });
114
+ }
115
+
116
+ if (errors.length > 0) {
117
+ throw httpError({
118
+ statusCode: 400,
119
+ statusMessage: `Section config validation failed (${errors.length} section${errors.length === 1 ? '' : 's'})`,
120
+ data: { code: 'SECTION_CONFIG_INVALID', sectionErrors: errors },
121
+ });
122
+ }
123
+ }
package/theme/base.css CHANGED
@@ -218,6 +218,7 @@
218
218
  --nav-height: 3rem; /* 48px topbar */
219
219
  --subnav-height: 2.75rem;
220
220
  --sidebar-width: 12.5rem; /* 200px fixed sidebar */
221
+ --sidebar-width-collapsed: 3.5rem; /* 56px icons-only — admin chrome collapsed state */
221
222
  --content-max-width: 60rem; /* 960px */
222
223
  --content-wide-max-width: 75rem;
223
224