@commonpub/layer 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -29,8 +29,8 @@
29
29
  "dependencies": {
30
30
  "@aws-sdk/client-s3": "^3.1010.0",
31
31
  "@commonpub/explainer": "^0.7.12",
32
- "@commonpub/schema": "^0.14.1",
33
- "@commonpub/server": "^2.45.1",
32
+ "@commonpub/schema": "^0.14.3",
33
+ "@commonpub/server": "^2.47.0",
34
34
  "@tiptap/core": "^2.11.0",
35
35
  "@tiptap/extension-bold": "^2.11.0",
36
36
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -53,12 +53,12 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
- "@commonpub/editor": "0.7.9",
57
- "@commonpub/learning": "0.5.0",
58
56
  "@commonpub/auth": "0.5.1",
59
- "@commonpub/protocol": "0.9.9",
60
57
  "@commonpub/docs": "0.6.2",
61
58
  "@commonpub/config": "0.11.0",
59
+ "@commonpub/learning": "0.5.1",
60
+ "@commonpub/editor": "0.7.9",
61
+ "@commonpub/protocol": "0.9.9",
62
62
  "@commonpub/ui": "0.8.5"
63
63
  },
64
64
  "devDependencies": {
@@ -1,13 +1,29 @@
1
1
  import { getLessonBySlug, markLessonComplete } from '@commonpub/server';
2
+ import { completeLessonSchema } from '@commonpub/learning';
2
3
 
3
4
  export default defineEventHandler(async (event) => {
4
5
  const user = requireAuth(event);
5
6
  const db = useDB();
6
7
  const { slug, lessonSlug } = parseParams(event, { slug: 'string', lessonSlug: 'string' });
7
- const body = await readBody(event).catch(() => ({}));
8
+
9
+ // Validate body. Strip any client-supplied quizScore/quizPassed (the schema
10
+ // is `.strict()` and only whitelists `answers`) — server is the source of
11
+ // truth for whether a quiz passed. Accept empty body for non-quiz lessons.
12
+ const input = await parseBody(event, completeLessonSchema);
8
13
 
9
14
  const result = await getLessonBySlug(db, slug, lessonSlug);
10
15
  if (!result) throw createError({ statusCode: 404, statusMessage: 'Lesson not found' });
11
16
 
12
- return markLessonComplete(db, user.id, result.lesson.id, body?.quizScore, body?.quizPassed);
17
+ try {
18
+ return await markLessonComplete(db, user.id, result.lesson.id, input.answers);
19
+ } catch (err: unknown) {
20
+ const msg = err instanceof Error ? err.message : String(err);
21
+ if (msg.includes('Quiz lessons require answers')) {
22
+ throw createError({ statusCode: 400, statusMessage: msg });
23
+ }
24
+ if (msg.includes('Not enrolled')) {
25
+ throw createError({ statusCode: 403, statusMessage: msg });
26
+ }
27
+ throw err;
28
+ }
13
29
  });
@@ -1,5 +1,6 @@
1
1
  import { getLessonBySlug } from '@commonpub/server';
2
2
  import { renderMarkdown } from '@commonpub/docs';
3
+ import { redactQuizAnswers } from '@commonpub/learning';
3
4
 
4
5
  function blocksToHtml(blocks: unknown): string {
5
6
  if (!Array.isArray(blocks)) return '';
@@ -39,6 +40,7 @@ function blocksToHtml(blocks: unknown): string {
39
40
  export default defineEventHandler(async (event) => {
40
41
  const db = useDB();
41
42
  const { slug, lessonSlug } = parseParams(event, { slug: 'string', lessonSlug: 'string' });
43
+ const user = getOptionalUser(event);
42
44
 
43
45
  const result = await getLessonBySlug(db, slug, lessonSlug);
44
46
  if (!result) {
@@ -64,5 +66,13 @@ export default defineEventHandler(async (event) => {
64
66
  }
65
67
  }
66
68
 
67
- return { ...result, renderedHtml };
69
+ // Quiz lessons: strip `correctOptionId` + `explanation` from each question
70
+ // unless the caller is the path author. Without this, anyone enrolled (or
71
+ // anonymous) could fetch the answer key directly from the lesson content.
72
+ const isAuthor = !!user && user.id === result.pathAuthorId;
73
+ const safeLesson = isAuthor
74
+ ? result.lesson
75
+ : { ...result.lesson, content: redactQuizAnswers(result.lesson.content as Record<string, unknown>) };
76
+
77
+ return { ...result, lesson: safeLesson, renderedHtml };
68
78
  });
@@ -1,9 +1,20 @@
1
- import { getUnreadCount, getUnreadMessageCount } from '@commonpub/server';
1
+ import { getUnreadCount, getUnreadMessageCount, subscribeSseEvents } from '@commonpub/server';
2
2
 
3
3
  /**
4
4
  * Unified SSE stream for notification and message counts.
5
- * Replaces the separate /api/notifications/stream and /api/messages/stream endpoints.
6
- * Sends both counts in a single event, halving DB polls and open connections.
5
+ *
6
+ * Two delivery paths layered together:
7
+ * 1. Pub/sub — the server emits on a per-user channel whenever a
8
+ * notification or message is written. When `NUXT_REDIS_URL` is set,
9
+ * events cross Nitro processes; without Redis, fanout is in-process
10
+ * only (same behavior as before session 130).
11
+ * 2. Polling — every 30 s we re-query counts as a safety net, so a
12
+ * missed publish (Redis blip, connection drop) resolves itself in
13
+ * one poll tick instead of until the client reconnects.
14
+ *
15
+ * Both paths converge on `sendCounts()`, which fetches fresh counts from
16
+ * the DB. The pub/sub payload is a nudge; we never trust it as the source
17
+ * of truth.
7
18
  */
8
19
  export default defineEventHandler(async (event) => {
9
20
  const user = requireAuth(event);
@@ -14,20 +25,43 @@ export default defineEventHandler(async (event) => {
14
25
  const stream = new ReadableStream({
15
26
  async start(controller) {
16
27
  let closed = false;
28
+ let unsubscribe: (() => void) | null = null;
29
+ let sending = false;
30
+ let pendingSend = false;
31
+
17
32
  function cleanup(): void {
18
33
  if (closed) return;
19
34
  closed = true;
20
35
  clearInterval(interval);
21
36
  clearInterval(keepalive);
37
+ if (unsubscribe) {
38
+ try { unsubscribe(); } catch { /* ignore */ }
39
+ unsubscribe = null;
40
+ }
22
41
  try { controller.close(); } catch { /* already closed */ }
23
42
  }
24
43
 
25
44
  async function sendCounts(): Promise<void> {
26
- const [notifications, messages] = await Promise.all([
27
- getUnreadCount(db, userId),
28
- getUnreadMessageCount(db, userId),
29
- ]);
30
- controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications, messages })}\n\n`));
45
+ if (closed) return;
46
+ // Coalesce overlapping triggers — if a pub/sub event fires while
47
+ // a previous sendCounts is still resolving, set pendingSend and
48
+ // run one more round after the current call returns. Prevents a
49
+ // burst of publishes from piling up N DB queries.
50
+ if (sending) { pendingSend = true; return; }
51
+ sending = true;
52
+ try {
53
+ do {
54
+ pendingSend = false;
55
+ const [notifications, messages] = await Promise.all([
56
+ getUnreadCount(db, userId),
57
+ getUnreadMessageCount(db, userId),
58
+ ]);
59
+ if (closed) return;
60
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications, messages })}\n\n`));
61
+ } while (pendingSend && !closed);
62
+ } finally {
63
+ sending = false;
64
+ }
31
65
  }
32
66
 
33
67
  // Send initial counts — if DB is unavailable, send zeros and let polling retry
@@ -37,14 +71,27 @@ export default defineEventHandler(async (event) => {
37
71
  controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'counts', notifications: 0, messages: 0 })}\n\n`));
38
72
  }
39
73
 
40
- // Poll every 10 seconds
74
+ // Pub/sub subscription nudges the send path whenever a
75
+ // notification or message is written for this user.
76
+ try {
77
+ unsubscribe = await subscribeSseEvents(userId, () => {
78
+ sendCounts().catch(() => {});
79
+ });
80
+ } catch {
81
+ // Pub/sub unavailable — polling alone still works, just slower.
82
+ unsubscribe = null;
83
+ }
84
+
85
+ // Polling fallback at 30 s (was 10 s). The pub/sub path is the
86
+ // primary delivery mechanism; polling only guards against missed
87
+ // events (Redis restart, subscriber dropped).
41
88
  const interval = setInterval(async () => {
42
89
  try {
43
90
  await sendCounts();
44
91
  } catch {
45
92
  cleanup();
46
93
  }
47
- }, 10000);
94
+ }, 30000);
48
95
 
49
96
  // Keepalive every 30 seconds
50
97
  const keepalive = setInterval(() => {
@@ -77,7 +77,7 @@ export default defineEventHandler(async (event) => {
77
77
 
78
78
  // Per-key rate limit (separate store from IP-based rate limit so a noisy
79
79
  // public-API consumer can't DoS the web UI for their own home IP).
80
- const rl = apiKeyRateLimit.check(key.id, key.rateLimitPerMinute);
80
+ const rl = await apiKeyRateLimit.check(key.id, key.rateLimitPerMinute);
81
81
  setResponseHeader(event, 'X-RateLimit-Limit', String(rl.limit));
82
82
  setResponseHeader(event, 'X-RateLimit-Remaining', String(rl.remaining));
83
83
  setResponseHeader(event, 'X-RateLimit-Reset', String(rl.resetAt));
@@ -1,10 +1,12 @@
1
1
  // Security middleware — rate limiting + security headers + CSP
2
- import { RateLimitStore, checkRateLimit, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
2
+ import { checkRateLimit, createRateLimitStore, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
3
3
 
4
- const store = new RateLimitStore();
4
+ // Selects a Redis-backed store when NUXT_REDIS_URL is set, otherwise the
5
+ // in-process memory store. Unset env = byte-identical behavior to pre-0.6.
6
+ const store = createRateLimitStore({ redisUrl: process.env.NUXT_REDIS_URL });
5
7
  const isDev = process.env.NODE_ENV !== 'production';
6
8
 
7
- export default defineEventHandler((event) => {
9
+ export default defineEventHandler(async (event) => {
8
10
  const url = getRequestURL(event);
9
11
  const pathname = url.pathname;
10
12
 
@@ -18,7 +20,7 @@ export default defineEventHandler((event) => {
18
20
  || 'unknown';
19
21
 
20
22
  const userId = event.context.auth?.user?.id as string | undefined;
21
- const { result, headers: rlHeaders } = checkRateLimit(store, ip, pathname, userId);
23
+ const { result, headers: rlHeaders } = await checkRateLimit(store, ip, pathname, userId);
22
24
 
23
25
  for (const [key, value] of Object.entries(rlHeaders)) {
24
26
  setResponseHeader(event, key, value);