From 3fda06703552c1bd8bbc63b6755678a5938f7109 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 13:08:40 +0000 Subject: [PATCH 1/6] fix(frontend): await quiz-groups update before deleting question DeleteQuestion sent the groups patch with fire-and-forget `send` (the `await` was a no-op on void), so the question could be removed from the QuestionActor before QuizActor had processed the groups update, leaving dangling group references under load. https://claude.ai/code/session_01DUUFUFpsvj4y6xU5172pX4 --- packages/frontend/src/actors/CurrentQuizActor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/src/actors/CurrentQuizActor.ts b/packages/frontend/src/actors/CurrentQuizActor.ts index 45dea603..ccfef043 100644 --- a/packages/frontend/src/actors/CurrentQuizActor.ts +++ b/packages/frontend/src/actors/CurrentQuizActor.ts @@ -598,7 +598,7 @@ export class CurrentQuizActor extends StatefulActor q !== id); return g; }); - await this.send( + await this.ask( actorUris.QuizActor, QuizActorMessages.Update({ uid: this.state.quiz.uid, groups }) ); From 985a699ebe93b52c6f3ccaada663af6157e4f1bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 13:33:34 +0000 Subject: [PATCH 2/6] fix(backend): await storeEntity in SessionStore.StoreSession storeEntity was fire-and-forget: the MongoDB write could fail silently while the caller already received a success reply. On any write error or process restart the session was lost, effectively logging the user out with no warning. Also refactors the async-inside-create pattern out: getEntity is now awaited before create(), and create() is synchronous, which avoids nested create() races introduced by storeEntity's own internal cache update. https://claude.ai/code/session_01DUUFUFpsvj4y6xU5172pX4 --- packages/backend/src/actors/SessionStore.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/actors/SessionStore.ts b/packages/backend/src/actors/SessionStore.ts index cdf6edb1..8eaebe99 100644 --- a/packages/backend/src/actors/SessionStore.ts +++ b/packages/backend/src/actors/SessionStore.ts @@ -47,16 +47,16 @@ export class SessionStore extends StoringActor { const result = await SessionStoreMessages.match>(message, { StoreSession: async session => { - this.state = await create(this.state, async draft => { - const currentSession = (await this.getEntity(session.uid)).orElse({} as Session); + const currentSession = (await this.getEntity(session.uid)).orElse({} as Session); + session.updated = toTimestamp(); + const newSession = { ...currentSession, ...session }; + this.state = create(this.state, draft => { if (session.actorSystem) { draft.clientIndex.set(session.actorSystem, session.uid); } - session.updated = toTimestamp(); - const newSession = { ...currentSession, ...session }; draft.cache.set(session.uid, newSession); - this.storeEntity(newSession); }); + await this.storeEntity(newSession); return unit(); }, CheckSession: async userId => { From 15f85a4f8cefde1319b1bf4287a3025d6c399be3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 13:33:40 +0000 Subject: [PATCH 3/6] fix(backend): await session role sync after user role change The ask+send block inside a synchronous create() callback was fire-and-forget: the admin got a success reply before the session role was updated, leaving a window where a downgraded user still held their old permissions. Moves the session update after storeEntity completes, uses ask for StoreSession (now that StoreSession awaits its own write), and guards against users with no active session. https://claude.ai/code/session_01DUUFUFpsvj4y6xU5172pX4 --- packages/backend/src/actors/UserStore.ts | 44 +++++++++++++----------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/actors/UserStore.ts b/packages/backend/src/actors/UserStore.ts index 82bc609e..478d9eca 100644 --- a/packages/backend/src/actors/UserStore.ts +++ b/packages/backend/src/actors/UserStore.ts @@ -353,26 +353,6 @@ export class UserStore extends SubscribableActor - ) - .then((session: Session) => { - session.role = newUser.role; - this.send(createActorUri("SessionStore"), SessionStoreMessages.StoreSession(session)); - }) - .catch((e: unknown) => { - this.logger.error( - `Failed to update session role for user ${String(newUser.uid)}: ` + - `${e instanceof Error ? e.stack : String(e)}` - ); - }); - } - for (const [subscriber, subscription] of this.state.collectionSubscribers) { this.send( subscriber, @@ -389,8 +369,30 @@ export class UserStore extends SubscribableActor newUser) .catch(error => error as Error); + if (!(storeResult instanceof Error) && oldUser.role !== newUser.role) { + try { + const sessionOrError = await this.ask( + createActorUri("SessionStore"), + SessionStoreMessages.GetSessionForUserId(newUser.uid) + ); + if (!(sessionOrError instanceof Error)) { + const session = sessionOrError as Session; + session.role = newUser.role; + await this.ask( + createActorUri("SessionStore"), + SessionStoreMessages.StoreSession(session) + ); + } + } catch (e: unknown) { + this.logger.error( + `Failed to update session role for user ${String(newUser.uid)}: ` + + `${e instanceof Error ? e.stack : String(e)}` + ); + } + } + return storeResult; }; } From c624f24e4b0fdf480a1f9dce80d381dcf9b2e709 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 13:33:48 +0000 Subject: [PATCH 4/6] fix(backend): await storeEntity in all FingerprintStore handlers Block, Unblock, IncreaseCount, and StoreFingerprint all called storeEntity without await inside synchronous .match() callbacks. A MongoDB write failure was silently discarded, so a blocked fingerprint could revert to unblocked after a process restart. Restructures each handler to extract the fingerprint via early-return rather than .match(), allowing storeEntity to be properly awaited before notifying subscribers. https://claude.ai/code/session_01DUUFUFpsvj4y6xU5172pX4 --- .../backend/src/actors/FingerprintStore.ts | 84 ++++++++----------- 1 file changed, 37 insertions(+), 47 deletions(-) diff --git a/packages/backend/src/actors/FingerprintStore.ts b/packages/backend/src/actors/FingerprintStore.ts index 9dc4f4b8..3a43a07a 100644 --- a/packages/backend/src/actors/FingerprintStore.ts +++ b/packages/backend/src/actors/FingerprintStore.ts @@ -50,14 +50,11 @@ export class FingerprintStore extends SubscribableActor>(message, { StoreFingerprint: async fingerprint => { - this.state = await create(this.state, async draft => { - const currentFingerprint = (await this.getEntity(fingerprint.uid)).orElse({} as Fingerprint); - fingerprint.updated = toTimestamp(); - const newFingerprint = { ...currentFingerprint, ...fingerprint }; - draft.cache.set(fingerprint.uid, newFingerprint); - logger.debug(`Storing new fingerprint ${JSON.stringify(newFingerprint)}`); - this.storeEntity(newFingerprint); - }); + const currentFingerprint = (await this.getEntity(fingerprint.uid)).orElse({} as Fingerprint); + fingerprint.updated = toTimestamp(); + const newFingerprint = { ...currentFingerprint, ...fingerprint }; + logger.debug(`Storing new fingerprint ${JSON.stringify(newFingerprint)}`); + await this.storeEntity(newFingerprint); return unit(); }, Get: async id => { @@ -66,49 +63,42 @@ export class FingerprintStore extends SubscribableActor { - const mbFingerprint = await this.getEntity(id) - const {uid} = await this.ask("actors://recapp-backend/UserStore", UserStoreMessages.GetByFingerprint(id)); - return mbFingerprint.match( - fp => { - this.storeEntity({...fp, blocked: true}); - this.updateSubscribers({...fp, blocked: true}) - if (uid) - this.send("actors://recapp-backend/UserStore", UserStoreMessages.Update({ uid, active: false })); - return unit(); - }, - () => { - return new Error(`Unknown fingerprint id ${id}`); - } - ) + const fp = (await this.getEntity(id)).orUndefined(); + if (!fp) { + return new Error(`Unknown fingerprint id ${id}`); + } + const { uid } = await this.ask("actors://recapp-backend/UserStore", UserStoreMessages.GetByFingerprint(id)); + const updated = { ...fp, blocked: true }; + await this.storeEntity(updated); + this.updateSubscribers(updated); + if (uid) { + this.send("actors://recapp-backend/UserStore", UserStoreMessages.Update({ uid, active: false })); + } + return unit(); }, Unblock: async id => { - const mbFingerprint = await this.getEntity(id) - const {uid} = await this.ask("actors://recapp-backend/UserStore", UserStoreMessages.GetByFingerprint(id)); - return mbFingerprint.match( - fp => { - this.storeEntity({...fp, blocked: false}); - this.updateSubscribers({...fp, blocked: false}) - if (uid) - this.send("actors://recapp-backend/UserStore", UserStoreMessages.Update({ uid, active: true })); - return unit(); - }, - () => { - return new Error(`Unknown fingerprint id ${id}`); - } - ) + const fp = (await this.getEntity(id)).orUndefined(); + if (!fp) { + return new Error(`Unknown fingerprint id ${id}`); + } + const { uid } = await this.ask("actors://recapp-backend/UserStore", UserStoreMessages.GetByFingerprint(id)); + const updated = { ...fp, blocked: false }; + await this.storeEntity(updated); + this.updateSubscribers(updated); + if (uid) { + this.send("actors://recapp-backend/UserStore", UserStoreMessages.Update({ uid, active: true })); + } + return unit(); }, IncreaseCount: async ({fingerprint, userUid, initialQuiz}) => { - const mbFingerprint = await this.getEntity(fingerprint) - return mbFingerprint.match( - fp => { - this.storeEntity({...fp, usageCount: fp.usageCount + 1, lastSeen: toTimestamp(), userUid, initialQuiz: initialQuiz ?? fp.initialQuiz}); - this.updateSubscribers({...fp, usageCount: fp.usageCount + 1, lastSeen: toTimestamp(), userUid, initialQuiz: initialQuiz ?? fp.initialQuiz}) - return unit(); - }, - () => { - return new Error(`Unknown fingerprint id ${fingerprint}`); - } - ) + const fp = (await this.getEntity(fingerprint)).orUndefined(); + if (!fp) { + return new Error(`Unknown fingerprint id ${fingerprint}`); + } + const updated = { ...fp, usageCount: fp.usageCount + 1, lastSeen: toTimestamp(), userUid, initialQuiz: initialQuiz ?? fp.initialQuiz }; + await this.storeEntity(updated); + this.updateSubscribers(updated); + return unit(); }, GetMostRecent: async () => { const db = await this.connector.db(); From 486504a8d8426bc75857f2c90ed98518e33cc045 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 13:33:53 +0000 Subject: [PATCH 5/6] fix(backend): await storeEntity before subscriber fan-out in StatisticsActor Quiz answer statistics were broadcast to all subscribers and returned to the caller before the MongoDB write completed. A write failure was silently swallowed, leaving subscribers with data that was never persisted. https://claude.ai/code/session_01DUUFUFpsvj4y6xU5172pX4 --- packages/backend/src/actors/StatisticsActor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/actors/StatisticsActor.ts b/packages/backend/src/actors/StatisticsActor.ts index deb8d1de..14f3ede5 100644 --- a/packages/backend/src/actors/StatisticsActor.ts +++ b/packages/backend/src/actors/StatisticsActor.ts @@ -243,7 +243,7 @@ export class StatisticsActor extends SubscribableActor< ); // console.log("STATS", stats); this.logger.info(`STATS stored quizId=${String(this.uid)}`); - this.storeEntity(stats); + await this.storeEntity(stats); for (const [subscriber] of this.state.collectionSubscribers) { this.send(subscriber, new StatisticsUpdateMessage(stats)); } From a16da31acf482cfb5b88f47f6596bd08855bd7b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 13:34:00 +0000 Subject: [PATCH 6/6] fix(frontend): handle ask rejections in nickname check and sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChangeNicknameModal: .ask().then() had no .catch(), so a UserStore network error propagated as an unhandled rejection and the uniqueness error was silently lost. SharingActor.AddEntry: same pattern — a UserStore rejection on teacher lookup left the sharing dialog with no feedback. Now maps the rejection to a queryNotFound error entry so the UI shows an explicit failure rather than silently doing nothing. https://claude.ai/code/session_01DUUFUFpsvj4y6xU5172pX4 --- packages/frontend/src/actors/SharingActor.ts | 5 +++++ .../frontend/src/components/modals/ChangeNicknameModal.tsx | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/frontend/src/actors/SharingActor.ts b/packages/frontend/src/actors/SharingActor.ts index 66ef9bd2..94196251 100644 --- a/packages/frontend/src/actors/SharingActor.ts +++ b/packages/frontend/src/actors/SharingActor.ts @@ -76,6 +76,11 @@ export class SharingActor extends StatefulActor { + this.updateState(draft => { + draft.errors.push({ id: toId(v4()), queryNotFound: query }); + }); }); }, Clear: () => { diff --git a/packages/frontend/src/components/modals/ChangeNicknameModal.tsx b/packages/frontend/src/components/modals/ChangeNicknameModal.tsx index 3b526609..65ff6e2f 100644 --- a/packages/frontend/src/components/modals/ChangeNicknameModal.tsx +++ b/packages/frontend/src/components/modals/ChangeNicknameModal.tsx @@ -33,6 +33,7 @@ export const ChangeNicknameModal: React.FC = ({ show, defaultValue, onClo s .ask("actors://recapp-backend/UserStore", UserStoreMessages.IsNicknameUnique(newValue)) .then(result => !result && setError(i18n._("error-nickname-already-used"))) + .catch(() => { /* network error — skip uniqueness check */ }) ); } };