@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.
- package/README.md +41 -12
- package/components/LayoutRow.vue +944 -0
- package/components/LayoutSection.vue +1028 -0
- package/components/LayoutSlot.vue +104 -162
- package/components/PageFrame.vue +116 -0
- package/components/admin/layouts/AdminLayoutsAnnouncer.vue +53 -0
- package/components/admin/layouts/AdminLayoutsAutoForm.vue +419 -0
- package/components/admin/layouts/AdminLayoutsCanvas.vue +332 -0
- package/components/admin/layouts/AdminLayoutsConflictModal.vue +266 -0
- package/components/admin/layouts/AdminLayoutsHelpOverlay.vue +346 -0
- package/components/admin/layouts/AdminLayoutsInspector.vue +157 -0
- package/components/admin/layouts/AdminLayoutsInspectorPage.vue +266 -0
- package/components/admin/layouts/AdminLayoutsInspectorRow.vue +80 -0
- package/components/admin/layouts/AdminLayoutsInspectorSection.vue +175 -0
- package/components/admin/layouts/AdminLayoutsPalette.vue +117 -0
- package/components/admin/layouts/AdminLayoutsPaletteTile.vue +149 -0
- package/components/admin/layouts/AdminLayoutsToolbar.vue +483 -0
- package/components/blocks/BlockDividerView.vue +52 -2
- package/components/homepage/ContentGridSection.vue +23 -1
- package/components/homepage/HeroSection.vue +69 -8
- package/components/sections/SectionCta.vue +175 -0
- package/components/sections/SectionLearning.vue +232 -0
- package/composables/autoFormSchema.ts +319 -0
- package/composables/useAdminSidebar.ts +116 -0
- package/composables/useEditorChrome.ts +56 -0
- package/composables/useFeatures.ts +32 -5
- package/composables/useLayout.ts +46 -43
- package/composables/useLayoutAnnouncer.ts +332 -0
- package/composables/useLayoutAutoSave.ts +117 -0
- package/composables/useLayoutDrag.ts +290 -0
- package/composables/useLayoutEditor.ts +593 -0
- package/composables/useLayoutHistory.ts +583 -0
- package/composables/useLayoutHotkeys.ts +366 -0
- package/composables/useLayoutResize.ts +783 -0
- package/layouts/admin.vue +137 -24
- package/middleware/admin-layouts.ts +29 -0
- package/nuxt.config.ts +14 -0
- package/package.json +8 -5
- package/pages/[...customPath].vue +154 -0
- package/pages/admin/homepage.vue +46 -0
- package/pages/admin/index.vue +16 -0
- package/pages/admin/layouts/[id].vue +1110 -0
- package/pages/admin/layouts/index.vue +356 -0
- package/pages/explore.vue +16 -6
- package/sections/builtin/content-feed.ts +18 -29
- package/sections/builtin/contests.ts +30 -0
- package/sections/builtin/cta.ts +46 -0
- package/sections/builtin/custom-html.ts +36 -0
- package/sections/builtin/divider.ts +15 -17
- package/sections/builtin/editorial.ts +29 -0
- package/sections/builtin/embed.ts +31 -0
- package/sections/builtin/gallery.ts +29 -0
- package/sections/builtin/heading.ts +14 -19
- package/sections/builtin/hero.ts +16 -51
- package/sections/builtin/hubs.ts +30 -0
- package/sections/builtin/image.ts +12 -49
- package/sections/builtin/learning.ts +30 -0
- package/sections/builtin/markdown.ts +29 -0
- package/sections/builtin/paragraph.ts +14 -17
- package/sections/builtin/stats.ts +35 -0
- package/sections/builtin/video.ts +30 -0
- package/sections/registry.ts +38 -7
- package/server/api/admin/homepage/sections.put.ts +52 -1
- package/server/api/admin/layouts/[id]/publish.post.ts +12 -0
- package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +11 -0
- package/server/api/admin/layouts/[id].delete.ts +33 -1
- package/server/api/admin/layouts/[id].put.ts +78 -0
- package/server/api/admin/layouts/index.post.ts +60 -4
- package/server/api/admin/layouts/migrate-homepage.post.ts +68 -0
- package/server/api/admin/layouts/seed-homepage.post.ts +9 -0
- package/server/api/layouts/by-route.get.ts +64 -12
- package/server/plugins/feature-flags-prime.ts +39 -0
- package/server/utils/layoutCache.ts +37 -1
- package/server/utils/validateSectionConfigs.ts +123 -0
- package/theme/base.css +1 -0
- package/components/sections/SectionContentFeed.vue +0 -160
- package/components/sections/SectionDivider.vue +0 -55
- package/components/sections/SectionHeading.vue +0 -78
- package/components/sections/SectionHero.vue +0 -164
- package/components/sections/SectionImage.vue +0 -104
- 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
|
-
|
|
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(
|
|
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:
|
|
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.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|