@commonpub/layer 0.29.0 → 0.30.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 (100) 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 +23 -2
  6. package/components/contest/ContestPrizes.vue +2 -2
  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 +8 -8
  11. package/pages/contests/[slug]/edit.vue +80 -15
  12. package/pages/contests/[slug]/index.vue +2 -1
  13. package/pages/contests/[slug]/results.vue +20 -5
  14. package/pages/contests/create.vue +24 -13
  15. package/pages/events/[slug]/index.vue +1 -1
  16. package/pages/notifications.vue +9 -0
  17. package/server/api/admin/api-keys/[id]/usage.get.ts +1 -1
  18. package/server/api/admin/api-keys/[id].delete.ts +1 -1
  19. package/server/api/admin/api-keys/index.get.ts +1 -1
  20. package/server/api/admin/api-keys/index.post.ts +1 -1
  21. package/server/api/admin/audit.get.ts +1 -1
  22. package/server/api/admin/categories/[id].delete.ts +1 -1
  23. package/server/api/admin/categories/[id].patch.ts +1 -1
  24. package/server/api/admin/categories/index.get.ts +1 -1
  25. package/server/api/admin/categories/index.post.ts +1 -1
  26. package/server/api/admin/content/[id].delete.ts +1 -1
  27. package/server/api/admin/content/[id].patch.ts +1 -1
  28. package/server/api/admin/content/bulk-editorial.post.ts +1 -1
  29. package/server/api/admin/features/index.get.ts +1 -1
  30. package/server/api/admin/features/index.put.ts +1 -1
  31. package/server/api/admin/federation/activity.get.ts +1 -1
  32. package/server/api/admin/federation/clients.get.ts +1 -1
  33. package/server/api/admin/federation/clients.post.ts +1 -1
  34. package/server/api/admin/federation/hub-mirrors/[id]/backfill.post.ts +1 -1
  35. package/server/api/admin/federation/hub-mirrors/index.get.ts +1 -1
  36. package/server/api/admin/federation/hub-mirrors/index.post.ts +1 -1
  37. package/server/api/admin/federation/mirrors/[id]/backfill.post.ts +1 -1
  38. package/server/api/admin/federation/mirrors/[id].delete.ts +1 -1
  39. package/server/api/admin/federation/mirrors/[id].get.ts +1 -1
  40. package/server/api/admin/federation/mirrors/[id].put.ts +1 -1
  41. package/server/api/admin/federation/mirrors/index.get.ts +1 -1
  42. package/server/api/admin/federation/mirrors/index.post.ts +1 -1
  43. package/server/api/admin/federation/pending.get.ts +1 -1
  44. package/server/api/admin/federation/refederate.post.ts +1 -1
  45. package/server/api/admin/federation/repair-types.post.ts +1 -1
  46. package/server/api/admin/federation/retry.post.ts +1 -1
  47. package/server/api/admin/federation/stats.get.ts +1 -1
  48. package/server/api/admin/federation/trusted-instances.delete.ts +1 -1
  49. package/server/api/admin/federation/trusted-instances.get.ts +1 -1
  50. package/server/api/admin/federation/trusted-instances.post.ts +1 -1
  51. package/server/api/admin/homepage/sections.get.ts +1 -1
  52. package/server/api/admin/homepage/sections.put.ts +1 -1
  53. package/server/api/admin/layouts/[id]/publish.post.ts +1 -1
  54. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -1
  55. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -1
  56. package/server/api/admin/layouts/[id].delete.ts +1 -1
  57. package/server/api/admin/layouts/[id].get.ts +1 -1
  58. package/server/api/admin/layouts/[id].put.ts +1 -1
  59. package/server/api/admin/layouts/index.get.ts +1 -1
  60. package/server/api/admin/layouts/index.post.ts +1 -1
  61. package/server/api/admin/layouts/migrate-homepage.post.ts +1 -1
  62. package/server/api/admin/layouts/seed-homepage.post.ts +1 -1
  63. package/server/api/admin/navigation/items.get.ts +1 -1
  64. package/server/api/admin/navigation/items.put.ts +1 -1
  65. package/server/api/admin/reports/[id]/resolve.post.ts +1 -1
  66. package/server/api/admin/reports.get.ts +1 -1
  67. package/server/api/admin/search/reindex.post.ts +1 -1
  68. package/server/api/admin/settings.get.ts +1 -1
  69. package/server/api/admin/settings.put.ts +1 -1
  70. package/server/api/admin/stats.get.ts +1 -1
  71. package/server/api/admin/storage/backfill-cdn-urls.post.ts +1 -1
  72. package/server/api/admin/themes/[id].delete.ts +1 -1
  73. package/server/api/admin/themes/[id].get.ts +1 -1
  74. package/server/api/admin/themes/[id].put.ts +1 -1
  75. package/server/api/admin/themes/discover.get.ts +1 -1
  76. package/server/api/admin/themes/index.get.ts +1 -1
  77. package/server/api/admin/themes/index.post.ts +1 -1
  78. package/server/api/admin/users/[id]/role.put.ts +1 -1
  79. package/server/api/admin/users/[id]/status.put.ts +1 -1
  80. package/server/api/admin/users/[id].delete.ts +1 -1
  81. package/server/api/admin/users.get.ts +1 -1
  82. package/server/api/contests/[slug]/entries.get.ts +3 -1
  83. package/server/api/contests/[slug]/index.delete.ts +4 -1
  84. package/server/api/contests/[slug]/judges/[userId].delete.ts +1 -1
  85. package/server/api/contests/[slug]/judges/index.post.ts +1 -1
  86. package/server/api/contests/[slug]/stakeholders/[userId].delete.ts +1 -1
  87. package/server/api/contests/[slug]/stakeholders/index.get.ts +1 -1
  88. package/server/api/contests/[slug]/stakeholders/index.post.ts +1 -1
  89. package/server/api/docs/migrate-content.post.ts +1 -1
  90. package/server/api/events/[slug].delete.ts +1 -1
  91. package/server/api/events/[slug].put.ts +1 -1
  92. package/server/api/layouts/by-route.get.ts +1 -1
  93. package/server/api/products/[id].delete.ts +1 -1
  94. package/server/api/videos/categories/[id].delete.ts +1 -1
  95. package/server/api/videos/categories/[id].put.ts +1 -1
  96. package/server/api/videos/categories.post.ts +1 -1
  97. package/server/middleware/auth.ts +22 -0
  98. package/server/utils/auth.ts +12 -5
  99. package/server/utils/permissions.ts +97 -0
  100. package/server/utils/requirePermission.ts +102 -0
