@commonpub/layer 0.29.0 → 0.31.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 (101) hide show
  1. package/components/ContentCard.vue +13 -3
  2. package/components/CpubMarkdown.vue +46 -0
  3. package/components/NotificationItem.vue +45 -14
  4. package/components/contest/ContestEntries.vue +6 -3
  5. package/components/contest/ContestHero.vue +28 -2
  6. package/components/contest/ContestPrizes.vue +7 -3
  7. package/components/contest/ContestRules.vue +9 -9
  8. package/composables/useFeatures.ts +8 -0
  9. package/nuxt.config.ts +1 -0
  10. package/package.json +9 -9
  11. package/pages/contests/[slug]/edit.vue +88 -15
  12. package/pages/contests/[slug]/index.vue +4 -3
  13. package/pages/contests/[slug]/results.vue +20 -5
  14. package/pages/contests/create.vue +31 -13
  15. package/pages/contests/index.vue +30 -2
  16. package/pages/events/[slug]/index.vue +1 -1
  17. package/pages/notifications.vue +9 -0
  18. package/server/api/admin/api-keys/[id]/usage.get.ts +1 -1
  19. package/server/api/admin/api-keys/[id].delete.ts +1 -1
  20. package/server/api/admin/api-keys/index.get.ts +1 -1
  21. package/server/api/admin/api-keys/index.post.ts +1 -1
  22. package/server/api/admin/audit.get.ts +1 -1
  23. package/server/api/admin/categories/[id].delete.ts +1 -1
  24. package/server/api/admin/categories/[id].patch.ts +1 -1
  25. package/server/api/admin/categories/index.get.ts +1 -1
  26. package/server/api/admin/categories/index.post.ts +1 -1
  27. package/server/api/admin/content/[id].delete.ts +1 -1
  28. package/server/api/admin/content/[id].patch.ts +1 -1
  29. package/server/api/admin/content/bulk-editorial.post.ts +1 -1
  30. package/server/api/admin/features/index.get.ts +1 -1
  31. package/server/api/admin/features/index.put.ts +1 -1
  32. package/server/api/admin/federation/activity.get.ts +1 -1
  33. package/server/api/admin/federation/clients.get.ts +1 -1
  34. package/server/api/admin/federation/clients.post.ts +1 -1
  35. package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
  36. package/server/api/admin/federation/hub-mirrors/index.get.ts +1 -1
  37. package/server/api/admin/federation/hub-mirrors/index.post.ts +1 -1
  38. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  39. package/server/api/admin/federation/mirrors/[id].delete.ts +1 -1
  40. package/server/api/admin/federation/mirrors/[id].get.ts +1 -1
  41. package/server/api/admin/federation/mirrors/[id].put.ts +1 -1
  42. package/server/api/admin/federation/mirrors/index.get.ts +1 -1
  43. package/server/api/admin/federation/mirrors/index.post.ts +1 -1
  44. package/server/api/admin/federation/pending.get.ts +1 -1
  45. package/server/api/admin/federation/refederate.post.ts +1 -1
  46. package/server/api/admin/federation/repair-types.post.ts +1 -1
  47. package/server/api/admin/federation/retry.post.ts +1 -1
  48. package/server/api/admin/federation/stats.get.ts +1 -1
  49. package/server/api/admin/federation/trusted-instances.delete.ts +1 -1
  50. package/server/api/admin/federation/trusted-instances.get.ts +1 -1
  51. package/server/api/admin/federation/trusted-instances.post.ts +1 -1
  52. package/server/api/admin/homepage/sections.get.ts +1 -1
  53. package/server/api/admin/homepage/sections.put.ts +1 -1
  54. package/server/api/admin/layouts/[id]/publish.post.ts +1 -1
  55. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -1
  56. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -1
  57. package/server/api/admin/layouts/[id].delete.ts +1 -1
  58. package/server/api/admin/layouts/[id].get.ts +1 -1
  59. package/server/api/admin/layouts/[id].put.ts +1 -1
  60. package/server/api/admin/layouts/index.get.ts +1 -1
  61. package/server/api/admin/layouts/index.post.ts +1 -1
  62. package/server/api/admin/layouts/migrate-homepage.post.ts +1 -1
  63. package/server/api/admin/layouts/seed-homepage.post.ts +1 -1
  64. package/server/api/admin/navigation/items.get.ts +1 -1
  65. package/server/api/admin/navigation/items.put.ts +1 -1
  66. package/server/api/admin/reports/[id]/resolve.post.ts +1 -1
  67. package/server/api/admin/reports.get.ts +1 -1
  68. package/server/api/admin/search/reindex.post.ts +1 -1
  69. package/server/api/admin/settings.get.ts +1 -1
  70. package/server/api/admin/settings.put.ts +1 -1
  71. package/server/api/admin/stats.get.ts +1 -1
  72. package/server/api/admin/storage/backfill-cdn-urls.post.ts +1 -1
  73. package/server/api/admin/themes/[id].delete.ts +1 -1
  74. package/server/api/admin/themes/[id].get.ts +1 -1
  75. package/server/api/admin/themes/[id].put.ts +1 -1
  76. package/server/api/admin/themes/discover.get.ts +1 -1
  77. package/server/api/admin/themes/index.get.ts +1 -1
  78. package/server/api/admin/themes/index.post.ts +1 -1
  79. package/server/api/admin/users/[id]/role.put.ts +1 -1
  80. package/server/api/admin/users/[id]/status.put.ts +1 -1
  81. package/server/api/admin/users/[id].delete.ts +1 -1
  82. package/server/api/admin/users.get.ts +1 -1
  83. package/server/api/contests/[slug]/entries.get.ts +3 -1
  84. package/server/api/contests/[slug]/index.delete.ts +4 -1
  85. package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
  86. package/server/api/contests/[slug]/judges/index.post.ts +1 -1
  87. package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +1 -1
  88. package/server/api/contests/[slug]/stakeholders/index.get.ts +1 -1
  89. package/server/api/contests/[slug]/stakeholders/index.post.ts +1 -1
  90. package/server/api/docs/migrate-content.post.ts +1 -1
  91. package/server/api/events/[slug].delete.ts +1 -1
  92. package/server/api/events/[slug].put.ts +1 -1
  93. package/server/api/layouts/by-route.get.ts +1 -1
  94. package/server/api/products/[id].delete.ts +1 -1
  95. package/server/api/videos/categories/[id].delete.ts +1 -1
  96. package/server/api/videos/categories/[id].put.ts +1 -1
  97. package/server/api/videos/categories.post.ts +1 -1
  98. package/server/middleware/auth.ts +22 -0
  99. package/server/utils/auth.ts +12 -5
  100. package/server/utils/permissions.ts +97 -0
  101. package/server/utils/requirePermission.ts +102 -0
