@commonpub/layer 0.81.0 → 0.83.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 (212) hide show
  1. package/components/AppToast.vue +1 -1
  2. package/components/ContentAvatar.vue +98 -0
  3. package/components/CpubCriteriaBar.vue +88 -0
  4. package/components/CpubDateTimeField.vue +73 -0
  5. package/components/CpubMarkdown.vue +3 -1
  6. package/components/FormatToggle.vue +2 -2
  7. package/components/ImageUpload.vue +5 -8
  8. package/components/MirrorDetailModal.vue +3 -1
  9. package/components/MirrorRequestApproveModal.vue +3 -1
  10. package/components/ProductEditModal.vue +184 -0
  11. package/components/RemoteFollowDialog.vue +2 -2
  12. package/components/SearchSidebar.vue +14 -21
  13. package/components/ShareToHubModal.vue +3 -1
  14. package/components/admin/layouts/AdminLayoutsPalette.vue +5 -1
  15. package/components/admin/layouts/AdminLayoutsPaletteTile.vue +7 -1
  16. package/components/admin/layouts/AdminLayoutsToolbar.vue +1 -1
  17. package/components/blocks/BlockCompareColumnsView.vue +92 -0
  18. package/components/blocks/BlockContentRenderer.vue +17 -0
  19. package/components/blocks/BlockCriteriaBarView.vue +25 -0
  20. package/components/blocks/BlockGalleryView.vue +5 -0
  21. package/components/blocks/BlockHtmlView.vue +26 -0
  22. package/components/blocks/BlockImageView.vue +4 -0
  23. package/components/blocks/BlockJudgesShowcaseView.vue +52 -0
  24. package/components/blocks/BlockRoadmapView.vue +84 -0
  25. package/components/blocks/BlockSponsorsView.vue +89 -0
  26. package/components/blocks/BlockTableView.vue +49 -0
  27. package/components/blocks/BlockTabsView.vue +121 -0
  28. package/components/contest/ContestBodyCanvas.vue +155 -0
  29. package/components/contest/ContestCriteriaEditor.vue +79 -0
  30. package/components/contest/ContestEditor.vue +948 -0
  31. package/components/contest/ContestEntries.vue +1 -1
  32. package/components/contest/ContestEntryPrivateData.vue +126 -0
  33. package/components/contest/ContestHero.vue +114 -186
  34. package/components/contest/ContestJudgeManager.vue +6 -4
  35. package/components/contest/ContestJudgingCriteria.vue +5 -21
  36. package/components/contest/ContestPrizes.vue +8 -1
  37. package/components/contest/ContestProposalForm.vue +88 -0
  38. package/components/contest/ContestRules.vue +8 -1
  39. package/components/contest/ContestSidebar.vue +11 -3
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +54 -20
  43. package/components/contest/ContestSubmissionField.vue +141 -0
  44. package/components/contest/blocks/CompareColumnsBlock.vue +127 -0
  45. package/components/contest/blocks/ContestTabPanel.vue +27 -0
  46. package/components/contest/blocks/CriteriaBarBlock.vue +118 -0
  47. package/components/contest/blocks/HtmlBlock.vue +61 -0
  48. package/components/contest/blocks/JudgesShowcaseBlock.vue +96 -0
  49. package/components/contest/blocks/RoadmapBlock.vue +127 -0
  50. package/components/contest/blocks/SponsorsBlock.vue +127 -0
  51. package/components/contest/blocks/TableBlock.vue +101 -0
  52. package/components/contest/blocks/TabsBlock.vue +168 -0
  53. package/components/editors/ArticleEditor.vue +9 -16
  54. package/components/editors/ExplainerEditor.vue +8 -5
  55. package/components/editors/ProjectEditor.vue +13 -10
  56. package/components/homepage/CustomHtmlSection.vue +11 -2
  57. package/components/hub/HubProducts.vue +4 -2
  58. package/components/nav/NavDropdown.vue +1 -5
  59. package/components/nav/NavLink.vue +2 -0
  60. package/components/views/ArticleView.vue +3 -56
  61. package/components/views/ExplainerView.vue +4 -0
  62. package/components/views/ProjectView.vue +83 -245
  63. package/composables/useAuth.ts +13 -0
  64. package/composables/useCan.ts +23 -0
  65. package/composables/useContestEditor.ts +388 -0
  66. package/composables/useDocsPageTree.ts +154 -0
  67. package/composables/useDocsSiteSettings.ts +107 -0
  68. package/composables/useEditorAutosave.ts +131 -0
  69. package/composables/useEngagement.ts +13 -6
  70. package/composables/useFeatures.ts +9 -1
  71. package/composables/useFileUpload.ts +60 -0
  72. package/composables/useProfileContent.ts +84 -0
  73. package/composables/useSanitize.ts +38 -4
  74. package/composables/useScrollSpy.ts +87 -0
  75. package/layouts/admin.vue +43 -18
  76. package/layouts/default.vue +18 -9
  77. package/nuxt.config.ts +13 -0
  78. package/package.json +8 -8
  79. package/pages/[type]/index.vue +6 -1
  80. package/pages/admin/api-keys.vue +13 -3
  81. package/pages/admin/features.vue +2 -0
  82. package/pages/admin/federation.vue +1 -1
  83. package/pages/admin/layouts/[id].vue +30 -2
  84. package/pages/admin/roles.vue +286 -0
  85. package/pages/admin/settings.vue +2 -1
  86. package/pages/admin/users.vue +81 -1
  87. package/pages/admin/video-categories.vue +203 -0
  88. package/pages/cert/[code].vue +6 -2
  89. package/pages/contests/[slug]/edit.vue +4 -764
  90. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  91. package/pages/contests/[slug]/index.vue +97 -8
  92. package/pages/contests/[slug]/judge.vue +49 -26
  93. package/pages/contests/create.vue +5 -466
  94. package/pages/contests/index.vue +7 -2
  95. package/pages/cookies.vue +1 -1
  96. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  97. package/pages/docs/[siteSlug]/edit.vue +93 -231
  98. package/pages/events/[slug]/edit.vue +20 -20
  99. package/pages/events/create.vue +18 -18
  100. package/pages/events/index.vue +7 -2
  101. package/pages/hubs/[slug]/index.vue +34 -9
  102. package/pages/hubs/[slug]/invites.vue +312 -0
  103. package/pages/hubs/[slug]/members.vue +128 -0
  104. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  105. package/pages/hubs/index.vue +6 -1
  106. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  107. package/pages/learn/index.vue +8 -1
  108. package/pages/messages/index.vue +1 -1
  109. package/pages/mirror/[id].vue +1 -1
  110. package/pages/products/[slug].vue +55 -2
  111. package/pages/products/index.vue +6 -1
  112. package/pages/settings/account.vue +8 -8
  113. package/pages/settings/profile.vue +23 -14
  114. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  115. package/pages/u/[username]/followers.vue +11 -3
  116. package/pages/u/[username]/following.vue +10 -8
  117. package/pages/u/[username]/index.vue +73 -7
  118. package/pages/videos/index.vue +13 -10
  119. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  120. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  121. package/server/api/admin/api-keys/index.get.ts +1 -0
  122. package/server/api/admin/api-keys/index.post.ts +1 -0
  123. package/server/api/admin/federation/refederate.post.ts +18 -1
  124. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  125. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  126. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  127. package/server/api/admin/layouts/[id].delete.ts +1 -4
  128. package/server/api/admin/layouts/[id].get.ts +1 -4
  129. package/server/api/admin/layouts/[id].put.ts +1 -4
  130. package/server/api/admin/permissions.get.ts +14 -0
  131. package/server/api/admin/roles/[id]/index.delete.ts +25 -0
  132. package/server/api/admin/roles/[id]/index.put.ts +24 -0
  133. package/server/api/admin/roles/index.get.ts +10 -0
  134. package/server/api/admin/roles/index.post.ts +27 -0
  135. package/server/api/admin/users/[id]/role.put.ts +20 -1
  136. package/server/api/admin/users/[id]/roles.get.ts +10 -0
  137. package/server/api/admin/users/[id]/roles.put.ts +17 -0
  138. package/server/api/auth/federated/login.post.ts +12 -5
  139. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  140. package/server/api/content/[id]/build.get.ts +11 -0
  141. package/server/api/content/[id]/report.post.ts +2 -0
  142. package/server/api/content/[id]/versions.get.ts +15 -0
  143. package/server/api/contests/[slug]/advance.post.ts +10 -5
  144. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  145. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  146. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  147. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  148. package/server/api/contests/[slug]/export.get.ts +43 -0
  149. package/server/api/contests/[slug]/index.get.ts +10 -2
  150. package/server/api/contests/[slug]/index.put.ts +11 -2
  151. package/server/api/contests/[slug]/judge.post.ts +8 -2
  152. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  153. package/server/api/contests/[slug]/stakeholders/index.post.ts +12 -3
  154. package/server/api/contests/[slug]/transition.post.ts +8 -3
  155. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  156. package/server/api/contests/index.post.ts +1 -1
  157. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  158. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  159. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  160. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  161. package/server/api/events/[slug]/attendees.get.ts +10 -0
  162. package/server/api/events/[slug].get.ts +9 -0
  163. package/server/api/events/index.get.ts +8 -1
  164. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  165. package/server/api/federation/content/[id]/build.get.ts +10 -0
  166. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  167. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  168. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  169. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  170. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  171. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  172. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  173. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  174. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  175. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  176. package/server/api/me.get.ts +7 -0
  177. package/server/api/products/[id].delete.ts +22 -2
  178. package/server/api/registry/ping.post.ts +17 -3
  179. package/server/api/search/index.get.ts +5 -3
  180. package/server/api/social/bookmark.get.ts +1 -0
  181. package/server/api/social/bookmark.post.ts +1 -0
  182. package/server/api/social/bookmarks.get.ts +1 -0
  183. package/server/api/social/comments/[id].delete.ts +1 -0
  184. package/server/api/social/comments.get.ts +1 -0
  185. package/server/api/social/comments.post.ts +1 -0
  186. package/server/api/social/like.get.ts +1 -0
  187. package/server/api/social/like.post.ts +1 -0
  188. package/server/api/users/[username]/content.get.ts +15 -3
  189. package/server/api/users/[username]/follow.delete.ts +1 -0
  190. package/server/api/users/[username]/follow.post.ts +1 -0
  191. package/server/api/users/[username]/followers.get.ts +2 -1
  192. package/server/api/users/[username]/following.get.ts +2 -1
  193. package/server/middleware/content-ap.ts +8 -3
  194. package/server/middleware/csrf.ts +93 -0
  195. package/server/plugins/federation-hub-sync.ts +48 -17
  196. package/server/plugins/notification-email.ts +22 -3
  197. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  198. package/server/routes/inbox.ts +14 -1
  199. package/server/routes/users/[username]/inbox.ts +13 -1
  200. package/server/utils/inbox.ts +7 -2
  201. package/server/utils/validate.ts +22 -0
  202. package/theme/base.css +5 -0
  203. package/theme/prose.css +20 -0
  204. package/theme/stoa-dark.css +4 -0
  205. package/types/contestBlocks.ts +122 -0
  206. package/utils/contestBlocks.ts +107 -0
  207. package/utils/contestBody.ts +25 -0
  208. package/utils/contestStages.ts +62 -0
  209. package/utils/contestSubmission.ts +97 -0
  210. package/utils/datetime.ts +45 -0
  211. package/utils/projectBlocks.ts +162 -0
  212. package/components/editors/BlogEditor.vue +0 -648