@@ -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 {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Cached per-user permission resolver (Nitro util).
3
+ *
4
+ * Wraps the pure `resolveUserPermissions` core (@commonpub/server) with a short
5
+ * TTL + bounded LRU + explicit invalidation — modeled on `config.ts`'s DB-cache
6
+ * pattern and `layoutCache.ts`'s bounded map. Lives in the LAYER (not the app)
7
+ * so it ships to every consumer via @commonpub/layer: the auth middleware,
8
+ * `requireAdmin`, and `requirePermission` that consume it are all in the layer,
9
+ * and the layer already calls `useDB()`/`useConfig()` from layer code on every
10
+ * instance (see middleware/auth.ts). See docs/plans/rbac.md (Phase 0 location
11
+ * correction).
12
+ *
13
+ * The flag lives ONLY here (in the resolver), never in the guards —
14
+ * `requireFeature('rbac')` would 404 admin endpoints when off. With the flag
15
+ * off, the core returns the legacy mapping (admin→all, else→none) ⇒
16
+ * byte-identical to pre-RBAC (INV-1).
17
+ *
18
+ * Auth-critical → 30s TTL (favor freshness so a demote/revoke takes effect
19
+ * within the window, not on re-login — the set is never baked into the session
20
+ * token). Invalidate AFTER the DB commit; per-process so it clears the local
21
+ * node only (≤30s elsewhere — documented multi-pod staleness, acceptable v1).
22
+ */
23
+ import { resolveUserPermissions, type ResolvedPermissions } from '@commonpub/server';
24
+
25
+ export const PERMISSIONS_CACHE_TTL_MS = 30_000;
26
+
27
+ /** Caps memory across adversarial/large user populations (bounded LRU). */
28
+ export const MAX_PERMISSION_ENTRIES = 5000;
29
+
30
+ interface CacheEntry {
31
+ value: ResolvedPermissions;
32
+ at: number;
33
+ }
34
+
35
+ const cache = new Map<string, CacheEntry>();
36
+
37
+ function readFresh(userId: string, now: number): ResolvedPermissions | null {
38
+ const entry = cache.get(userId);
39
+ if (!entry) return null;
40
+ if (now - entry.at > PERMISSIONS_CACHE_TTL_MS) {
41
+ cache.delete(userId);
42
+ return null;
43
+ }
44
+ // LRU touch — move to newest end so eviction stays O(1) oldest-first.
45
+ cache.delete(userId);
46
+ cache.set(userId, entry);
47
+ return entry.value;
48
+ }
49
+
50
+ function store(userId: string, value: ResolvedPermissions, now: number): void {
51
+ if (cache.has(userId)) cache.delete(userId);
52
+ cache.set(userId, { value, at: now });
53
+ while (cache.size > MAX_PERMISSION_ENTRIES) {
54
+ const oldest = cache.keys().next().value;
55
+ if (oldest === undefined) break;
56
+ cache.delete(oldest);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Resolve (and cache) a user's effective permissions. Reads the `features.rbac`
62
+ * flag from the merged config; never throws (the core default-denies on error,
63
+ * and the admin floor is enforced downstream via `primaryRole`).
64
+ *
65
+ * @param primaryRole the already-enriched `users.role` (the auth middleware has
66
+ * it). Passing it lets the core skip its own users query — so the admin and
67
+ * flag-off hot paths do ZERO extra DB work — and keeps resolution consistent
68
+ * with the enrich query.
69
+ */
70
+ export async function resolvePermissions(
71
+ userId: string,
72
+ primaryRole?: string,
73
+ ): Promise<ResolvedPermissions> {
74
+ const now = Date.now();
75
+ const cached = readFresh(userId, now);
76
+ if (cached) return cached;
77
+
78
+ const rbacEnabled = useConfig().features.rbac === true;
79
+ const resolved = await resolveUserPermissions(useDB(), userId, { rbacEnabled, primaryRole });
80
+ store(userId, resolved, now);
81
+ return resolved;
82
+ }
83
+
84
+ /** Drop one user's cached permissions. Call AFTER a role/grant DB commit. */
85
+ export function invalidatePermissions(userId: string): void {
86
+ cache.delete(userId);
87
+ }
88
+
89
+ /** Drop the whole cache — the RBAC kill-switch companion to flipping the flag off. */
90
+ export function invalidateAllPermissions(): void {
91
+ cache.clear();
92
+ }
93
+
94
+ /** Cache size — test-only inspection. */
95
+ export function _permissionsCacheSize(): number {
96
+ return cache.size;
97
+ }
@@ -0,0 +1,102 @@
1
+ import { hasPermissionPure } from '@commonpub/auth';
2
+ import type { PermissionKey } from '@commonpub/schema';
3
+ import type { ResolvedPermissions } from '@commonpub/server';
4
+ import type { H3Event } from 'h3';
5
+ import type { AuthUser } from './auth';
6
+
7
+ // `requireAuth`, `getOptionalUser`, and `createError` are Nitro/h3 auto-imports
8
+ // (same as the rest of the layer's server utils) — referenced without a static
9
+ // import so there's no import cycle with auth.ts (which calls requirePermission
10
+ // to reimplement requireAdmin).
11
+
12
+ declare module 'h3' {
13
+ interface H3EventContext {
14
+ /**
15
+ * Effective permissions for the request's user, attached by the auth
16
+ * middleware via `resolvePermissions()`. Absent for anon / unenriched
17
+ * requests — guards default-deny when missing (INV-3).
18
+ */
19
+ cpubPermissions?: ResolvedPermissions;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Server-side permission gate — the single choke-point all instance-wide
25
+ * authorization routes through. Mirrors `requireScope.ts`: reads the resolved
26
+ * set the middleware attached (`event.context.cpubPermissions`) and the primary
27
+ * role (admin floor), then defers the decision to the pure `hasPermissionPure`.
28
+ *
29
+ * 401 if anon, 403 if the user lacks `needed`. NOT wrapped in
30
+ * `requireFeature('rbac')` — the flag lives only in the resolver, so admin
31
+ * endpoints keep working with the flag off (a flag-gated guard would 404 them).
32
+ * With the flag off the resolver yields the legacy mapping ⇒ this is
33
+ * byte-identical to the old `requireAdmin` for `admin.access` (INV-1).
34
+ *
35
+ * @param statusMessage Optional 403 message override (lets `requireAdmin`
36
+ * preserve its exact legacy "Admin access required" wording).
37
+ */
38
+ export function requirePermission(
39
+ event: H3Event,
40
+ needed: PermissionKey,
41
+ statusMessage?: string,
42
+ ): AuthUser {
43
+ const user = requireAuth(event);
44
+ const resolved = event.context.cpubPermissions;
45
+ const granted = resolved?.permissions ?? new Set<string>();
46
+ // Admin floor (INV-2) reads the AUTHORITATIVE enriched `user.role` from
47
+ // requireAuth — never `resolved.primaryRole`. If the resolver default-denied
48
+ // on a DB error it returns an empty set with `primaryRole: ''`, and `?? `
49
+ // would NOT fall back from a defined empty string — locking out admins for a
50
+ // TTL window. `user.role` comes from the same enrich query the old
51
+ // requireAdmin trusted, so no DB state can lock out admin.
52
+ const primaryRole = user.role || resolved?.primaryRole;
53
+
54
+ if (!hasPermissionPure(granted, needed, primaryRole)) {
55
+ throw createError({
56
+ statusCode: 403,
57
+ statusMessage: statusMessage ?? `Missing permission: ${needed}`,
58
+ });
59
+ }
60
+ return user;
61
+ }
62
+
63
+ /**
64
+ * Non-throwing permission check — for owner-OR-permission cases (the ad-hoc
65
+ * `user.role === 'x'` sites migrated in Phase 1) and client-driving endpoints.
66
+ * Returns false for anon. Reads the same attached context as requirePermission.
67
+ */
68
+ export function hasPermission(event: H3Event, needed: PermissionKey): boolean {
69
+ const user = getOptionalUser(event);
70
+ if (!user) return false;
71
+ const resolved = event.context.cpubPermissions;
72
+ const granted = resolved?.permissions ?? new Set<string>();
73
+ // Authoritative enriched role for the admin floor — see requirePermission.
74
+ const primaryRole = user.role || resolved?.primaryRole;
75
+ return hasPermissionPure(granted, needed, primaryRole);
76
+ }
77
+
78
+ /**
79
+ * Owner-OR-permission gate — the Phase 1 replacement for the ad-hoc
80
+ * `resource.ownerId === user.id || user.role === 'admin'` idiom on resource
81
+ * routes (contest judges/stakeholders, etc). Returns true if the caller owns the
82
+ * resource OR holds `needed`. Non-throwing (returns false for anon / neither).
83
+ *
84
+ * Flag-off this is byte-identical to the old idiom: `hasPermission` floors on the
85
+ * admin role, so `owner || hasPermission(…)` ≡ `owner || role==='admin'`. Flag-on,
86
+ * a custom/staff role granted `needed` also passes — the intended broadening.
87
+ *
88
+ * The plan filed this under `packages/server/src/rbac/`, but — like the resolver
89
+ * (session-175 location correction) — it operates on an H3 `event` + the
90
+ * middleware-attached context, so it lives in the layer beside its siblings
91
+ * `requirePermission`/`hasPermission`. The pure core is `hasPermissionPure`.
92
+ */
93
+ export function ownerOrPermission(
94
+ event: H3Event,
95
+ ownerId: string | null | undefined,
96
+ needed: PermissionKey,
97
+ ): boolean {
98
+ const user = getOptionalUser(event);
99
+ if (!user) return false;
100
+ if (ownerId && user.id === ownerId) return true;
101
+ return hasPermission(event, needed);
102
+ }