@commonpub/layer 0.82.0 → 0.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) 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 +8 -2
  40. package/components/contest/ContestStageSubmission.vue +10 -36
  41. package/components/contest/ContestStagesEditor.vue +141 -65
  42. package/components/contest/ContestStakeholderManager.vue +3 -2
  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/useContestEditor.ts +388 -0
  64. package/composables/useDocsPageTree.ts +154 -0
  65. package/composables/useDocsSiteSettings.ts +107 -0
  66. package/composables/useEditorAutosave.ts +131 -0
  67. package/composables/useEngagement.ts +13 -6
  68. package/composables/useFeatures.ts +9 -1
  69. package/composables/useFileUpload.ts +60 -0
  70. package/composables/useProfileContent.ts +84 -0
  71. package/composables/useSanitize.ts +38 -4
  72. package/composables/useScrollSpy.ts +87 -0
  73. package/layouts/admin.vue +41 -19
  74. package/layouts/default.vue +18 -9
  75. package/nuxt.config.ts +13 -0
  76. package/package.json +9 -9
  77. package/pages/[type]/index.vue +6 -1
  78. package/pages/admin/api-keys.vue +13 -3
  79. package/pages/admin/features.vue +2 -0
  80. package/pages/admin/federation.vue +1 -1
  81. package/pages/admin/layouts/[id].vue +30 -2
  82. package/pages/admin/settings.vue +2 -1
  83. package/pages/admin/users.vue +1 -1
  84. package/pages/admin/video-categories.vue +203 -0
  85. package/pages/cert/[code].vue +6 -2
  86. package/pages/contests/[slug]/edit.vue +4 -769
  87. package/pages/contests/[slug]/entries/[entryId].vue +34 -1
  88. package/pages/contests/[slug]/index.vue +93 -7
  89. package/pages/contests/[slug]/judge.vue +49 -26
  90. package/pages/contests/create.vue +5 -466
  91. package/pages/contests/index.vue +7 -2
  92. package/pages/cookies.vue +1 -1
  93. package/pages/docs/[siteSlug]/[...pagePath].vue +13 -26
  94. package/pages/docs/[siteSlug]/edit.vue +93 -231
  95. package/pages/events/[slug]/edit.vue +20 -20
  96. package/pages/events/create.vue +18 -18
  97. package/pages/events/index.vue +7 -2
  98. package/pages/hubs/[slug]/index.vue +34 -9
  99. package/pages/hubs/[slug]/invites.vue +312 -0
  100. package/pages/hubs/[slug]/members.vue +128 -0
  101. package/pages/hubs/[slug]/posts/[postId].vue +2 -2
  102. package/pages/hubs/index.vue +6 -1
  103. package/pages/learn/[slug]/[lessonSlug]/index.vue +12 -3
  104. package/pages/learn/index.vue +8 -1
  105. package/pages/messages/index.vue +1 -1
  106. package/pages/mirror/[id].vue +1 -1
  107. package/pages/products/[slug].vue +55 -2
  108. package/pages/products/index.vue +6 -1
  109. package/pages/settings/account.vue +8 -8
  110. package/pages/settings/profile.vue +23 -14
  111. package/pages/u/[username]/[type]/[slug]/edit.vue +12 -5
  112. package/pages/u/[username]/followers.vue +11 -3
  113. package/pages/u/[username]/following.vue +10 -8
  114. package/pages/u/[username]/index.vue +73 -7
  115. package/pages/videos/index.vue +13 -10
  116. package/server/api/admin/api-keys/[id]/usage.get.ts +2 -2
  117. package/server/api/admin/api-keys/[id].delete.ts +2 -2
  118. package/server/api/admin/api-keys/index.get.ts +1 -0
  119. package/server/api/admin/api-keys/index.post.ts +1 -0
  120. package/server/api/admin/federation/refederate.post.ts +18 -1
  121. package/server/api/admin/layouts/[id]/publish.post.ts +1 -4
  122. package/server/api/admin/layouts/[id]/versions/[versionId]/revert.post.ts +1 -5
  123. package/server/api/admin/layouts/[id]/versions/index.get.ts +1 -4
  124. package/server/api/admin/layouts/[id].delete.ts +1 -4
  125. package/server/api/admin/layouts/[id].get.ts +1 -4
  126. package/server/api/admin/layouts/[id].put.ts +1 -4
  127. package/server/api/auth/federated/login.post.ts +12 -5
  128. package/server/api/content/[id]/__tests__/versions.get.test.ts +127 -0
  129. package/server/api/content/[id]/build.get.ts +11 -0
  130. package/server/api/content/[id]/report.post.ts +2 -0
  131. package/server/api/content/[id]/versions.get.ts +15 -0
  132. package/server/api/contests/[slug]/entries/[entryId]/private.get.ts +48 -0
  133. package/server/api/contests/[slug]/entries/[entryId]/submission.put.ts +1 -1
  134. package/server/api/contests/[slug]/entries/[entryId]/vote.delete.ts +1 -2
  135. package/server/api/contests/[slug]/entries/[entryId]/vote.post.ts +1 -2
  136. package/server/api/contests/[slug]/export.get.ts +43 -0
  137. package/server/api/contests/[slug]/judge.post.ts +8 -2
  138. package/server/api/contests/[slug]/proposal.post.ts +36 -0
  139. package/server/api/contests/[slug]/user-search.get.ts +30 -0
  140. package/server/api/contests/index.post.ts +1 -1
  141. package/server/api/docs/[siteSlug]/nav.get.ts +6 -1
  142. package/server/api/docs/[siteSlug]/pages/[pageId].get.ts +5 -1
  143. package/server/api/docs/[siteSlug]/pages/index.get.ts +6 -1
  144. package/server/api/docs/[siteSlug]/search.get.ts +7 -1
  145. package/server/api/events/[slug]/attendees.get.ts +10 -0
  146. package/server/api/events/[slug].get.ts +9 -0
  147. package/server/api/events/index.get.ts +8 -1
  148. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +1 -1
  149. package/server/api/federation/content/[id]/build.get.ts +10 -0
  150. package/server/api/hubs/[slug]/invites/[id].delete.ts +17 -0
  151. package/server/api/hubs/[slug]/invites.get.ts +5 -3
  152. package/server/api/hubs/[slug]/posts/[postId]/poll-options.get.ts +1 -2
  153. package/server/api/hubs/[slug]/posts/[postId]/poll-vote.post.ts +1 -2
  154. package/server/api/hubs/[slug]/posts/[postId]/vote.post.ts +1 -2
  155. package/server/api/hubs/[slug]/requests/[userId]/approve.post.ts +15 -0
  156. package/server/api/hubs/[slug]/requests/[userId]/deny.post.ts +15 -0
  157. package/server/api/hubs/[slug]/requests.get.ts +20 -0
  158. package/server/api/hubs/[slug]/resources/[id].delete.ts +1 -2
  159. package/server/api/hubs/[slug]/resources/[id].put.ts +1 -2
  160. package/server/api/products/[id].delete.ts +22 -2
  161. package/server/api/registry/ping.post.ts +17 -3
  162. package/server/api/search/index.get.ts +5 -3
  163. package/server/api/social/bookmark.get.ts +1 -0
  164. package/server/api/social/bookmark.post.ts +1 -0
  165. package/server/api/social/bookmarks.get.ts +1 -0
  166. package/server/api/social/comments/[id].delete.ts +1 -0
  167. package/server/api/social/comments.get.ts +1 -0
  168. package/server/api/social/comments.post.ts +1 -0
  169. package/server/api/social/like.get.ts +1 -0
  170. package/server/api/social/like.post.ts +1 -0
  171. package/server/api/users/[username]/content.get.ts +15 -3
  172. package/server/api/users/[username]/follow.delete.ts +1 -0
  173. package/server/api/users/[username]/follow.post.ts +1 -0
  174. package/server/api/users/[username]/followers.get.ts +2 -1
  175. package/server/api/users/[username]/following.get.ts +2 -1
  176. package/server/middleware/content-ap.ts +8 -3
  177. package/server/middleware/csrf.ts +93 -0
  178. package/server/plugins/federation-hub-sync.ts +48 -17
  179. package/server/plugins/notification-email.ts +22 -3
  180. package/server/routes/hubs/[slug]/inbox.ts +13 -1
  181. package/server/routes/inbox.ts +14 -1
  182. package/server/routes/users/[username]/inbox.ts +13 -1
  183. package/server/utils/inbox.ts +7 -2
  184. package/server/utils/validate.ts +22 -0
  185. package/theme/base.css +5 -0
  186. package/theme/prose.css +20 -0
  187. package/theme/stoa-dark.css +4 -0
  188. package/types/contestBlocks.ts +122 -0
  189. package/utils/contestBlocks.ts +107 -0
  190. package/utils/contestBody.ts +25 -0
  191. package/utils/contestStages.ts +62 -0
  192. package/utils/contestSubmission.ts +97 -0
  193. package/utils/datetime.ts +45 -0
  194. package/utils/projectBlocks.ts +162 -0
  195. package/components/editors/BlogEditor.vue +0 -648