@@ -1,4 +1,4 @@
1
- import { getContestBySlug, canViewContest } from '@commonpub/server';
1
+ import { getContestBySlug, canViewContest, isContestEditor } from '@commonpub/server';
2
2
  import type { ContestDetail } from '@commonpub/server';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<ContestDetail> => {
@@ -13,5 +13,13 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
13
13
  if (!(await canViewContest(db, contest, user))) {
14
14
  throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
15
  }
16
- return contest;
16
+ // Per-request manage flag for the client (owner / editor / contest.manage).
17
+ // Server stays the enforcement boundary; this only drives UI affordances.
18
+ // Returned as a fresh object (not a mutation of the fetched row) so the
19
+ // per-viewer flag can never leak across requests if getContestBySlug is cached.
20
+ const viewerCanManage = user
21
+ ? ownerOrPermission(event, contest.createdById, 'contest.manage') ||
22
+ (await isContestEditor(db, contest.id, user.id))
23
+ : false;
24
+ return { ...contest, viewerCanManage };
17
25
  });
@@ -1,4 +1,4 @@
1
- import { updateContest } from '@commonpub/server';
1
+ import { updateContest, getContestBySlug, isContestEditor } from '@commonpub/server';
2
2
  import type { ContestDetail } from '@commonpub/server';
