@commonpub/layer 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) 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/composables/autoFormSchema.ts +319 -0
  23. package/composables/useAdminSidebar.ts +116 -0
  24. package/composables/useEditorChrome.ts +56 -0
  25. package/composables/useLayout.ts +34 -41
  26. package/composables/useLayoutAnnouncer.ts +332 -0
  27. package/composables/useLayoutAutoSave.ts +117 -0
  28. package/composables/useLayoutDrag.ts +290 -0
  29. package/composables/useLayoutEditor.ts +593 -0
  30. package/composables/useLayoutHistory.ts +583 -0
  31. package/composables/useLayoutHotkeys.ts +366 -0
  32. package/composables/useLayoutResize.ts +783 -0
  33. package/layouts/admin.vue +137 -24
  34. package/middleware/admin-layouts.ts +29 -0
  35. package/package.json +11 -8
  36. package/pages/[...customPath].vue +154 -0
  37. package/pages/admin/homepage.vue +46 -0
  38. package/pages/admin/index.vue +16 -0
  39. package/pages/admin/layouts/[id].vue +1110 -0
  40. package/pages/admin/layouts/index.vue +356 -0
  41. package/pages/explore.vue +16 -6
  42. package/sections/builtin/content-feed.ts +18 -29
  43. package/sections/builtin/contests.ts +11 -19
  44. package/sections/builtin/cta.ts +46 -0
  45. package/sections/builtin/custom-html.ts +16 -30
  46. package/sections/builtin/divider.ts +15 -17
  47. package/sections/builtin/editorial.ts +11 -21
  48. package/sections/builtin/embed.ts +31 -0
  49. package/sections/builtin/gallery.ts +29 -0
  50. package/sections/builtin/heading.ts +14 -19
  51. package/sections/builtin/hero.ts +16 -51
  52. package/sections/builtin/hubs.ts +11 -26
  53. package/sections/builtin/image.ts +12 -49
  54. package/sections/builtin/learning.ts +5 -13
  55. package/sections/builtin/markdown.ts +29 -0
  56. package/sections/builtin/paragraph.ts +14 -17
  57. package/sections/builtin/stats.ts +17 -18
  58. package/sections/builtin/video.ts +30 -0
  59. package/sections/registry.ts +11 -0
  60. package/server/api/admin/homepage/sections.put.ts +52 -1
  61. package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
  62. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
  63. package/server/api/admin/layouts/[id].delete.ts +33 -1
  64. package/server/api/admin/layouts/[id].put.ts +78 -0
  65. package/server/api/admin/layouts/index.post.ts +60 -4
  66. package/server/api/admin/layouts/migrate-homepage.post.ts +12 -0
  67. package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
  68. package/server/api/layouts/by-route.get.ts +64 -12
  69. package/server/utils/layoutCache.ts +37 -1
  70. package/server/utils/validateSectionConfigs.ts +123 -0
  71. package/theme/base.css +1 -0
  72. package/components/sections/SectionContentFeed.vue +0 -160
  73. package/components/sections/SectionContests.vue +0 -193
  74. package/components/sections/SectionCustomHtml.vue +0 -70
  75. package/components/sections/SectionDivider.vue +0 -55
  76. package/components/sections/SectionEditorial.vue +0 -138
  77. package/components/sections/SectionHeading.vue +0 -78
  78. package/components/sections/SectionHero.vue +0 -164
  79. package/components/sections/SectionHubs.vue +0 -247
  80. package/components/sections/SectionImage.vue +0 -104
  81. package/components/sections/SectionParagraph.vue +0 -55
  82. package/components/sections/SectionStats.vue +0 -151
@@ -35,6 +35,11 @@ import { hubsSection } from './builtin/hubs';
35
35
  import { contestsSection } from './builtin/contests';
36
36
  import { learningSection } from './builtin/learning';
37
37
  import { customHtmlSection } from './builtin/custom-html';
38
+ import { ctaSection } from './builtin/cta';
39
+ import { markdownSection } from './builtin/markdown';
40
+ import { gallerySection } from './builtin/gallery';
41
+ import { videoSection } from './builtin/video';
42
+ import { embedSection } from './builtin/embed';
38
43
 
39
44
  // Singleton — registered once at module load. Vue/Nuxt's setup() runs
40
45
  // per-component, but module load is once per app process. Safe.
@@ -69,6 +74,12 @@ registry.register(hubsSection);
69
74
  registry.register(contestsSection);
70
75
  registry.register(learningSection);
71
76
  registry.register(customHtmlSection);
77
+ // Phase 6b additions (session 159)
78
+ registry.register(ctaSection);
79
+ registry.register(markdownSection);
80
+ registry.register(gallerySection);
81
+ registry.register(videoSection);
82
+ registry.register(embedSection);
72
83
 
