From b87b4dce3ae437fc89544586dbb8946d96348337 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 09:54:06 -0700 Subject: [PATCH 1/8] chore(ci): restrict workflow token permissions Limit GitHub Actions to the read-only repository access required by build and test jobs. Prevent checkout credentials from persisting beyond each checkout step. - grant contents read at workflow scope - disable credential persistence for both jobs --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 160e149e..4bd979d8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,9 @@ on: - main - dev +permissions: + contents: read + jobs: frontend: runs-on: ubuntu-24.04 @@ -20,6 +23,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v6 @@ -48,6 +53,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + persist-credentials: false - name: Set up Temurin JDK uses: actions/setup-java@v5 From 397796c3c57913a27e2006ae515b8ef901e34fea Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 09:54:37 -0700 Subject: [PATCH 2/8] fix(frontend): detect Java keywords at identifier boundaries Avoid marking unrelated code as Java when common words merely contain a Java keyword. Model Java identifier-part semantics so Unicode and ignorable characters cannot split false keyword tokens. - tokenize unmarked code blocks before keyword lookup - cover substring and identifier-ignorable regressions --- .../src/lib/services/javaLanguageDetection.ts | 35 ++++++++++++++-- frontend/src/lib/services/markdown.test.ts | 42 +++++++++++++------ 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/services/javaLanguageDetection.ts b/frontend/src/lib/services/javaLanguageDetection.ts index 007ef198..bb85f40c 100644 --- a/frontend/src/lib/services/javaLanguageDetection.ts +++ b/frontend/src/lib/services/javaLanguageDetection.ts @@ -1,5 +1,5 @@ /** Keywords indicating Java code for auto-detection. */ -const JAVA_KEYWORDS = [ +const JAVA_KEYWORDS = new Set([ "public", "private", "class", @@ -8,7 +8,16 @@ const JAVA_KEYWORDS = [ "String", "int", "boolean", -] as const; +]); + +/** Java's identifier-ignorable ISO control character ranges. */ +const JAVA_IDENTIFIER_IGNORABLE_CONTROL_RANGES = "\\u0000-\\u0008\\u000E-\\u001B\\u007F-\\u009F"; + +/** Matches Java identifier parts, including identifier-ignorable characters. */ +const JAVA_IDENTIFIER_PART_PATTERN = new RegExp( + `[\\p{ID_Continue}\\p{Sc}\\p{Cf}${JAVA_IDENTIFIER_IGNORABLE_CONTROL_RANGES}]`, + "u", +); /** CSS class applied to detected Java code blocks for syntax highlighting. */ const JAVA_LANGUAGE_CLASS = "language-java"; @@ -16,6 +25,26 @@ const JAVA_LANGUAGE_CLASS = "language-java"; /** Selector for unmarked code blocks eligible for language detection. */ const UNMARKED_CODE_SELECTOR = "pre > code:not([class])"; +/** Determines whether the code text contains a standalone Java keyword token. */ +function containsJavaKeyword(codeText: string): boolean { + let javaIdentifier = ""; + + for (const codeCharacter of codeText) { + if (JAVA_IDENTIFIER_PART_PATTERN.test(codeCharacter)) { + javaIdentifier += codeCharacter; + continue; + } + + if (JAVA_KEYWORDS.has(javaIdentifier)) { + return true; + } + + javaIdentifier = ""; + } + + return JAVA_KEYWORDS.has(javaIdentifier); +} + /** Adds Java highlighting metadata to unmarked code blocks that contain Java keywords. */ export function applyJavaLanguageDetection(container: HTMLElement | null | undefined): void { if (!container || typeof container.querySelectorAll !== "function") { @@ -31,7 +60,7 @@ export function applyJavaLanguageDetection(container: HTMLElement | null | undef const codeBlocks = container.querySelectorAll(UNMARKED_CODE_SELECTOR); codeBlocks.forEach((codeBlock) => { const codeText = codeBlock.textContent ?? ""; - if (JAVA_KEYWORDS.some((javaKeyword) => codeText.includes(javaKeyword))) { + if (containsJavaKeyword(codeText)) { codeBlock.className = JAVA_LANGUAGE_CLASS; } }); diff --git a/frontend/src/lib/services/markdown.test.ts b/frontend/src/lib/services/markdown.test.ts index d592520f..ccbf742e 100644 --- a/frontend/src/lib/services/markdown.test.ts +++ b/frontend/src/lib/services/markdown.test.ts @@ -189,23 +189,17 @@ describe("applyJavaLanguageDetection", () => { expect(code.className).toBe(""); }); - it("detects various Java keywords", () => { - const javaKeywords = [ - "public", - "private", - "class", - "import", - "void", - "String", - "int", - "boolean", + it("detects Java keywords at identifier boundaries", () => { + const javaKeywordBoundarySnippets = [ + "int[] greetingCounts = { 1 };", + 'String.valueOf("hello");', ]; - for (const keyword of javaKeywords) { + for (const javaKeywordBoundarySnippet of javaKeywordBoundarySnippets) { const container = document.createElement("div"); const pre = document.createElement("pre"); const code = document.createElement("code"); - code.textContent = `${keyword} something`; + code.textContent = javaKeywordBoundarySnippet; pre.appendChild(code); container.appendChild(pre); @@ -214,6 +208,30 @@ describe("applyJavaLanguageDetection", () => { expect(code.className).toBe("language-java"); } }); + + it("does not classify keyword substrings or identifier continuations as Java", () => { + const nonJavaSnippets = [ + 'print("hello")', + "const point = { horizontal: 1 };", + 'const hint = "tip";', + 'const publication = "article";', + 'const republic = "state";', + 'const public\uFEFFation = "article";', + ]; + + for (const nonJavaSnippet of nonJavaSnippets) { + const container = document.createElement("div"); + const pre = document.createElement("pre"); + const code = document.createElement("code"); + code.textContent = nonJavaSnippet; + pre.appendChild(code); + container.appendChild(pre); + + applyJavaLanguageDetection(container); + + expect(code.className).toBe(""); + } + }); }); describe("escapeHtml", () => { From 236eb8928d8455dc659a24000b30ec4b31704bbf Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 09:55:13 -0700 Subject: [PATCH 3/8] fix(styles): remove invalid font quoting Satisfy the canonical font-family syntax while shrinking the touched global stylesheet below its repository size ceiling. Remove animation definitions with no consumers instead of relocating dead styles. - unquote the Fraunces family name - delete two unused keyframe rules --- frontend/src/styles/global.css | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 70661105..8ef0e25b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -5,7 +5,7 @@ */ @font-face { - font-family: "Fraunces"; + font-family: Fraunces; src: url("/fonts/Fraunces-Variable.ttf") format("truetype"); font-weight: 100 900; font-display: swap; @@ -294,27 +294,6 @@ body { } } -@keyframes slide-in-right { - from { - opacity: 0; - transform: translateX(-12px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -@keyframes pulse-subtle { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.6; - } -} - @keyframes typing-cursor { 0%, 100% { From 0c1069fd6def25d31f973ed75217399e9f2089fd Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 09:55:49 -0700 Subject: [PATCH 4/8] fix(streaming): keep exception details out of guided SSE Prevent guided chat failures from exposing internal exception class names to clients. Preserve retry metadata and structured server diagnostics for operators. - emit a fixed user-facing stream error contract - assert exception types and secret messages remain server-side --- .../web/GuidedLearningController.java | 10 ++-- ...earningControllerStreamingFailureTest.java | 47 ++++++++++++++++--- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java index 1028daf1..7d664973 100644 --- a/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java +++ b/src/main/java/com/williamcallahan/javachat/web/GuidedLearningController.java @@ -43,6 +43,9 @@ @PreAuthorize("permitAll()") public class GuidedLearningController extends BaseController { private static final int MAX_GUIDED_LOG_FIELD_LENGTH = 256; + private static final String GUIDED_CHAT_STREAM_ERROR_MESSAGE = "Streaming error"; + private static final String GUIDED_CHAT_STREAM_ERROR_DETAILS = + "The response stream encountered an error. Please try again."; private static final Logger log = LoggerFactory.getLogger(GuidedLearningController.class); /** Timeout for synchronous lesson content generation operations. */ @@ -322,14 +325,9 @@ private Flux> streamGuidedResponse(String sessionId, Str .addKeyValue("exceptionType", error.getClass().getSimpleName()) .log(); } - Throwable upstreamFailure = terminalFailureContext - .map(ReportedStreamingFailure::upstreamFailure) - .orElse(error); boolean retryable = openAIStreamingService.isRecoverableStreamingFailure(error); return sseSupport.streamErrorEvent( - "Streaming error: " + upstreamFailure.getClass().getSimpleName(), - "The response stream encountered an error. Please try again.", - retryable); + GUIDED_CHAT_STREAM_ERROR_MESSAGE, GUIDED_CHAT_STREAM_ERROR_DETAILS, retryable); }); } diff --git a/src/test/java/com/williamcallahan/javachat/web/GuidedLearningControllerStreamingFailureTest.java b/src/test/java/com/williamcallahan/javachat/web/GuidedLearningControllerStreamingFailureTest.java index bedfc897..0c34e082 100644 --- a/src/test/java/com/williamcallahan/javachat/web/GuidedLearningControllerStreamingFailureTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/GuidedLearningControllerStreamingFailureTest.java @@ -1,5 +1,9 @@ package com.williamcallahan.javachat.web; +import static com.williamcallahan.javachat.web.SseConstants.EVENT_ERROR; +import static com.williamcallahan.javachat.web.SseConstants.STATUS_CODE_STREAM_PROVIDER_FATAL_ERROR; +import static com.williamcallahan.javachat.web.SseConstants.STATUS_CODE_STREAM_PROVIDER_RETRYABLE_ERROR; +import static com.williamcallahan.javachat.web.SseConstants.STATUS_STAGE_STREAM; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -15,6 +19,7 @@ import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.williamcallahan.javachat.config.AppProperties; import com.williamcallahan.javachat.domain.prompt.StructuredPrompt; @@ -26,16 +31,18 @@ import com.williamcallahan.javachat.service.RetrievalService; import com.williamcallahan.javachat.service.StreamingResult; import java.util.List; +import java.util.Objects; import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; +import org.springframework.http.codec.ServerSentEvent; import org.springframework.mock.web.MockHttpServletResponse; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -/** Verifies guided stream boundaries do not duplicate service-owned terminal stream alerts. */ +/** Verifies guided stream boundaries keep failure diagnostics internal without duplicating terminal alerts. */ class GuidedLearningControllerStreamingFailureTest { private static final String SESSION_ID = "guided-session"; private static final String LESSON_SLUG = "sealed-classes"; @@ -44,6 +51,7 @@ class GuidedLearningControllerStreamingFailureTest { private final Logger controllerLogger = (Logger) LoggerFactory.getLogger(GuidedLearningController.class); private final ListAppender logAppender = new ListAppender<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); private GuidedLearningService guidedLearningService; private ChatMemoryService chatMemoryService; @@ -65,7 +73,7 @@ void setUpController() { streamingService, new ExceptionResponseBuilder(), mock(MarkdownService.class), - new SseSupport(new ObjectMapper()), + new SseSupport(objectMapper), new AppProperties()); } @@ -77,7 +85,7 @@ void stopCapturingControllerLogs() { } @Test - void guidedChatDoesNotEmitDuplicateErrorForTerminalStreamingFailure() { + void guidedChatKeepsTerminalExceptionTypeOutOfRetryableClientError() throws JsonProcessingException { ReportedTerminalStreamingFailure terminalFailure = terminalFailure(); when(streamingService.isAvailable()).thenReturn(true); when(chatMemoryService.getHistory(SESSION_ID)).thenReturn(List.of()); @@ -90,12 +98,21 @@ void guidedChatDoesNotEmitDuplicateErrorForTerminalStreamingFailure() { Flux.error(terminalFailure), RateLimitService.ApiProvider.OPENAI, Flux.empty()))); when(streamingService.isRecoverableStreamingFailure(terminalFailure)).thenReturn(true); - List streamEvents = guidedController.stream( + List> streamEvents = guidedController.stream( new GuidedStreamRequest(SESSION_ID, LESSON_SLUG, USER_QUERY), new MockHttpServletResponse()) .collectList() .block(); assertFalse(streamEvents.isEmpty()); + String serializedStreamError = serializedErrorEvent(streamEvents); + SseSupport.SseEventPayload streamError = + objectMapper.readValue(serializedStreamError, SseSupport.SseEventPayload.class); + assertEquals("Streaming error", streamError.message()); + assertEquals("The response stream encountered an error. Please try again.", streamError.details()); + assertEquals(STATUS_CODE_STREAM_PROVIDER_RETRYABLE_ERROR, streamError.code()); + assertEquals(Boolean.TRUE, streamError.retryable()); + assertEquals(STATUS_STAGE_STREAM, streamError.stage()); + assertFalse(serializedStreamError.contains(IllegalStateException.class.getSimpleName())); assertEquals(0, controllerErrorCount()); } @@ -106,7 +123,7 @@ void guidedLessonContentDoesNotEmitDuplicateErrorForTerminalStreamingFailure() { when(guidedLearningService.streamLessonContent(LESSON_SLUG)).thenReturn(Flux.error(terminalFailure)); when(streamingService.isRecoverableStreamingFailure(terminalFailure)).thenReturn(true); - List streamEvents = guidedController + List> streamEvents = guidedController .streamLesson(LESSON_SLUG, new MockHttpServletResponse()) .collectList() .block(); @@ -116,7 +133,7 @@ void guidedLessonContentDoesNotEmitDuplicateErrorForTerminalStreamingFailure() { } @Test - void guidedChatLogsBoundedNonTerminalContextWithoutThrowable() { + void guidedChatKeepsNonTerminalExceptionTypeInStructuredLogOnly() throws JsonProcessingException { IllegalStateException upstreamFailure = new IllegalStateException(UPSTREAM_SECRET_MESSAGE); when(streamingService.isAvailable()).thenReturn(true); when(chatMemoryService.getHistory(SESSION_ID)).thenReturn(List.of()); @@ -129,12 +146,20 @@ void guidedChatLogsBoundedNonTerminalContextWithoutThrowable() { Flux.error(upstreamFailure), RateLimitService.ApiProvider.OPENAI, Flux.empty()))); when(streamingService.isRecoverableStreamingFailure(upstreamFailure)).thenReturn(false); - List streamEvents = guidedController.stream( + List> streamEvents = guidedController.stream( new GuidedStreamRequest(SESSION_ID, LESSON_SLUG, USER_QUERY), new MockHttpServletResponse()) .collectList() .block(); assertFalse(streamEvents.isEmpty()); + String serializedStreamError = serializedErrorEvent(streamEvents); + SseSupport.SseEventPayload streamError = + objectMapper.readValue(serializedStreamError, SseSupport.SseEventPayload.class); + assertEquals("Streaming error", streamError.message()); + assertEquals(STATUS_CODE_STREAM_PROVIDER_FATAL_ERROR, streamError.code()); + assertEquals(Boolean.FALSE, streamError.retryable()); + assertFalse(serializedStreamError.contains(IllegalStateException.class.getSimpleName())); + assertFalse(serializedStreamError.contains(UPSTREAM_SECRET_MESSAGE)); ILoggingEvent controllerAlert = logAppender.list.stream() .filter(logEvent -> logEvent.getLevel() == Level.ERROR) .findFirst() @@ -156,6 +181,14 @@ private long controllerErrorCount() { .count(); } + private static String serializedErrorEvent(List> streamEvents) { + ServerSentEvent errorEvent = streamEvents.stream() + .filter(streamEvent -> EVENT_ERROR.equals(streamEvent.event())) + .findFirst() + .orElseThrow(); + return Objects.requireNonNull(errorEvent.data(), "error event data"); + } + private void assertLogField(ILoggingEvent controllerAlert, String fieldName, Object expectedField) { assertTrue(controllerAlert.getKeyValuePairs().stream() .anyMatch(structuredField -> From f67a6b5b882ba045b328a15fc9c2746022f8ee41 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Mon, 13 Jul 2026 10:26:00 -0700 Subject: [PATCH 5/8] fix(accessibility): name the mobile lesson back control The lesson back control hid its only visible label at the mobile breakpoint, leaving screen readers with an unnamed button. - retain an All Lessons accessible name at every viewport - cover the responsive control with a role-and-name regression test --- frontend/src/lib/components/LearnView.svelte | 7 ++++++- frontend/src/lib/components/LearnView.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/LearnView.svelte b/frontend/src/lib/components/LearnView.svelte index 6f66c9a3..c07340d3 100644 --- a/frontend/src/lib/components/LearnView.svelte +++ b/frontend/src/lib/components/LearnView.svelte @@ -609,7 +609,12 @@
-