3
3
  import { updateContestSchema } from '@commonpub/schema';
4
4
 
@@ -9,9 +9,18 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
9
9
  const { slug } = parseParams(event, { slug: 'string' });
10
10
  const input = await parseBody(event, updateContestSchema);
11
11
 
12
+ // Owner, a per-contest `editor` stakeholder, or a `contest.manage` holder may
13
+ // edit. The owner check inside updateContest covers the owner; pass canManage
14
+ // for the permission/editor paths (editor is also re-checked server-side).
15
+ const contest = await getContestBySlug(db, slug);
16
+ const canManage = contest
17
+ ? ownerOrPermission(event, contest.createdById, 'contest.manage') ||
18
+ (await isContestEditor(db, contest.id, user.id))
19
+ : false;
20
+
12
21
  let result;
13
22
  try {
14
- result = await updateContest(db, slug, user.id, input);
23
+ result = await updateContest(db, slug, user.id, input, canManage);
15
24
  } catch (err) {
16
25
  if (err instanceof Error && err.message === 'SLUG_TAKEN') {
17
26
  throw createError({ statusCode: 409, statusMessage: 'That URL slug is already in use by another contest.' });
@@ -1,13 +1,19 @@
1
- import { judgeContestEntry } from '@commonpub/server';
1
+ import { getContestBySlug, judgeContestEntry } from '@commonpub/server';
2
2
  import { judgeEntrySchema } from '@commonpub/schema';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
5
5
  requireFeature('contests');
6
6
  const user = requireAuth(event);
7
7
  const db = useDB();
8
+ const { slug } = parseParams(event, { slug: 'string' });
8
9
  const input = await parseBody(event, judgeEntrySchema);
9
10
 
10
- const result = await judgeContestEntry(db, input.entryId, input.score, user.id, input.feedback, input.criteriaScores);
11
+ // B5a resolve the contest from the route `:slug` and assert the entry belongs
12
+ // to it, so the slug in the path actually scopes the judged entry.
13
+ const contest = await getContestBySlug(db, slug);
14
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
+
16
+ const result = await judgeContestEntry(db, input.entryId, input.score, user.id, input.feedback, input.criteriaScores, contest.id);
11
17
  if (!result.judged) {
12
18
  throw createError({ statusCode: 403, statusMessage: result.error ?? 'Judging failed' });
13
19
  }
@@ -0,0 +1,36 @@
1
+ import { submitContestProposal, getContestBySlug, canViewContest } from '@commonpub/server';
2
+ import { stageSubmissionSchema } from '@commonpub/schema';
3
+
4
+ /**
5
+ * POST /api/contests/:slug/proposal
6
+ * Form-first proposal entry (Phase 4). Validates the stage form, creates a DRAFT
7
+ * placeholder project, links a contest entry to it, and records agreement
8
+ * acceptances + PII separately. Gated by features.contestProposals; the server
9
+ * enforces that the target stage is the current, proposal-mode submission stage.
10
+ */
11
+ export default defineEventHandler(async (event): Promise<{ entryId: string; projectSlug: string; contentType: string }> => {
12
+ requireFeature('contests');
13
+ requireFeature('contestProposals');
14
+ const user = requireAuth(event);
15
+ const db = useDB();
16
+ const { slug } = parseParams(event, { slug: 'string' });
17
+
18
+ const contest = await getContestBySlug(db, slug);
19
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
20
+ if (!(await canViewContest(db, contest, user))) {
21
+ throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
22
+ }
23
+
24
+ const input = await parseBody(event, stageSubmissionSchema);
25
+ const result = await submitContestProposal(db, {
26
+ contestId: contest.id,
27
+ stageId: input.stageId,
28
+ fields: input.fields,
29
+ userId: user.id,
30
+ ip: getRequestIP(event) ?? null,
31
+ });
32
+ if (!result.ok) {
33
+ throw createError({ statusCode: 400, statusMessage: result.error });
34
+ }
35
+ return { entryId: result.entryId, projectSlug: result.projectSlug, contentType: result.contentType };
36
+ });
@@ -1,11 +1,19 @@
1
1
  import { getContestBySlug, addContestStakeholder } from '@commonpub/server';
2
+ import { stakeholderRoleSchema } from '@commonpub/schema';
2
3
  import { z } from 'zod';
3
4
 
4
- const addStakeholderSchema = z.object({ userId: z.string().uuid() });
5
+ const addStakeholderSchema = z.object({
6
+ userId: z.string().uuid(),
7
+ // 'reviewer' (view-only, default) or 'editor' (full edit rights to THIS
8
+ // contest only). Only owner / contest.manage can add or promote, so an editor
9
+ // cannot mint more editors.
10
+ role: stakeholderRoleSchema.optional(),
11
+ });
5
12
 
6
13
  /**
7
14
  * POST /api/contests/:slug/stakeholders
8
- * Grant a user view-only review access (contest owner or admin only).
15
+ * Grant a user per-contest access (reviewer = view-only, editor = full edit of
16
+ * this contest). Contest owner or a `contest.manage` holder only.
9
17
  */
10
18
  export default defineEventHandler(async (event) => {
11
19
  requireFeature('contests');
@@ -22,6 +30,7 @@ export default defineEventHandler(async (event) => {
22
30
 
23
31
  const body = await parseBody(event, addStakeholderSchema);
24
32
  const result = await addContestStakeholder(db, contest.id, body.userId, {
33
+ role: body.role,
25
34
  contestSlug: slug,
26
35
  contestTitle: contest.title,
27
36
  invitedBy: user.id,
@@ -29,5 +38,5 @@ export default defineEventHandler(async (event) => {
29
38
  if (!result.added) {
30
39
  throw createError({ statusCode: 400, statusMessage: result.error ?? 'Failed to add stakeholder' });
31
40
  }
32
- return { added: true };
41
+ return { added: true, updated: result.updated ?? false };
33
42
  });
@@ -1,4 +1,4 @@
1
- import { getContestBySlug, transitionContestStatus } from '@commonpub/server';
1
+ import { getContestBySlug, transitionContestStatus, isContestEditor } from '@commonpub/server';
2
2
  import type { ContestStatus } from '@commonpub/server';
3
3
  import { contestTransitionSchema } from '@commonpub/schema';
4
4
 
@@ -14,10 +14,15 @@ export default defineEventHandler(async (event): Promise<{ transitioned: boolean
14
14
  throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
15
15
  }
16
16
 
17
- const result = await transitionContestStatus(db, contest.id, user.id, input.status);
17
+ const canManage =
18
+ ownerOrPermission(event, contest.createdById, 'contest.manage') ||
19
+ (await isContestEditor(db, contest.id, user.id));
20
+
21
+ const result = await transitionContestStatus(db, contest.id, user.id, input.status, canManage);
18
22
 
19
23
  if (!result.transitioned) {
20
- throw createError({ statusCode: 400, statusMessage: result.error || 'Transition failed' });
24
+ const denied = /authoriz|owner/i.test(result.error ?? '');
25
+ throw createError({ statusCode: denied ? 403 : 400, statusMessage: result.error || 'Transition failed' });
21
26
  }
22
27
 
23
28
  return { transitioned: true, newStatus: input.status };
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import { getContestBySlug, searchUsers } from '@commonpub/server';
3
+ import type { UserSearchResult } from '@commonpub/server';
4
+
5
+ const querySchema = z.object({
6
+ q: z.string().min(2).max(100),
7
+ limit: z.coerce.number().int().min(1).max(25).optional(),
8
+ });
9
+
10
+ /**
11
+ * Scoped user lookup for contest invite pickers (judges/reviewers). Returns
12
+ * PUBLIC fields only and is gated to contest managers (owner / contest.manage),
13
+ * so non-admin organizers can search without the admin-only user list (the 403
14
+ * bug the judge/stakeholder managers used to hit).
15
+ */
16
+ export default defineEventHandler(async (event): Promise<UserSearchResult[]> => {
17
+ requireFeature('contests');
18
+ requireAuth(event);
19
+ const db = useDB();
20
+ const { slug } = parseParams(event, { slug: 'string' });
21
+
22
+ const contest = await getContestBySlug(db, slug);
23
+ if (!contest) throw createError({ statusCode: 404, statusMessage: 'Contest not found' });
24
+ if (!ownerOrPermission(event, contest.createdById, 'contest.manage')) {
25
+ throw createError({ statusCode: 403, statusMessage: 'Not authorized to manage this contest' });
26
+ }
27
+
28
+ const { q, limit } = await parseQueryParams(event, querySchema);
29
+ return searchUsers(db, q, limit ?? 10);
30
+ });
@@ -30,6 +30,6 @@ export default defineEventHandler(async (event): Promise<ContestDetail> => {
30
30
 
31
31
  return createContest(db, { ...input, slug, createdBy: user.id } as CreateContestInput, {
32
32
  userRole: user.role,
33
- contestCreationPolicy: config.instance.contestCreation ?? 'staff',
33
+ contestCreationPolicy: config.instance.contestCreation ?? 'admin',
34
34
  });
35
35
  });
@@ -20,7 +20,12 @@ export default defineEventHandler(async (event) => {
20
20
 
21
21
  if (!version) return [];
22
22
 
23
+ // Public viewers only see published pages in nav; the site owner/admin (the docs
24
+ // editor uses this route too) sees drafts too.
25
+ const viewer = getOptionalUser(event);
26
+ const canSeeDrafts = !!viewer && (viewer.role === 'admin' || viewer.id === result.site.ownerId);
27
+
23
28
  // Return pages directly as nav items
24
- const pages = await listDocsPages(db, version.id);
29
+ const pages = await listDocsPages(db, version.id, { publishedOnly: !canSeeDrafts });
25
30
  return pages.map(p => ({ id: p.id, title: p.title, sidebarLabel: p.sidebarLabel, slug: p.slug, sortOrder: p.sortOrder, parentId: p.parentId }));
26
31
  });
@@ -15,7 +15,11 @@ export default defineEventHandler(async (event) => {
15
15
 
16
16
  if (!version) throw createError({ statusCode: 404, statusMessage: 'No version found' });
17
17
 
18
- const pages = await listDocsPages(db, version.id);
18
+ // Public viewers only see published pages; the site owner/admin sees drafts too.
19
+ const viewer = getOptionalUser(event);
20
+ const canSeeDrafts = !!viewer && (viewer.role === 'admin' || viewer.id === result.site.ownerId);
21
+
22
+ const pages = await listDocsPages(db, version.id, { publishedOnly: !canSeeDrafts });
19
23
  const page = pages.find((p) => p.slug === pageSlug);
20
24
  if (!page) throw createError({ statusCode: 404, statusMessage: 'Page not found' });
21
25
 
@@ -30,7 +30,12 @@ export default defineEventHandler(async (event) => {
30
30
  throw createError({ statusCode: 404, statusMessage: 'No version found for docs site' });
31
31
  }
32
32
 
33
- const pages = await listDocsPages(db, version.id);
33
+ // Public viewers only see published pages; the site owner/admin (e.g. the docs
34
+ // editor at /docs/[siteSlug]/edit, which hits this same route) sees drafts too.
35
+ const viewer = getOptionalUser(event);
36
+ const canSeeDrafts = !!viewer && (viewer.role === 'admin' || viewer.id === result.site.ownerId);
37
+
38
+ const pages = await listDocsPages(db, version.id, { publishedOnly: !canSeeDrafts });
34
39
 
35
40
  // Content is JSONB — arrays come back parsed, legacy strings stay as strings
36
41
  return pages.map((page) => {
@@ -17,5 +17,11 @@ export default defineEventHandler(async (event) => {
17
17
  const version = result.versions?.find((v) => v.isDefault) ?? result.versions?.[0];
18
18
  if (!version) return [];
19
19
 
20
- return searchDocsPages(db, result.site.id, version.id, query.q ?? '', config.docs.searchLanguage);
20
+ // Public viewers only search published pages; the site owner/admin sees drafts too.
21
+ const viewer = getOptionalUser(event);
22
+ const canSeeDrafts = !!viewer && (viewer.role === 'admin' || viewer.id === result.site.ownerId);
23
+
24
+ return searchDocsPages(db, result.site.id, version.id, query.q ?? '', config.docs.searchLanguage, {
25
+ publishedOnly: !canSeeDrafts,
26
+ });
21
27
  });
@@ -14,6 +14,16 @@ 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
+ // Don't expose the attendee roster for non-published events (draft/cancelled/etc)
18
+ // to the public; gate to the creator/admin, matching the event-detail gating.
19
+ // (Fuller per-event roster privacy for published events is a separate product
20
+ // decision — tracked as a follow-up.)
21
+ if (existing.status !== 'published' && existing.status !== 'active') {
22
+ const viewer = getOptionalUser(event);
23
+ const canView = !!viewer && (viewer.role === 'admin' || viewer.id === existing.createdById);
24
+ if (!canView) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
25
+ }
26
+
17
27
  const query = getQuery(event);
18
28
  return listEventAttendees(db, existing.id, {
19
29
  status: (query.status as AttendeeStatus) || undefined,
@@ -13,5 +13,14 @@ export default defineEventHandler(async (event) => {
13
13
  const result = await getEventBySlug(db, slug);
14
14
  if (!result) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
15
15
 
16
+ // Only published/active events are publicly viewable. Draft/cancelled/completed
17
+ // events are visible only to the creator or an admin (mirrors content/contest
18
+ // draft gating). 404 (not 403) so the slug's existence isn't leaked.
19
+ if (result.status !== 'published' && result.status !== 'active') {
20
+ const viewer = getOptionalUser(event);
21
+ const canView = !!viewer && (viewer.role === 'admin' || viewer.id === result.createdById);
22
+ if (!canView) throw createError({ statusCode: 404, statusMessage: 'Event not found' });
23
+ }
24
+
16
25
  return result;
17
26
  });
@@ -23,9 +23,16 @@ export default defineEventHandler(async (event) => {
23
23
  userId = user.id;
24
24
  }
25
25
 
26
+ // hubId feeds a uuid SQL bind; reject a malformed value at the door (a
27
+ // non-uuid string reaching the bind throws an unhandled 500).
28
+ const hubId = (query.hubId as string) || undefined;
29
+ if (hubId && !isUuid(hubId)) {
30
+ throw createError({ statusCode: 400, statusMessage: 'Invalid hubId' });
31
+ }
32
+
26
33
  return listEvents(db, {
27
34
  status,
28
- hubId: (query.hubId as string) || undefined,
35
+ hubId,
29
36
  upcoming: query.upcoming === 'true',
30
37
  featured: query.featured === 'true',
31
38
  userId,
@@ -2,7 +2,7 @@ import { listFederatedHubPostReplies } from '@commonpub/server';
2
2
 
3
3
  export default defineEventHandler(async (event) => {
4
4
  requireFeature('federation');
5
- const postId = getRouterParam(event, 'postId')!;
5
+ const { postId } = parseParams(event, { id: 'uuid', postId: 'uuid' });
6
6
  const query = getQuery(event);
7
7
  const db = useDB();
8
8
 
@@ -0,0 +1,10 @@
1
+ import { isFederatedBuildMarked } from '@commonpub/server';
2
+
3
+ // Hydration counterpart to the federated build toggle — see content/[id]/build.get.ts.
4
+ export default defineEventHandler(async (event): Promise<{ marked: boolean }> => {
5
+ requireFeature('federation');
6
+ const user = requireAuth(event);
7
+ const db = useDB();
8
+ const { id } = parseParams(event, { id: 'uuid' });
9
+ return { marked: await isFederatedBuildMarked(db, id, user.id) };
10
+ });
@@ -0,0 +1,17 @@
1
+ import { revokeInvite, getHubBySlug } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event): Promise<{ revoked: boolean }> => {
4
+ const user = requireAuth(event);
5
+ const db = useDB();
6
+ const { slug, id } = parseParams(event, { slug: 'string', id: 'uuid' });
7
+ const hub = await getHubBySlug(db, slug);
8
+ if (!hub) {
9
+ throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
10
+ }
11
+
12
+ const revoked = await revokeInvite(db, id, user.id, hub.id);
13
+ if (!revoked) {
14
+ throw createError({ statusCode: 403, statusMessage: 'Not authorized to revoke invites' });
15
+ }
16
+ return { revoked };
17
+ });
@@ -1,4 +1,4 @@
1
- import { listInvites, getHubBySlug, getMember } from '@commonpub/server';
1
+ import { listInvites, getHubBySlug, getMember, hasPermission } from '@commonpub/server';
2
2
  import type { HubInviteItem } from '@commonpub/server';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<HubInviteItem[]> => {
@@ -10,9 +10,11 @@ export default defineEventHandler(async (event): Promise<HubInviteItem[]> => {
10
10
  throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
11
11
  }
12
12
 
13
- // Only moderators, admins, and owners can view invite lists
13
+ // Invite management is admin+ (matches createInvite/revokeInvite's manageMembers).
14
+ // Gating the list at the same level keeps moderators from seeing write controls
15
+ // they can't use.
14
16
  const member = await getMember(db, hub.id, user.id);
15
- if (!member || !['moderator', 'admin', 'owner'].includes(member.role)) {
17
+ if (!member || !hasPermission(member.role, 'manageMembers')) {
16
18
  throw createError({ statusCode: 403, statusMessage: 'Insufficient permissions' });
17
19
  }
18
20
 
@@ -7,8 +7,7 @@ import { getPollOptions, getUserPollVote } from '@commonpub/server';
7
7
  export default defineEventHandler(async (event) => {
8
8
  requireFeature('hubs');
9
9
  const db = useDB();
10
- const postId = getRouterParam(event, 'postId');
11
- if (!postId) throw createError({ statusCode: 400, statusMessage: 'Missing postId' });
10
+ const { postId } = parseParams(event, { postId: 'uuid' });
12
11
 
13
12
  const options = await getPollOptions(db, postId);
14
13
  const user = getOptionalUser(event);
@@ -13,8 +13,7 @@ export default defineEventHandler(async (event) => {
13
13
  requireFeature('hubs');
14
14
  const user = requireAuth(event);
15
15
  const db = useDB();
16
- const postId = getRouterParam(event, 'postId');
17
- if (!postId) throw createError({ statusCode: 400, statusMessage: 'Missing postId' });
16
+ const { postId } = parseParams(event, { postId: 'uuid' });
18
17
 
19
18
  const body = await parseBody(event, pollVoteSchema);
20
19
  const result = await voteOnPoll(db, postId, body.optionId, user.id);
@@ -13,8 +13,7 @@ export default defineEventHandler(async (event) => {
13
13
  requireFeature('hubs');
14
14
  const user = requireAuth(event);
15
15
  const db = useDB();
16
- const postId = getRouterParam(event, 'postId');
17
- if (!postId) throw createError({ statusCode: 400, statusMessage: 'Missing postId' });
16
+ const { postId } = parseParams(event, { postId: 'uuid' });
18
17
 
19
18
  const body = await parseBody(event, voteSchema);
20
19
  return voteOnPost(db, postId, user.id, body.direction);
@@ -0,0 +1,15 @@
1
+ import { approveJoinRequest, getHubBySlug } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event): Promise<{ approved: boolean; error?: string }> => {
4
+ const user = requireAuth(event);
5
+ const db = useDB();
6
+ const { slug, userId } = parseParams(event, { slug: 'string', userId: 'uuid' });
7
+
8
+ const hub = await getHubBySlug(db, slug);
9
+ if (!hub) {
10
+ throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
11
+ }
12
+
13
+ // Permission (manageMembers) is enforced inside approveJoinRequest.
14
+ return approveJoinRequest(db, user.id, hub.id, userId);
15
+ });
@@ -0,0 +1,15 @@
1
+ import { denyJoinRequest, getHubBySlug } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event): Promise<{ denied: boolean; error?: string }> => {
4
+ const user = requireAuth(event);
5
+ const db = useDB();
6
+ const { slug, userId } = parseParams(event, { slug: 'string', userId: 'uuid' });
7
+
8
+ const hub = await getHubBySlug(db, slug);
9
+ if (!hub) {
10
+ throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
11
+ }
12
+
13
+ // Permission (manageMembers) is enforced inside denyJoinRequest.
14
+ return denyJoinRequest(db, user.id, hub.id, userId);
15
+ });
@@ -0,0 +1,20 @@
1
+ import { listJoinRequests, getHubBySlug, getMember } from '@commonpub/server';
2
+ import type { HubMemberItem } from '@commonpub/server';
3
+
4
+ export default defineEventHandler(async (event): Promise<{ items: HubMemberItem[]; total: number }> => {
5
+ const user = requireAuth(event);
6
+ const db = useDB();
7
+ const { slug } = parseParams(event, { slug: 'string' });
8
+ const hub = await getHubBySlug(db, slug);
9
+ if (!hub) {
10
+ throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
11
+ }
12
+
13
+ // Reviewing join requests is a member-management action (admin/owner).
14
+ const member = await getMember(db, hub.id, user.id);
15
+ if (!member || !['admin', 'owner'].includes(member.role)) {
16
+ throw createError({ statusCode: 403, statusMessage: 'Insufficient permissions' });
17
+ }
18
+
19
+ return listJoinRequests(db, hub.id);
20
+ });
@@ -3,8 +3,7 @@ import { deleteHubResource } from '@commonpub/server';
3
3
  export default defineEventHandler(async (event) => {
4
4
  const db = useDB();
5
5
  const user = requireAuth(event);
6
- const id = getRouterParam(event, 'id');
7
- if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing resource ID' });
6
+ const { id } = parseParams(event, { id: 'uuid' });
8
7
 
9
8
  const result = await deleteHubResource(db, id, user.id);
10
9
  if (!result.success) {
@@ -4,8 +4,7 @@ import { updateHubResourceSchema } from '@commonpub/schema';
4
4
  export default defineEventHandler(async (event) => {
5
5
  const db = useDB();
6
6
  const user = requireAuth(event);
7
- const id = getRouterParam(event, 'id');
8
- if (!id) throw createError({ statusCode: 400, statusMessage: 'Missing resource ID' });
7
+ const { id } = parseParams(event, { id: 'uuid' });
9
8
 
10
9
  const input = await parseBody(event, updateHubResourceSchema);
11
10
 
@@ -6,8 +6,15 @@
6
6
  */
7
7
  export default defineEventHandler((event) => {
8
8
  const { user, session } = event.context.auth ?? {};
9
+ // Effective permissions resolved by the auth middleware (RBAC). The admin
10
+ // floor lives in users.role, so the set is empty for admins — useCan() applies
11
+ // the floor client-side. Permissions/roleKeys are advisory (UX only); the
12
+ // server is always the enforcement boundary.
13
+ const resolved = event.context.cpubPermissions;
9
14
  return {
10
15
  user: user ?? null,
11
16
  session: session ?? null,
17
+ permissions: resolved ? [...resolved.permissions] : [],
18
+ roleKeys: resolved?.roleKeys ?? [],
12
19
  };
13
20
  });
@@ -1,11 +1,31 @@
1
1
  import { deleteProduct } from '@commonpub/server';
2
+ import { products } from '@commonpub/schema';
3
+ import { eq } from 'drizzle-orm';
2
4
 
3
5
  export default defineEventHandler(async (event): Promise<{ deleted: boolean }> => {
6
+ const user = requireAuth(event);
4
7
  const db = useDB();
5
- requirePermission(event, 'content.moderate');
6
8
  const { id } = parseParams(event, { id: 'uuid' });
7
9
 
8
- const deleted = await deleteProduct(db, id);
10
+ // Owner OR moderator may delete. Resolve the product's owner first.
11
+ const [product] = await db
12
+ .select({ createdById: products.createdById })
13
+ .from(products)
14
+ .where(eq(products.id, id))
15
+ .limit(1);
16
+
17
+ if (!product) {
18
+ throw createError({ statusCode: 404, statusMessage: 'Product not found' });
19
+ }
20
+
21
+ if (!ownerOrPermission(event, product.createdById, 'content.moderate')) {
22
+ throw createError({ statusCode: 403, statusMessage: 'Missing permission: content.moderate' });
23
+ }
24
+
25
+ // Pass userId for an owner-scoped data-layer delete; moderators (non-owners)
26
+ // are already gated above, so they delete unconditionally.
27
+ const isOwner = product.createdById === user.id;
28
+ const deleted = await deleteProduct(db, id, isOwner ? user.id : undefined);
9
29
 
10
30
  if (!deleted) {
11
31
  throw createError({ statusCode: 404, statusMessage: 'Product not found' });
@@ -1,4 +1,4 @@
1
- import { recordRegistryPing, createRateLimitStore, getClientIp } from '@commonpub/server';
1
+ import { recordRegistryPing, createRateLimitStore, getClientIp, recordActivitySeen } from '@commonpub/server';
2
2
  import { verifyInboxRequest, extractDomain } from '../../utils/inbox';
3
3
 
4
4
  /**
@@ -32,13 +32,27 @@ export default defineEventHandler(async (event) => {
32
32
  const preRl = await store.check(`registry:ping:ip:${getClientIp(event)}`, PRE_TIER);
33
33
  if (!preRl.allowed) reject429(preRl.resetAt);
34
34
 
35
- const { actorUri } = await verifyInboxRequest(event, 'registry-ping');
35
+ const { actorUri, body } = await verifyInboxRequest(event, 'registry-ping');
36
36
  const domain = extractDomain(actorUri);
37
37
 
38
38
  // Per-verified-domain cap (the signer is now cryptographically known).
39
39
  const rl = await store.check(`registry:ping:${domain}`, PING_TIER);
40
40
  if (!rl.allowed) reject429(rl.resetAt);
41
41
 
42
- const result = await recordRegistryPing(useDB(), domain, actorUri);
42
+ const db = useDB();
43
+
44
+ // Replay dedup: claim the verified activity id BEFORE recording the ping so a
45
+ // replayed, validly-signed ping can't re-trigger the NodeInfo pull. No id =
46
+ // process normally. Placed after verification so attacker-chosen ids can't be
47
+ // seeded.
48
+ const activityId = body.id;
49
+ if (typeof activityId === 'string' && activityId.length > 0) {
50
+ const first = await recordActivitySeen(db, activityId);
51
+ if (!first) {
52
+ return { status: 'ok' };
53
+ }
54
+ }
55
+
56
+ const result = await recordRegistryPing(db, domain, actorUri);
43
57
  return { status: result };
44
58
  });
@@ -7,7 +7,7 @@ import { z } from 'zod';
7
7
  const searchQuerySchema = z.object({
8
8
  q: z.string().max(200).optional(),
9
9
  type: z.string().optional(),
10
- sort: z.enum(['relevance', 'recent', 'popular']).optional(),
10
+ sort: z.enum(['relevance', 'recent', 'popular', 'likes']).optional(),
11
11
  difficulty: z.string().optional(),
12
12
  tags: z.string().optional(),
13
13
  author: z.string().optional(),
@@ -141,8 +141,10 @@ export default defineEventHandler(async (event): Promise<{ items: unknown[]; tot
141
141
  difficulty: params.difficulty as ContentFilters['difficulty'],
142
142
  tag: tagList[0],
143
143
  // Postgres has no relevance ranking (that's Meilisearch's job) — the old
144
- // path also fell back to recency for 'relevance'.
145
- sort: params.sort === 'popular' ? 'popular' : 'recent',
144
+ // path also fell back to recency for 'relevance'. 'popular'/'likes' pass
145
+ // through (listContent coerces them to recency when the merge federates,
146
+ // since federated rows carry no view/like counts — same as 'popular').
147
+ sort: params.sort === 'popular' || params.sort === 'likes' ? params.sort : 'recent',
146
148
  limit,
147
149
  offset,
148
150
  };
@@ -7,6 +7,7 @@ const checkSchema = z.object({
7
7
  });
8
8
 
9
9
  export default defineEventHandler(async (event): Promise<{ bookmarked: boolean }> => {
10
+ requireFeature('social');
10
11
  const user = requireAuth(event);
11
12
  const db = useDB();
12
13
  const query = parseQueryParams(event, checkSchema);
@@ -7,6 +7,7 @@ const toggleBookmarkSchema = z.object({
7
7
  });
8
8
 
9
9
  export default defineEventHandler(async (event): Promise<{ bookmarked: boolean }> => {
10
+ requireFeature('social');
10
11
  const user = requireAuth(event);
11
12
  const db = useDB();
12
13
  const input = await parseBody(event, toggleBookmarkSchema);
@@ -8,6 +8,7 @@ const bookmarksQuerySchema = z.object({
8
8
  });
9
9
 
10
10
  export default defineEventHandler(async (event): Promise<PaginatedResponse<BookmarkItem>> => {
11
+ requireFeature('social');
11
12
  const user = requireAuth(event);
12
13
  const db = useDB();
13
14
  const query = parseQueryParams(event, bookmarksQuerySchema);