@@ -5,7 +5,7 @@ import { getHomepageSections } from '@commonpub/server';
5
5
  * Returns homepage sections for admin editing.
6
6
  */
7
7
  export default defineEventHandler(async (event) => {
8
- requireAdmin(event);
8
+ requirePermission(event, 'layout.manage');
9
9
  const db = useDB();
10
10
  return getHomepageSections(db);
11
11
  });
@@ -34,7 +34,7 @@ const updateSectionsSchema = z.object({
34
34
  * Save homepage section configuration.
35
35
  */
36
36
  export default defineEventHandler(async (event) => {
37
- const user = requireAdmin(event);
37
+ const user = requirePermission(event, 'layout.manage');
38
38
  const db = useDB();
39
39
  const body = await parseBody(event, updateSectionsSchema);
40
40
 
@@ -14,7 +14,7 @@ import { invalidateLayoutsByRouteCache } from '../../../../utils/layoutCache';
14
14
  export default defineEventHandler(async (event) => {
15
15
  requireFeature('admin');
16
16
  requireFeature('layoutEngine');
17
- const admin = requireAdmin(event);
17
+ const admin = requirePermission(event, 'layout.manage');
18
18
  const db = useDB();
19
19
 
20
20
  const id = getRouterParam(event, 'id');
@@ -15,7 +15,7 @@ import { invalidateLayoutsByRouteCache } from '../../../../../../utils/layoutCac
15
15
  export default defineEventHandler(async (event) => {
16
16
  requireFeature('admin');
17
17
  requireFeature('layoutEngine');
18
- const admin = requireAdmin(event);
18
+ const admin = requirePermission(event, 'layout.manage');
19
19
  const db = useDB();
20
20
 
21
21
  const id = getRouterParam(event, 'id');
@@ -12,7 +12,7 @@ import { getLayoutById, listLayoutVersions } from '@commonpub/server';
12
12
  export default defineEventHandler(async (event) => {
13
13
  requireFeature('admin');
14
14
  requireFeature('layoutEngine');
15
- requireAdmin(event);
15
+ requirePermission(event, 'layout.manage');
16
16
  const db = useDB();
17
17
 
18
18
  const id = getRouterParam(event, 'id');
@@ -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
- const admin = requireAdmin(event);
18
+ const admin = requirePermission(event, 'layout.manage');
19
19
  const db = useDB();
20
20
 
21
21
  const id = getRouterParam(event, 'id');
@@ -11,7 +11,7 @@ import { getLayoutById } from '@commonpub/server';
11
11
  export default defineEventHandler(async (event) => {
12
12
  requireFeature('admin');
13
13
  requireFeature('layoutEngine');
14
- requireAdmin(event);
14
+ requirePermission(event, 'layout.manage');
15
15
 
16
16
  const id = getRouterParam(event, 'id');
17
17
  if (!id) {
@@ -27,7 +27,7 @@ import { validateSectionConfigs } from '../../../utils/validateSectionConfigs';
27
27
  export default defineEventHandler(async (event) => {
28
28
  requireFeature('admin');
29
29
  requireFeature('layoutEngine');
30
- const admin = requireAdmin(event);
30
+ const admin = requirePermission(event, 'layout.manage');
31
31
  const db = useDB();
32
32
 
33
33
  const id = getRouterParam(event, 'id');
@@ -12,7 +12,7 @@ import { listLayouts } from '@commonpub/server';
12
12
  export default defineEventHandler(async (event) => {
13
13
  requireFeature('admin');
14
14
  requireFeature('layoutEngine');
15
- requireAdmin(event);
15
+ requirePermission(event, 'layout.manage');
16
16
 
17
17
  const db = useDB();
18
18
  const query = getQuery(event) as { scope?: string };
@@ -19,7 +19,7 @@ import { validateSectionConfigs } from '../../../utils/validateSectionConfigs';
19
19
  export default defineEventHandler(async (event) => {
20
20
  requireFeature('admin');
21
21
  requireFeature('layoutEngine');
22
- const admin = requireAdmin(event);
22
+ const admin = requirePermission(event, 'layout.manage');
23
23
  const db = useDB();
24
24
 
25
25
  const body = await parseBody(event, layoutCreateSchema);
@@ -38,7 +38,7 @@ const bodySchema = z.object({
38
38
  export default defineEventHandler(async (event) => {
39
39
  requireFeature('admin');
40
40
  requireFeature('layoutEngine');
41
- const admin = requireAdmin(event);
41
+ const admin = requirePermission(event, 'layout.manage');
42
42
 
43
43
  const body = await readBody(event).catch(() => ({}));
44
44
  const { force } = bodySchema.parse(body ?? {});
@@ -24,7 +24,7 @@ import { invalidateLayoutsByRouteCache } from '../../../utils/layoutCache';
24
24
  export default defineEventHandler(async (event) => {
25
25
  requireFeature('admin');
26
26
  requireFeature('layoutEngine');
27
- const admin = requireAdmin(event);
27
+ const admin = requirePermission(event, 'layout.manage');
28
28
  const db = useDB();
29
29
 
30
30
  const result = await seedHomepageLayout(db, { adminId: admin.id });
@@ -5,7 +5,7 @@ import { getNavItems } from '@commonpub/server';
5
5
  * Returns navigation items for admin editing.
6
6
  */
7
7
  export default defineEventHandler(async (event) => {
8
- requireAdmin(event);
8
+ requirePermission(event, 'navigation.manage');
9
9
  const db = useDB();
10
10
  return getNavItems(db);
11
11
  });
@@ -26,7 +26,7 @@ const updateNavSchema = z.object({
26
26
  * Save navigation item configuration.
27
27
  */
28
28
  export default defineEventHandler(async (event) => {
29
- const user = requireAdmin(event);
29
+ const user = requirePermission(event, 'navigation.manage');
30
30
  const db = useDB();
31
31
  const body = await parseBody(event, updateNavSchema);
32
32
 
@@ -3,7 +3,7 @@ import { resolveReportSchema } from '@commonpub/schema';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<void> => {
5
5
  requireFeature('admin');
6
- const admin = requireAdmin(event);
6
+ const admin = requirePermission(event, 'reports.review');
7
7
  const db = useDB();
8
8
  const { id } = parseParams(event, { id: 'uuid' });
9
9
  const input = await parseBody(event, resolveReportSchema);
@@ -10,7 +10,7 @@ const reportsQuerySchema = z.object({
10
10
 
11
11
  export default defineEventHandler(async (event): Promise<PaginatedResponse<ReportListItem>> => {
12
12
  requireFeature('admin');
13
- requireAdmin(event);
13
+ requirePermission(event, 'reports.review');
14
14
  const db = useDB();
15
15
  const filters = parseQueryParams(event, reportsQuerySchema);
16
16
 
@@ -10,7 +10,7 @@ import type { MeiliClient } from '@commonpub/server';
10
10
  */
11
11
  export default defineEventHandler(async (event) => {
12
12
  const user = requireAuth(event);
13
- requireAdmin(event);
13
+ requirePermission(event, 'search.manage');
14
14
 
15
15
  const meiliUrl = process.env.MEILI_URL;
16
16
  const meiliKey = process.env.MEILI_MASTER_KEY;
@@ -2,7 +2,7 @@ import { getInstanceSettings } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event) => {
4
4
  requireFeature('admin');
5
- requireAdmin(event);
5
+ requirePermission(event, 'settings.manage');
6
6
  const db = useDB();
7
7
  const config = useConfig();
8
8
 
@@ -3,7 +3,7 @@ import { adminSettingSchema } from '@commonpub/schema';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<void> => {
5
5
  requireFeature('admin');
6
- const admin = requireAdmin(event);
6
+ const admin = requirePermission(event, 'settings.manage');
7
7
  const db = useDB();
8
8
  const input = await parseBody(event, adminSettingSchema);
9
9
 
@@ -3,7 +3,7 @@ import type { PlatformStats } from '@commonpub/server';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<PlatformStats> => {
5
5
  requireFeature('admin');
6
- requireAdmin(event);
6
+ requirePermission(event, 'audit.read');
7
7
  const db = useDB();
8
8
  return getPlatformStats(db);
9
9
  });
@@ -34,7 +34,7 @@ function spacesHosts(): { origin: string; cdn: string } | null {
34
34
 
35
35
  export default defineEventHandler(async (event) => {
36
36
  requireAuth(event);
37
- requireAdmin(event);
37
+ requirePermission(event, 'storage.manage');
38
38
 
39
39
  const hosts = spacesHosts();
40
40
  if (!hosts) {
@@ -13,7 +13,7 @@ import {
13
13
 
14
14
  export default defineEventHandler(async (event): Promise<{ ok: true; resetDefault: boolean }> => {
15
15
  requireFeature('admin');
16
- const admin = requireAdmin(event);
16
+ const admin = requirePermission(event, 'theme.manage');
17
17
  const db = useDB();
18
18
 
19
19
  const { id } = parseParams(event, { id: 'string' });
@@ -7,7 +7,7 @@ import { getCustomTheme } from '@commonpub/server';
7
7
 
8
8
  export default defineEventHandler(async (event) => {
9
9
  requireFeature('admin');
10
- requireAdmin(event);
10
+ requirePermission(event, 'theme.manage');
11
11
  const db = useDB();
12
12
 
13
13
  const { id } = parseParams(event, { id: 'string' });
@@ -9,7 +9,7 @@ import { getCustomTheme, saveCustomTheme } from '@commonpub/server';
9
9
 
10
10
  export default defineEventHandler(async (event) => {
11
11
  requireFeature('admin');
12
- const admin = requireAdmin(event);
12
+ const admin = requirePermission(event, 'theme.manage');
13
13
  const db = useDB();
14
14
 
15
15
  const { id } = parseParams(event, { id: 'string' });
@@ -21,7 +21,7 @@ import { TOKEN_SPECS } from '@commonpub/ui';
21
21
 
22
22
  export default defineEventHandler((event) => {
23
23
  requireFeature('admin');
24
- requireAdmin(event);
24
+ requirePermission(event, 'theme.manage');
25
25
 
26
26
  const defaults: Record<string, string> = {};
27
27
  for (const spec of TOKEN_SPECS) {
@@ -17,7 +17,7 @@ import { listCustomThemes } from '@commonpub/server';
17
17
 
18
18
  export default defineEventHandler(async (event) => {
19
19
  requireFeature('admin');
20
- requireAdmin(event);
20
+ requirePermission(event, 'theme.manage');
21
21
  const db = useDB();
22
22
  const config = useConfig();
23
23
 
@@ -12,7 +12,7 @@ const BUILT_IN_IDS = new Set(BUILT_IN_THEMES.map((t) => t.id));
12
12
 
13
13
  export default defineEventHandler(async (event) => {
14
14
  requireFeature('admin');
15
- const admin = requireAdmin(event);
15
+ const admin = requirePermission(event, 'theme.manage');
16
16
  const db = useDB();
17
17
 
18
18
  const input = await parseBody(event, customThemeSchema);
@@ -3,7 +3,7 @@ import { adminUpdateRoleSchema } from '@commonpub/schema';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<void> => {
5
5
  requireFeature('admin');
6
- const admin = requireAdmin(event);
6
+ const admin = requirePermission(event, 'users.manage');
7
7
  const db = useDB();
8
8
  const { id } = parseParams(event, { id: 'uuid' });
9
9
  const input = await parseBody(event, adminUpdateRoleSchema);
@@ -3,7 +3,7 @@ import { adminUpdateStatusSchema } from '@commonpub/schema';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<void> => {
5
5
  requireFeature('admin');
6
- const admin = requireAdmin(event);
6
+ const admin = requirePermission(event, 'users.manage');
7
7
  const db = useDB();
8
8
  const { id } = parseParams(event, { id: 'uuid' });
9
9
  const input = await parseBody(event, adminUpdateStatusSchema);
@@ -2,7 +2,7 @@ import { deleteUser } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event): Promise<void> => {
4
4
  requireFeature('admin');
5
- const admin = requireAdmin(event);
5
+ const admin = requirePermission(event, 'users.delete');
6
6
  const db = useDB();
7
7
  const { id } = parseParams(event, { id: 'uuid' });
8
8
 
@@ -10,7 +10,7 @@ const adminUsersQuerySchema = z.object({
10
10
 
11
11
  export default defineEventHandler(async (event): Promise<PaginatedResponse<UserListItem>> => {
12
12
  requireFeature('admin');
13
- requireAdmin(event);
13
+ requirePermission(event, 'users.read');
14
14
  const db = useDB();
15
15
  const filters = parseQueryParams(event, adminUsersQuerySchema);
16
16
 
@@ -6,6 +6,7 @@ const entriesQuerySchema = z.object({
6
6
  limit: z.coerce.number().int().min(1).max(100).optional(),
7
7
  offset: z.coerce.number().int().min(0).optional(),
8
8
  includeJudgeScores: z.coerce.boolean().optional(),
9
+ order: z.enum(['recent', 'rank']).optional(),
9
10
  });
10
11
 
11
12
  export default defineEventHandler(async (event): Promise<{ items: ContestEntryItem[]; total: number }> => {
@@ -29,13 +30,14 @@ export default defineEventHandler(async (event): Promise<{ items: ContestEntryIt
29
30
  if (user) {
30
31
  privileged =
31
32
  user.id === contest.createdById ||
32
- user.role === 'admin' ||
33
+ hasPermission(event, 'contest.manage') ||
33
34
  (await isContestJudge(db, contest.id, user.id));
34
35
  }
35
36
 
36
37
  return listContestEntries(db, contest.id, {
37
38
  limit: query.limit,
38
39
  offset: query.offset,
40
+ orderBy: query.order,
39
41
  includeJudgeScores: privileged && query.includeJudgeScores,
40
42
  revealScores: shouldRevealScores(contest.judgingVisibility, contest.status, privileged),
41
43
  });
@@ -12,7 +12,10 @@ export default defineEventHandler(async (event): Promise<{ deleted: boolean }> =
12
12
  throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
13
13
  }
14
14
 
15
- const deleted = await deleteContest(db, contest.id, user.id);
15
+ // Owner OR a contest.manage holder (RBAC Phase 1) may delete. Flag-off this is
16
+ // owner-or-admin, byte-identical to before; flag-on a custom managing role works.
17
+ const canManage = ownerOrPermission(event, contest.createdById, 'contest.manage');
18
+ const deleted = await deleteContest(db, contest.id, user.id, canManage);
16
19
  if (!deleted) {
17
20
  throw createError({ statusCode: 403, statusMessage: 'Not authorized to delete this contest' });
18
21
  }
@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
15
15
  const contest = await getContestBySlug(db, slug);
16
16
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
17
17
 
18
- if (contest.createdById !== user.id && user.role !== 'admin') {
18
+ if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
19
19
  throw createError({ statusCode: 403, statusMessage: 'Only contest owner or admin can manage judges' });
20
20
  }
21
21
 
@@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => {
21
21
  const contest = await getContestBySlug(db, slug);
22
22
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
23
23
 
24
- if (contest.createdById !== user.id && user.role !== 'admin') {
24
+ if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
25
25
  throw createError({ statusCode: 403, statusMessage: 'Only contest owner or admin can manage judges' });
26
26
  }
27
27
 
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
14
14
 
15
15
  const contest = await getContestBySlug(db, slug);
16
16
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
17
- if (contest.createdById !== user.id && user.role !== 'admin') {
17
+ if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
18
18
  throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can manage stakeholders' });
19
19
  }
20
20
 
@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
13
13
 
14
14
  const contest = await getContestBySlug(db, slug);
15
15
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
16
- if (contest.createdById !== user.id && user.role !== 'admin') {
16
+ if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
17
17
  throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can view stakeholders' });
18
18
  }
19
19
 
@@ -16,7 +16,7 @@ export default defineEventHandler(async (event) => {
16
16
 
17
17
  const contest = await getContestBySlug(db, slug);
18
18
  if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
19
- if (contest.createdById !== user.id && user.role !== 'admin') {
19
+ if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
20
20
  throw createError({ statusCode: 403, statusMessage: 'Only the contest owner or admin can manage stakeholders' });
21
21
  }
22
22
 
@@ -16,7 +16,7 @@ import { docsPages } from '@commonpub/schema';
16
16
  import { eq } from 'drizzle-orm';
17
17
 
18
18
  export default defineEventHandler(async (event) => {
19
- requireAdmin(event);
19
+ requirePermission(event, 'content.editorial');
20
20
  const db = useDB();
21
21
 
22
22
  // Fetch all pages
@@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
14
14
  const existing = await getEventBySlug(db, slug);
15
15
  if (!existing) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
16
16
 
17
- const isAdmin = user.role === 'admin';
17
+ const isAdmin = hasPermission(event, 'event.manage');
18
18
  const deleted = await deleteEvent(db, existing.id, user.id, isAdmin);
19
19
  if (!deleted) throw createError({ statusCode: 403, statusMessage: 'Unauthorized' });
20
20
 
@@ -29,7 +29,7 @@ export default defineEventHandler(async (event) => {
29
29
  if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' });
30
30
 
31
31
  const body = await parseBody(event, updateEventSchema);
32
- const isAdmin = user.role === 'admin';
32
+ const isAdmin = hasPermission(event, 'event.manage');
33
33
 
34
34
  const result = await updateEvent(db, slug, user.id, body, isAdmin);
35
35
  if (!result) throw createError({ statusCode: 404, statusMessage: 'Event not found or unauthorized' });
@@ -71,7 +71,7 @@ export default defineEventHandler(async (event): Promise<PublicLayoutSlice | nul
71
71
  // requester's access tier so a layout served to a higher tier can't
72
72
  // leak via cache to a lower tier on the same path.
73
73
  const user = getOptionalUser(event);
74
- const isAdmin = user?.role === 'admin';
74
+ const isAdmin = hasPermission(event, 'layout.manage');
75
75
  const tier: 'admin' | 'members' | 'anon' = isAdmin ? 'admin' : user ? 'members' : 'anon';
76
76
  const cacheKey = `${tier}:${path}`;
77
77
 
@@ -2,7 +2,7 @@ import { deleteProduct } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event): Promise<{ deleted: boolean }> => {
4
4
  const db = useDB();
5
- requireAdmin(event);
5
+ requirePermission(event, 'content.moderate');
6
6
  const { id } = parseParams(event, { id: 'uuid' });
7
7
 
8
8
  const deleted = await deleteProduct(db, id);
@@ -1,7 +1,7 @@
1
1
  import { deleteVideoCategory } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
4
- requireAdmin(event);
4
+ requirePermission(event, 'categories.manage');
5
5
 
6
6
  const { id } = parseParams(event, { id: 'uuid' });
7
7
 
@@ -3,7 +3,7 @@ import type { VideoCategoryItem } from '@commonpub/server';
3
3
  import { createVideoCategorySchema } from '@commonpub/schema';
4
4
 
5
5
  export default defineEventHandler(async (event): Promise<VideoCategoryItem> => {
6
- requireAdmin(event);
6
+ requirePermission(event, 'categories.manage');
7
7
  const { id } = parseParams(event, { id: 'uuid' });
8
8
  const input = await parseBody(event, createVideoCategorySchema.partial());
9
9
 
@@ -3,7 +3,7 @@ import type { VideoCategoryItem } from '@commonpub/server';
3
3
  import { createVideoCategorySchema } from '@commonpub/schema';
4
4
 
5
5
  export default defineEventHandler(async (event): Promise<VideoCategoryItem> => {
6
- requireAdmin(event);
6
+ requirePermission(event, 'categories.manage');
7
7
  const db = useDB();
8
8
  const input = await parseBody(event, createVideoCategorySchema);
9
9
 
@@ -55,6 +55,26 @@ declare module 'h3' {
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * Attach the user's resolved permissions to the request context (RBAC Phase 0).
60
+ * One cached Map hit on the hot path (30s TTL), like feature-flags-prime. Reads
61
+ * the userId from the already-enriched auth user. Fail-closed: on any error the
62
+ * context stays unset and the guards default-deny — but the admin floor still
63
+ * holds because `requirePermission` falls back to the enriched `user.role`
64
+ * (INV-2). No-op for anon requests. `resolvePermissions` is a Nitro auto-import.
65
+ */
66
+ async function attachPermissions(event: import('h3').H3Event, auth: AuthLocals): Promise<void> {
67
+ if (!auth?.user?.id) return;
68
+ try {
69
+ // Pass the enriched role so the resolver skips its own users query (admin +
70
+ // flag-off paths do zero extra DB work) and stays consistent with enrichUser.
71
+ const primaryRole = (auth.user as unknown as { role?: string }).role;
72
+ event.context.cpubPermissions = await resolvePermissions(auth.user.id, primaryRole);
73
+ } catch {
74
+ // Leave unset — guards default-deny; admin floor survives via user.role.
75
+ }
76
+ }
77
+
58
78
  /**
59
79
  * Enrich the session user with custom DB columns (role, username, status)
60
80
  * that Better Auth doesn't include by default.
@@ -89,6 +109,7 @@ export default defineEventHandler(async (event) => {
89
109
  const webHeaders = new Headers(headers as Record<string, string>);
90
110
  event.context.auth = await middleware.resolveSession(webHeaders);
91
111
  await enrichUser(event.context.auth);
112
+ await attachPermissions(event, event.context.auth);
92
113
  } catch {
93
114
  event.context.auth = { user: null, session: null };
94
115
  }
@@ -143,6 +164,7 @@ export default defineEventHandler(async (event) => {
143
164
  const webHeaders = new Headers(headers as Record<string, string>);
144
165
  event.context.auth = await middleware.resolveSession(webHeaders);
145
166
  await enrichUser(event.context.auth);
167
+ await attachPermissions(event, event.context.auth);
146
168
  } catch (err: unknown) {
147
169
  // DB error during session resolution — don't silently eat it for API routes
148
170
  if (pathname.startsWith('/api/')) {
@@ -22,12 +22,19 @@ export function requireAuth(event: H3Event): AuthUser {
22
22
  return auth.user as AuthUser;
23
23
  }
24
24
 
25
+ /**
26
+ * Admin gate — the linchpin reimplemented (session 175, RBAC Phase 0) as
27
+ * `requirePermission(event, 'admin.access')`, routing all ~73 call sites through
28
+ * the permission machinery without changing any of them. `admin.access` is
29
+ * seeded ONLY to the admin role, and the resolver's admin-floor + flag-off
30
+ * legacy mapping make this bit-identical to the old `user.role === 'admin'`
31
+ * check (INV-1). The legacy 403 message is preserved verbatim.
32
+ *
33
+ * `requirePermission` is a Nitro auto-import (sibling util) — referenced without
34
+ * a static import so there's no import cycle with requirePermission.ts.
35
+ */
25
36
  export function requireAdmin(event: H3Event): AuthUser {
26
- const user = requireAuth(event);
27
- if (user.role !== 'admin') {
28
- throw createError({ statusCode: 403, statusMessage: 'Admin access required' });
29
- }
30
- return user;
37
+ return requirePermission(event, 'admin.access', 'Admin access required');
31
38
  }
32
39
 
33
40
  export function getOptionalUser(event: H3Event): AuthUser | null {