@@ -9,7 +9,7 @@ import {
9
9
  fetchRemoteHubFollowers,
10
10
  } from '@commonpub/server';
11
11
  import { federatedHubs } from '@commonpub/schema';
12
- import { eq, and, or, lt, isNull } from 'drizzle-orm';
12
+ import { eq, and, or, lt, isNull, inArray } from 'drizzle-orm';
13
13
 
14
14
  const MAX_HUBS_PER_CYCLE = 5;
15
15
  const STAGGER_DELAY_MS = 2_000;
@@ -56,28 +56,57 @@ export default defineNitroPlugin((nitro) => {
56
56
  const now = new Date();
57
57
  const staleThreshold = new Date(now.getTime() - intervalMs);
58
58
 
59
- // Find accepted, non-hidden hubs where lastSyncAt is older than the interval or null
60
- const staleHubs = await db
61
- .select({
62
- id: federatedHubs.id,
63
- actorUri: federatedHubs.actorUri,
64
- name: federatedHubs.name,
65
- lastSyncAt: federatedHubs.lastSyncAt,
66
- })
59
+ // Stale predicate: lastSyncAt is null or older than the interval. Reused for
60
+ // both the candidate select and the atomic claim so the claim only succeeds
61
+ // while the row is still stale (compare-and-claim).
62
+ const isStale = or(
63
+ isNull(federatedHubs.lastSyncAt),
64
+ lt(federatedHubs.lastSyncAt, staleThreshold),
65
+ );
66
+
67
+ // Find accepted, non-hidden hubs that are stale (candidates). We capture the
68
+ // prior lastSyncAt here (before the claim overwrites it) so first-sync
69
+ // detection survives the atomic claim below.
70
+ const candidates = await db
71
+ .select({ id: federatedHubs.id, lastSyncAt: federatedHubs.lastSyncAt })
67
72
  .from(federatedHubs)
68
73
  .where(and(
69
74
  eq(federatedHubs.status, 'accepted'),
70
75
  eq(federatedHubs.isHidden, false),
71
- or(
72
- isNull(federatedHubs.lastSyncAt),
73
- lt(federatedHubs.lastSyncAt, staleThreshold),
74
- ),
76
+ isStale,
75
77
  ))
76
78
  .limit(MAX_HUBS_PER_CYCLE);
77
79
 
78
- if (staleHubs.length === 0) return;
80
+ if (candidates.length === 0) return;
81
+
82
+ // Map id -> was-this-a-first-sync (prior lastSyncAt null), captured pre-claim.
83
+ const wasFirstSync = new Map<string, boolean>(
84
+ candidates.map((c) => [c.id, c.lastSyncAt === null]),
85
+ );
86
+
87
+ // Atomic claim (mirrors federation-delivery's compare-and-claim): set
88
+ // lastSyncAt = now() on the selected ids, but only WHERE still stale. On N
89
+ // replicas selecting the same hubs, exactly one replica's UPDATE matches the
90
+ // stale predicate per row, so each hub is claimed (and fetched from remotes)
91
+ // once per cycle instead of N times. RETURNING gives us the rows we won.
92
+ const candidateIds = candidates.map((c) => c.id);
93
+ const claimedAt = new Date();
94
+ const staleHubs = await db
95
+ .update(federatedHubs)
96
+ .set({ lastSyncAt: claimedAt })
97
+ .where(and(
98
+ inArray(federatedHubs.id, candidateIds),
99
+ isStale,
100
+ ))
101
+ .returning({
102
+ id: federatedHubs.id,
103
+ actorUri: federatedHubs.actorUri,
104
+ name: federatedHubs.name,
105
+ });
106
+
107
+ if (staleHubs.length === 0) return; // another replica claimed them first
79
108
 
80
- console.log(`[hub-sync] Found ${staleHubs.length} stale hub(s) to sync`);
109
+ console.log(`[hub-sync] Claimed ${staleHubs.length} stale hub(s) to sync`);
81
110
 
82
111
  for (const hub of staleHubs) {
83
112
  try {
@@ -85,7 +114,7 @@ export default defineNitroPlugin((nitro) => {
85
114
  await refreshFederatedHubMetadata(db, hub.id, hub.actorUri);
86
115
 
87
116
  // Fetch followers to populate members list (first sync or periodic refresh)
88
- if (!hub.lastSyncAt) {
117
+ if (wasFirstSync.get(hub.id)) {
89
118
  // First sync — fetch followers to seed the members table
90
119
  try {
91
120
  const result = await fetchRemoteHubFollowers(db, hub.id, domain);
@@ -105,7 +134,9 @@ export default defineNitroPlugin((nitro) => {
105
134
  }
106
135
  }
107
136
 
108
- // Update lastSyncAt
137
+ // Refresh lastSyncAt to completion time. The atomic claim above already
138
+ // set it to the claim time (which is what prevents other replicas from
139
+ // re-claiming); this just advances it to reflect successful completion.
109
140
  await db.update(federatedHubs).set({
110
141
  lastSyncAt: new Date(),
111
142
  }).where(eq(federatedHubs.id, hub.id));
@@ -11,7 +11,7 @@ import {
11
11
  listNotifications,
12
12
  } from '@commonpub/server';
13
13
  import type { NotificationType } from '@commonpub/server';
14
- import { users } from '@commonpub/schema';
14
+ import { users, digestRuns } from '@commonpub/schema';
15
15
  import { and, isNotNull, eq } from 'drizzle-orm';
16
16
 
17
17
  export default defineNitroPlugin((nitro) => {
@@ -76,7 +76,9 @@ export default defineNitroPlugin((nitro) => {
76
76
  }
77
77
  }, 5_000);
78
78
 
79
- // Track the last date digests were sent to prevent duplicates on server restart during 8am hour
79
+ // Cheap in-process pre-check to avoid hitting the DB once this replica has already
80
+ // observed today's digest as claimed. The DB claim (digest_runs) is the authority:
81
+ // it guarantees exactly-one-replica-wins across N replicas / restarts.
80
82
  let lastDigestDate = '';
81
83
 
82
84
  async function runDigest(siteUrl: string, siteName: string): Promise<void> {
@@ -91,11 +93,28 @@ export default defineNitroPlugin((nitro) => {
91
93
 
92
94
  if (!isDigestHour) return;
93
95
 
94
- // Prevent duplicate sends if server restarts during digest hour
96
+ // Deterministic UTC date key (YYYY-MM-DD). toISOString() is always UTC.
95
97
  const todayKey = now.toISOString().slice(0, 10);
98
+
99
+ // Cheap pre-check: this replica already knows today's digest is handled.
96
100
  if (lastDigestDate === todayKey) return;
101
+
102
+ // Atomic cross-replica claim: INSERT today's row, ON CONFLICT DO NOTHING.
103
+ // Exactly one replica gets a returned row (it won the claim); the rest get
104
+ // an empty array and bail without sending. Replaces the per-process guard
105
+ // that sent N duplicate digests on N replicas.
106
+ const claimed = await db
107
+ .insert(digestRuns)
108
+ .values({ digestDate: todayKey })
109
+ .onConflictDoNothing({ target: digestRuns.digestDate })
110
+ .returning({ digestDate: digestRuns.digestDate });
111
+
112
+ // Record locally regardless of outcome so this replica skips the DB on
113
+ // subsequent hourly ticks within the same UTC day.
97
114
  lastDigestDate = todayKey;
98
115
 
116
+ if (claimed.length === 0) return; // another replica already claimed today
117
+
99
118
  // Find users with digest preferences
100
119
  const digestUsers = await db
101
120
  .select({
@@ -1,5 +1,5 @@
1
1
  import { processInboxActivity } from '@commonpub/protocol';
2
- import { createInboxHandlers } from '@commonpub/server';
2
+ import { createInboxHandlers, recordActivitySeen } from '@commonpub/server';
3
3
  import { verifyInboxRequest, assertActorMatchesSigner } from '../../../utils/inbox';
4
4
 
5
5
  /**
@@ -22,6 +22,18 @@ export default defineEventHandler(async (event) => {
22
22
  assertActorMatchesSigner(actorUri, body, 'hub-inbox');
23
23
 
24
24
  const db = useDB();
25
+
26
+ // Replay dedup: claim the verified activity id BEFORE dispatch so a replayed,
27
+ // validly-signed activity can't double-apply side effects. No id = process
28
+ // normally. Placed after verification so attacker-chosen ids can't be seeded.
29
+ const activityId = body.id;
30
+ if (typeof activityId === 'string' && activityId.length > 0) {
31
+ const first = await recordActivitySeen(db, activityId);
32
+ if (!first) {
33
+ return { status: 'accepted' };
34
+ }
35
+ }
36
+
25
37
  const domain = config.instance.domain;
26
38
  const slug = getRouterParam(event, 'slug');
27
39
  const handlers = createInboxHandlers({ db, domain, hubContext: slug ? { hubSlug: slug } : undefined });
@@ -1,5 +1,5 @@
1
1
  import { processInboxActivity } from '@commonpub/protocol';
2
- import { createInboxHandlers } from '@commonpub/server';
2
+ import { createInboxHandlers, recordActivitySeen } from '@commonpub/server';
3
3
  import { verifyInboxRequest, assertActorMatchesSigner, extractDomain } from '../utils/inbox';
4
4
 
5
5
  export default defineEventHandler(async (event) => {
@@ -18,6 +18,19 @@ export default defineEventHandler(async (event) => {
18
18
  assertActorMatchesSigner(actorUri, body, 'shared-inbox');
19
19
 
20
20
  const db = useDB();
21
+
22
+ // Replay dedup: claim the verified activity id BEFORE dispatch so a replayed,
23
+ // validly-signed activity can't double-apply side effects. No id = process
24
+ // normally (can't dedup what isn't addressable). Placed after verification so
25
+ // attacker-chosen ids can't be seeded.
26
+ const activityId = body.id;
27
+ if (typeof activityId === 'string' && activityId.length > 0) {
28
+ const first = await recordActivitySeen(db, activityId);
29
+ if (!first) {
30
+ return { status: 'accepted' };
31
+ }
32
+ }
33
+
21
34
  const runtimeConfig = useRuntimeConfig();
22
35
  const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
23
36
  const callbacks = createInboxHandlers({ db, domain });
@@ -1,5 +1,5 @@
1
1
  import { processInboxActivity } from '@commonpub/protocol';
2
- import { createInboxHandlers } from '@commonpub/server';
2
+ import { createInboxHandlers, recordActivitySeen } from '@commonpub/server';
3
3
  import { verifyInboxRequest, assertActorMatchesSigner, extractDomain } from '../../../utils/inbox';
4
4
 
5
5
  export default defineEventHandler(async (event) => {
@@ -17,6 +17,18 @@ export default defineEventHandler(async (event) => {
17
17
  assertActorMatchesSigner(actorUri, body, 'user-inbox');
18
18
 
19
19
  const db = useDB();
20
+
21
+ // Replay dedup: claim the verified activity id BEFORE dispatch so a replayed,
22
+ // validly-signed activity can't double-apply side effects. No id = process
23
+ // normally. Placed after verification so attacker-chosen ids can't be seeded.
24
+ const activityId = body.id;
25
+ if (typeof activityId === 'string' && activityId.length > 0) {
26
+ const first = await recordActivitySeen(db, activityId);
27
+ if (!first) {
28
+ return { status: 'accepted' };
29
+ }
30
+ }
31
+
20
32
  const runtimeConfig = useRuntimeConfig();
21
33
  const domain = extractDomain((runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`);
22
34
  const callbacks = createInboxHandlers({ db, domain });
@@ -4,6 +4,7 @@
4
4
  * body size limits, and Date header freshness checks.
5
5
  */
6
6
  import { verifyHttpSignature, resolveActor } from '@commonpub/protocol';
7
+ import { createSafeActorFetchFn } from '@commonpub/server';
7
8
  import type { H3Event } from 'h3';
8
9
 
9
10
  /** Maximum allowed body size for inbox POSTs (1 MB) */
@@ -97,8 +98,12 @@ export async function verifyInboxRequest(event: H3Event, label: string): Promise
97
98
 
98
99
  const actorUri = keyId.replace(/#.*$/, '');
99
100
 
100
- // 4. Resolve actor and public key
101
- const actor = await resolveActor(actorUri, fetch);
101
+ // 4. Resolve actor and public key.
102
+ // Use the SSRF-pinned fetch (DNS-rebind safe), NOT raw global fetch: actorUri here is
103
+ // the attacker-controlled keyId resolved BEFORE signature verification, so an
104
+ // unauthenticated POST /inbox could otherwise drive a server-side GET to internal
105
+ // addresses (cloud metadata, RFC1918). Audit session 204 — P0.
106
+ const actor = await resolveActor(actorUri, createSafeActorFetchFn());
102
107
  if (!actor?.publicKey?.publicKeyPem) {
103
108
  throw createError({ statusCode: 401, statusMessage: 'Could not resolve actor public key' });
104
109
  }
@@ -9,6 +9,13 @@ import type { ZodType } from 'zod';
9
9
  const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
10
  const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*$/;
11
11
 
12
+ /** True when `value` is a syntactically-valid UUID. Use to guard untrusted
13
+ * query/body values that feed a uuid SQL bind (a non-uuid string reaching
14
+ * the bind throws an unhandled 500). Router params should use `parseParams`. */
15
+ export function isUuid(value: string): boolean {
16
+ return UUID_REGEX.test(value);
17
+ }
18
+
12
19
  /**
13
20
  * Hard ceiling on JSON request bodies (10 MB). Every JSON write route funnels
14
21
  * through `parseBody`, so this one guard caps them all. It rejects on the
@@ -33,10 +40,25 @@ type ParamType = 'uuid' | 'slug' | 'string';
33
40
 
34
41
  /** Parse and validate request body against a Zod schema. Throws 400 on failure, 413 if oversized. */
35
42
  export async function parseBody<T>(event: H3Event, schema: ZodType<T>): Promise<T> {
43
+ // Fast-path: reject on the declared Content-Length before buffering anything.
36
44
  const declaredLength = Number(getRequestHeader(event, 'content-length') ?? 0);
37
45
  if (Number.isFinite(declaredLength) && declaredLength > MAX_JSON_BODY_BYTES) {
38
46
  throw createError({ statusCode: 413, statusMessage: 'Payload too large' });
39
47
  }
48
+
49
+ // The header is advisory and absent on chunked (Transfer-Encoding: chunked)
50
+ // requests — a client can stream an unbounded body with no Content-Length and
51
+ // slip past the fast-path. Read the RAW body once and enforce the cap on the
52
+ // ACTUAL buffered byte size before JSON.parse. `readBody` reuses this cached
53
+ // raw body, so there is no double read.
54
+ const raw: string | Buffer | undefined = await readRawBody(event, false);
55
+ if (raw != null) {
56
+ const size = typeof raw === 'string' ? Buffer.byteLength(raw) : raw.length;
57
+ if (size > MAX_JSON_BODY_BYTES) {
58
+ throw createError({ statusCode: 413, statusMessage: 'Payload too large' });
59
+ }
60
+ }
61
+
40
62
  const body = await readBody(event);
41
63
  const parsed = schema.safeParse(body);
42
64
  if (!parsed.success) {
package/theme/base.css CHANGED
@@ -7,6 +7,11 @@
7
7
  =========================================== */
8
8
 
9
9
  :root {
10
+ /* Theme native UI (date/time pickers, scrollbars, form controls) to the light
11
+ palette; dark.css overrides to `dark`. Without this, native popups (e.g. the
12
+ datetime-local calendar) render in the OS default scheme and clash. */
13
+ color-scheme: light;
14
+
10
15
  /* === SURFACES === */
11
16
  --bg: #fafaf9;
12
17
  --surface: #ffffff;
package/theme/prose.css CHANGED
@@ -339,4 +339,24 @@
339
339
  text-transform: uppercase;
340
340
  letter-spacing: var(--tracking-wide);
341
341
  }
342
+
343
+ /* ===========================================
344
+ Full-HTML author content (CpubMarkdown format=html)
345
+ The v-html children carry no scoped styles, so this global, theme-aware
346
+ baseline keeps un-styled (or var()-based) author HTML readable in BOTH
347
+ themes. Hardcoded color literals are neutralized at sanitize time
348
+ (sanitizeRichHtml neutralizeColors), so this baseline shows through.
349
+ =========================================== */
350
+ .cpub-md-html {
351
+ color: var(--text);
352
+ line-height: var(--leading-normal);
353
+ }
354
+ .cpub-md-html a { color: var(--accent); }
355
+ .cpub-md-html h1, .cpub-md-html h2, .cpub-md-html h3,
356
+ .cpub-md-html h4, .cpub-md-html h5, .cpub-md-html h6 { color: var(--text); }
357
+ .cpub-md-html img { max-width: 100%; height: auto; }
358
+ .cpub-md-html hr { border: 0; border-top: var(--border-width-default) solid var(--border); }
359
+ .cpub-md-html blockquote { border-left: 3px solid var(--border); color: var(--text-dim); padding-left: var(--space-4); }
360
+ .cpub-md-html th, .cpub-md-html td { border: var(--border-width-default) solid var(--border); }
361
+ .cpub-md-html pre, .cpub-md-html code { background: var(--surface2); color: var(--text); }
342
362
  }
@@ -11,6 +11,10 @@
11
11
  =========================================== */
12
12
 
13
13
  [data-theme="stoa-dark"] {
14
+ /* Theme native UI (date/time pickers, scrollbars) dark — dark.css/agora-dark.css
15
+ already do this; stoa-dark was missing it, so native controls rendered light. */
16
+ color-scheme: dark;
17
+
14
18
  /* === SURFACES (warm near-black) === */
15
19
  --bg: #15130d;
16
20
  --surface: #1f1c14;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Contest-specific block content shapes (BlockTuple content objects).
3
+ *
4
+ * These blocks live ENTIRELY in the layer: the edit component is provided to
5
+ * BlockCanvas via `BLOCK_COMPONENTS_KEY` (no @commonpub/editor change — the
6
+ * editor registry is unused; component resolution is `override ?? builtin ??
7
+ * TextBlock`), and the view component is registered in BlockContentRenderer's
8
+ * map. Persistence rides the contest `descriptionBlocks`/`rulesBlocks` jsonb
9
+ * (validated loosely as `BlockTuple[]`), so no registry/schema wiring is needed.
10
+ */
11
+
12
+ /** A single judge/mentor card in the `judgesShowcase` block. */
13
+ export interface JudgeShowcaseEntry {
14
+ name: string;
15
+ /** Avatar image URL (falls back to the name initial). */
16
+ avatarUrl?: string;
17
+ /** Role / affiliation line, e.g. "Lead Judge, ACME Labs". */
18
+ title?: string;
19
+ /** Short bio / description. */
20
+ bio?: string;
21
+ /** Optional profile or external link (http(s) only when rendered). */
22
+ link?: string;
23
+ }
24
+
25
+ /**
26
+ * `judgesShowcase` block — an editorial judges/mentors showcase for the contest
27
+ * overview (avatar + name + bio), curated independently of the `contest_judges`
28
+ * table / Judges tab.
29
+ */
30
+ export interface JudgesShowcaseContent {
31
+ /** Optional section heading, e.g. "Meet the Judges". */
32
+ heading?: string;
33
+ judges: JudgeShowcaseEntry[];
34
+ }
35
+
36
+ /** Block type key shared by the edit component (provide map) + the view (renderer map). */
37
+ export const JUDGES_SHOWCASE_TYPE = 'judgesShowcase';
38
+
39
+ /** A single logo in the `sponsors` block. */
40
+ export interface SponsorLogo {
41
+ /** Logo image URL (uploaded or pasted). */
42
+ src: string;
43
+ /** Accessible name — the organization, used as the img alt. */
44
+ alt: string;
45
+ /** Optional outbound link (http(s) only when rendered). */
46
+ url?: string;
47
+ /** Optional tier label, e.g. "Gold". Logos sharing a tier render in one group. */
48
+ tier?: string;
49
+ }
50
+
51
+ /**
52
+ * `sponsors` block — a logo wall for partners/sponsors. Flat list of logos with an
53
+ * optional eyebrow heading; logos that share a `tier` group together (the view
54
+ * shows the tier labels only when at least one logo is tiered).
55
+ */
56
+ export interface SponsorsContent {
57
+ /** Eyebrow heading above the wall, e.g. "Sponsors". */
58
+ heading?: string;
59
+ logos: SponsorLogo[];
60
+ }
61
+
62
+ export const SPONSORS_TYPE = 'sponsors';
63
+
64
+ /** Tone of a `compareColumns` column — drives its color + per-item icon. */
65
+ export type CompareTone = 'positive' | 'negative' | 'neutral';
66
+
67
+ /** One column in the `compareColumns` block. */
68
+ export interface CompareColumn {
69
+ tone: CompareTone;
70
+ /** Column title, e.g. "Encouraged" / "Out of scope". */
71
+ title: string;
72
+ /** Bullet items (plain text). */
73
+ items: string[];
74
+ }
75
+
76
+ /**
77
+ * `compareColumns` block — side-by-side guidance columns (the classic
78
+ * "Encouraged / Out of scope" or do-vs-don't pattern), with an optional eyebrow,
79
+ * heading, and a footer note.
80
+ */
81
+ export interface CompareColumnsContent {
82
+ /** Eyebrow label above the heading, e.g. "What is in scope". */
83
+ eyebrow?: string;
84
+ /** Heading line. */
85
+ heading?: string;
86
+ columns: CompareColumn[];
87
+ /** Optional footer note shown under the columns. */
88
+ note?: string;
89
+ }
90
+
91
+ export const COMPARE_COLUMNS_TYPE = 'compareColumns';
92
+
93
+ /** Visual emphasis of a roadmap node — default (hollow), accent (filled), highlight (finale). */
94
+ export type RoadmapTone = 'default' | 'accent' | 'highlight';
95
+
96
+ /** One milestone on the `roadmap` timeline. */
97
+ export interface RoadmapItem {
98
+ /** Free-text date label, e.g. "Jun 30" (organizer-editable, not a real date). */
99
+ date?: string;
100
+ title: string;
101
+ /** Plain-text blurb under the title. */
102
+ description?: string;
103
+ /** Optional pill next to the date, e.g. "Mid-term". */
104
+ badge?: string;
105
+ tone?: RoadmapTone;
106
+ }
107
+
108
+ /**
109
+ * `roadmap` block — a vertical schedule timeline. The edit block can seed its
110
+ * items from the contest's stages/schedule (one click), then the organizer edits,
111
+ * reorders, and styles them freely; the saved items are independent of the live
112
+ * stages (present-how-you-like).
113
+ */
114
+ export interface RoadmapContent {
115
+ /** Eyebrow label, e.g. "Key dates, 2026". */
116
+ eyebrow?: string;
117
+ /** Heading line, e.g. "The 18-week roadmap". */
118
+ heading?: string;
119
+ items: RoadmapItem[];
120
+ }
121
+
122
+ export const ROADMAP_TYPE = 'roadmap';
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Helpers for contest content blocks (criteria-bar segment math, the shared
3
+ * theme-color palette) + the inject key the contest editor uses to feed the
4
+ * criteria-bar block its rubric. The math is pure/unit-testable in isolation.
5
+ */
6
+ import type { InjectionKey, Ref } from 'vue';
7
+ import type { RoadmapItem } from '../types/contestBlocks';
8
+
9
+ /** A contest judging-rubric criterion (mirrors useContestEditor's criteria row). */
10
+ export interface ContestRubricCriterion { label: string; weight?: number; description?: string }
11
+
12
+ /** ContestEditor `provide`s its live judging criteria under this key so the
13
+ * criteria-bar edit block can offer a "use this contest's rubric" auto-fill.
14
+ * Absent (null) when the block is used outside the contest editor. */
15
+ export const CONTEST_RUBRIC_KEY: InjectionKey<Ref<ContestRubricCriterion[]>> = Symbol('contestRubric');
16
+
17
+ /** ContestEditor `provide`s a ready-to-use roadmap derived from the contest's
18
+ * effective schedule under this key, so the roadmap block can offer a
19
+ * "pull from schedule" seed. Absent (null) outside the contest editor. */
20
+ export const CONTEST_SCHEDULE_KEY: InjectionKey<Ref<RoadmapItem[]>> = Symbol('contestSchedule');
21
+
22
+ /** A stage as the roadmap cares about it (structural subset of ContestStage). */
23
+ export interface RoadmapStageSource { name: string; kind?: string; startsAt?: string; endsAt?: string; description?: string }
24
+ /** The three core schedule dates, when there are no custom stages. */
25
+ export interface RoadmapScheduleDates { startDate?: string; endDate?: string; judgingEndDate?: string }
26
+
27
+ /** Format an ISO date as a short label ("Jun 30"); '' for empty/invalid input. */
28
+ export function fmtRoadmapDate(iso?: string): string {
29
+ if (!iso) return '';
30
+ const d = new Date(iso);
31
+ if (Number.isNaN(d.getTime())) return '';
32
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
33
+ }
34
+
35
+ /**
36
+ * Derive roadmap items from the contest's effective timeline: the custom stages
37
+ * when present, else the core Submissions → Judging → Results flow from the
38
+ * schedule dates. The `results` kind (and the synthetic finale) get the
39
+ * `highlight` tone. Pure — the seed the roadmap edit block copies in.
40
+ */
41
+ export function roadmapFromSchedule(stages: RoadmapStageSource[] | undefined, schedule: RoadmapScheduleDates): RoadmapItem[] {
42
+ const named = (stages ?? []).filter((s) => (s?.name ?? '').trim());
43
+ if (named.length) {
44
+ return named.map((s) => ({
45
+ date: fmtRoadmapDate(s.startsAt ?? s.endsAt),
46
+ title: s.name.trim(),
47
+ description: (s.description ?? '').trim() || undefined,
48
+ tone: s.kind === 'results' ? 'highlight' : 'default',
49
+ }));
50
+ }
51
+ const items: RoadmapItem[] = [];
52
+ if (schedule.startDate) items.push({ date: fmtRoadmapDate(schedule.startDate), title: 'Submissions open', tone: 'default' });
53
+ if (schedule.endDate) items.push({ date: fmtRoadmapDate(schedule.endDate), title: 'Submissions close', tone: 'default' });
54
+ const judge = schedule.judgingEndDate || schedule.endDate;
55
+ if (judge) items.push({ date: fmtRoadmapDate(judge), title: 'Judging ends', tone: 'default' });
56
+ items.push({ title: 'Results announced', tone: 'highlight' });
57
+ return items;
58
+ }
59
+
60
+ /**
61
+ * Theme color tokens segments cycle through (all dark/light-safe `var(--*)`).
62
+ * Ordered to spread hue across the rotation so adjacent segments stay
63
+ * distinguishable in a seamless (gap-free) bar — incl. green-accent themes where
64
+ * accent/teal/green collapse, so those three are kept apart in the order.
65
+ */
66
+ export const CRITERIA_BAR_PALETTE = ['accent', 'yellow', 'purple', 'teal', 'pink', 'green', 'red'] as const;
67
+ export type CriteriaColorKey = (typeof CRITERIA_BAR_PALETTE)[number];
68
+
69
+ /** Resolve a segment color: the author's palette key, else a rotation by index.
70
+ * Always a theme `var(--*)` so it adapts to light/dark + custom themes. */
71
+ export function criteriaColorVar(key: string | undefined, index = 0): string {
72
+ const k = key && (CRITERIA_BAR_PALETTE as readonly string[]).includes(key)
73
+ ? key
74
+ : CRITERIA_BAR_PALETTE[index % CRITERIA_BAR_PALETTE.length];
75
+ return `var(--${k})`;
76
+ }
77
+
78
+ export interface CriteriaBarItem { label: string; weight?: number; color?: string; description?: string }
79
+ export interface CriteriaRow { label: string; weight: number; description?: string; pct: number; colorVar: string; colorKey: string }
80
+
81
+ /**
82
+ * Resolve criteria items into legend rows + bar geometry. EVERY labeled item
83
+ * becomes a row (so the legend lists them all, even 0-weight/holistic ones);
84
+ * `pct` is each weight's share of the total (the bar fills 100% regardless of
85
+ * whether weights sum to 100). Colors are assigned by the item's index in the
86
+ * labeled list so a row's legend swatch always matches its bar segment. The bar
87
+ * renders `rows.filter(r => r.pct > 0)`.
88
+ */
89
+ export function criteriaBar(items: CriteriaBarItem[] | undefined): { rows: CriteriaRow[]; total: number } {
90
+ const labeled = (items ?? []).filter((i) => (i?.label ?? '').trim());
91
+ const total = labeled.reduce((s, i) => s + Math.max(0, Number(i?.weight) || 0), 0);
92
+ const rows = labeled.map((i, idx) => {
93
+ const key = i.color && (CRITERIA_BAR_PALETTE as readonly string[]).includes(i.color)
94
+ ? i.color
95
+ : CRITERIA_BAR_PALETTE[idx % CRITERIA_BAR_PALETTE.length]!;
96
+ const w = Math.max(0, Number(i.weight) || 0);
97
+ return {
98
+ label: i.label.trim(),
99
+ weight: w,
100
+ description: (i.description ?? '').trim() || undefined,
101
+ pct: total > 0 ? Math.round((w / total) * 1000) / 10 : 0,
102
+ colorVar: `var(--${key})`,
103
+ colorKey: key,
104
+ };
105
+ });
106
+ return { rows, total };
107
+ }
@@ -0,0 +1,25 @@
1
+ import { markdownToBlockTuples, type BlockTuple } from '@commonpub/editor';
2
+
3
+ /**
4
+ * Seed the contest body block editor (overview/rules): prefer existing
5
+ * `BlockTuple[]`; otherwise convert the legacy markdown/html body — the
6
+ * convert-on-edit pattern (CLAUDE rule #4), so editing a legacy contest doesn't
7
+ * lose its content. Legacy HTML is preserved VERBATIM in a single markdown block
8
+ * (lossless) rather than lossily re-parsed; markdown is parsed into real blocks.
9
+ */
10
+ export function seedBodyBlocks(
11
+ blocks: unknown[] | null | undefined,
12
+ legacy?: string | null,
13
+ legacyFormat?: 'markdown' | 'html' | null,
14
+ ): BlockTuple[] {
15
+ if (Array.isArray(blocks) && blocks.length) return blocks as BlockTuple[];
16
+ const text = (legacy ?? '').trim();
17
+ if (!text) return [];
18
+ if (legacyFormat === 'html') return [['markdown', { content: text }]];
19
+ try {
20
+ const parsed = markdownToBlockTuples(text);
21
+ return parsed.length ? parsed : [['markdown', { content: text }]];
22
+ } catch {
23
+ return [['markdown', { content: text }]];
24
+ }
25
+ }