73
84
  /**
74
85
  * Read-only accessor — the layer's standard pattern for shared state.
@@ -1,5 +1,6 @@
1
- import { setHomepageSections } from '@commonpub/server';
1
+ import { setHomepageSections, migrateHomepageSectionsToLayout } from '@commonpub/server';
2
2
  import { z } from 'zod';
3
+ import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
3
4
 
4
5
  const sectionConfigSchema = z.object({
5
6
  contentType: z.string().max(64).optional(),
@@ -48,5 +49,55 @@ export default defineEventHandler(async (event) => {
48
49
 
49
50
  await setHomepageSections(db, body.sections, user.id, getRequestIP(event) ?? undefined);
50
51
 
52
+ // When the layout engine is on, the legacy `homepage.sections` JSON
53
+ // is no longer what /pages/index.vue renders — the page reads from
54
+ // the `layouts` table. Without this sync, admin edits made via the
55
+ // existing homepage editor (which still writes homepage.sections,
56
+ // because the Phase 3 layout editor isn't built yet) would have
57
+ // ZERO visible effect on the live page.
58
+ //
59
+ // Fix: after the legacy save succeeds, if layoutEngine is on,
60
+ // **R4 audit P0 data-loss fix (session 160)**: previously this called
61
+ // migrateHomepageSectionsToLayout with force:true on every legacy save,
62
+ // which DELETES the existing layout (cascade → rows, sections,
63
+ // VERSIONS). If an admin had bespoke-edited the homepage via /admin/
64
+ // layouts, those edits + the entire publish history were silently
65
+ // destroyed the next time anyone touched /admin/homepage. Same data-
66
+ // loss path also hit the audit log if two admins were editing in
67
+ // parallel.
68
+ //
69
+ // New semantics: NON-DESTRUCTIVE auto-sync. If a layout doesn't yet
70
+ // exist for ('route','/'), create it from the legacy data so the
71
+ // first-time operator's legacy edits keep working. If a layout DOES
72
+ // exist, leave it alone — the new editor is the source of truth from
73
+ // that point forward. /admin/homepage now shows a deprecation banner
74
+ // (see the page itself) directing operators to /admin/layouts.
75
+ //
76
+ // History: original auto-sync added session 159 (per the comment
77
+ // above) to handle "I removed a section in /admin/homepage but it
78
+ // still renders" — that scenario still works on first-time admins
79
+ // since the layout gets created on first save. After the operator
80
+ // adopts the layout editor, /admin/homepage becomes effectively
81
+ // read-only with respect to the live homepage; the legacy data still
82
+ // saves into instance_settings for backward-compat but is no longer
83
+ // promoted.
84
+ const config = useConfig();
85
+ if (config.features.layoutEngine) {
86
+ try {
87
+ const result = await migrateHomepageSectionsToLayout(db, {
88
+ adminId: user.id,
89
+ force: false, // changed from true — see comment above
90
+ });
91
+ if (result.migrated) {
92
+ invalidateLayoutsByRouteCache();
93
+ }
94
+ } catch (err) {
95
+ // Don't fail the user's save just because the layout sync hit a
96
+ // hiccup. Log + return — they can re-trigger the migration via
97
+ // the dedicated /api/admin/layouts/migrate-homepage endpoint.
98
+ console.error('[admin:homepage.sections] post-save layout sync failed:', err);
99
+ }
100
+ }
101
+
51
102
  return { sections: body.sections, message: 'Homepage updated' };
52
103
  });
@@ -28,6 +28,18 @@ export default defineEventHandler(async (event) => {
28
28
  }
29
29
 
30
30
  const version = await publishLayout(db, id, { publishedBy: admin.id });
31
+
32
+ // Audit log (round 3): publish changes what the public sees — highest-
33
+ // leverage action. layout_versions also stores publishedBy (durable trail);
34
+ // stdout adds incident-response greppability.
35
+ console.info('cpub.audit.layout.publish', JSON.stringify({
36
+ at: new Date().toISOString(),
37
+ adminId: admin.id,
38
+ layoutId: id,
39
+ scope: existing.scope,
40
+ versionId: (version as { id?: string } | null | undefined)?.id ?? null,
41
+ }));
42
+
31
43
  invalidateLayoutsByRouteCache();
32
44
  return version;
33
45
  });
@@ -31,6 +31,17 @@ export default defineEventHandler(async (event) => {
31
31
 
32
32
  try {
33
33
  const reverted = await revertToVersion(db, id, versionId, { userId: admin.id });
34
+
35
+ // Audit log (round 3): revert overwrites current state with a prior
36
+ // snapshot — destructive transformation, deserves forensic trail.
37
+ console.info('cpub.audit.layout.revert', JSON.stringify({
38
+ at: new Date().toISOString(),
39
+ adminId: admin.id,
40
+ layoutId: id,
41
+ scope: existing.scope,
42
+ versionId,
43
+ }));
44
+
34
45
  invalidateLayoutsByRouteCache();
35
46
  return reverted;
36
47
  } catch (e) {
@@ -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
  });
@@ -49,6 +49,18 @@ export default defineEventHandler(async (event) => {
49
49
  force,
50
50
  });
51
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
+
52
64
  if (result.migrated) {
53
65
  invalidateLayoutsByRouteCache();
54
66
  }
@@ -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;
@@ -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
  /**