fix(PROJ-11): clear the remaining QA backlog (BUG-5/6/9/10/13/14/15/16/17/21/22)
Every previously-reported bug from the three QA rounds is now addressed (BUG-11 and BUG-15 mitigated rather than fully solved — see spec for why). Verified end-to-end on talkhub/matrix after each change. Security: - BUG-21: @fastify/helmet registered, CORS origin allowlist (APP_BASE_URL + localhost:5173) replaces `origin: true`. Verified: own origin gets CORS + HSTS/nosniff/X-Frame-Options headers, foreign origins get none. - BUG-13: GET /api/teams only returns matrixRoomId for teams the requester actually belongs to (was leaking every team's room id to any org member). Verified with/without team membership. - BUG-14: leftJoin now filters scope='team'; new partial unique index chat_rooms_team_id_team_scope_unique enforces one room per team at the DB level, not just in the query (migration 0002). Reliability: - BUG-5: use-team-chat.ts's setup poll now backs off exponentially (5s->60s) instead of a fixed 15s; SyncState.Error stops the client and reconnects with backoff instead of letting matrix-js-sdk retry against Synapse forever. - BUG-6: one-off backfill script (src/scripts/backfill-history-visibility.ts) sets history_visibility=shared on rooms created before that fix; run against talkhub/matrix. - BUG-9: route rate limit on GET /api/chat/room, unified 404 for nonexistent-team vs not-a-member (was a cross-org existence oracle), LRU-correct eviction order in matrix-token-cache.ts, scroll position preserved on "load older messages", and eslint.config.js added (lint never ran on this feature before — flat config was simply missing). - BUG-15 (mitigated): a small module-level credential cache in lib/matrix.ts lets useTeamChat and useTeamUnreadCounts share one fetched token instead of each hitting POST /api/chat/token independently. Reduces but doesn't eliminate rate-limit risk under rapid team switching (no in-flight dedup between the two hooks). - BUG-16: refreshCounts() checks `cancelled` before setCounts. - BUG-17: use-team-chat.ts uses getRoomUnreadNotificationCount() instead of the deprecated getUnreadNotificationCount(); left unchanged in use-team-unread-counts.ts on purpose, since importing NotificationCountType there would undo BUG-11's bundle-splitting fix. Cleanup: - BUG-10: new POST /api/chat/logout invalidates the cached token and calls Synapse's logout_all admin endpoint; frontend calls it best-effort on logout and clears its own credential cache. - BUG-22: trim() on displayName construction (trailing space with an empty last name); the reported trailing-dot localpart is moot after BUG-20 since localparts are random now, not name-derived. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
1 parent
f3361d695e
commit
edc05d4d1e
23 files changed
+2288
-70
No files matched your search
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX "chat_rooms_team_id_team_scope_unique" ON "chat_rooms" USING btree ("team_id") WHERE "chat_rooms"."scope" = 'team';
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,13 @@
|
||||
"when": 1789144826183,
|
||||
"tag": "0001_rare_mulholland_black",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1789160534830,
|
||||
"tag": "0002_melted_imperial_guard",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+51
-2
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "ai-coding-starter-kit-backend",
|
||||
"name": "talktime-backend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ai-coding-starter-kit-backend",
|
||||
"name": "talktime-backend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/helmet": "^13.1.1",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/rate-limit": "^10.1.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -1004,6 +1005,42 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/helmet": {
|
||||
"version": "13.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.1.1.tgz",
|
||||
"integrity": "sha512-bSat5DTq8geASv8G6P0KW1UbltZ+xGD/zyd9S72pT7ogAHehcsWL85GdjMRCjDsExJvaEvgEZ52qU/2HXirVCw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"helmet": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/helmet/node_modules/fastify-plugin": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
|
||||
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/jwt": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.2.tgz",
|
||||
@@ -1697,6 +1734,18 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
||||
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.1",
|
||||
"@fastify/helmet": "^13.1.1",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/rate-limit": "^10.1.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
integer,
|
||||
pgEnum,
|
||||
unique,
|
||||
uniqueIndex,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
@@ -249,7 +251,16 @@ export const chatRooms = pgTable(
|
||||
teamId: uuid("team_id").references(() => teams.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index("chat_rooms_organization_id_idx").on(table.organizationId)],
|
||||
(table) => [
|
||||
index("chat_rooms_organization_id_idx").on(table.organizationId),
|
||||
// BUG-14: without this, a repeated/failed create_team_room sync job could
|
||||
// leave two rooms pointing at the same team, which made GET /api/teams'
|
||||
// leftJoin return that team twice (duplicate tiles, duplicate React keys,
|
||||
// one unread count silently overwriting the other).
|
||||
uniqueIndex("chat_rooms_team_id_team_scope_unique")
|
||||
.on(table.teamId)
|
||||
.where(sql`${table.scope} = 'team'`),
|
||||
],
|
||||
);
|
||||
|
||||
export const syncJobStatusEnum = pgEnum("sync_job_status", [
|
||||
|
||||
+79
-43
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { chatRooms, members, teamMembers, teams } from "../db/schema.js";
|
||||
import { publicHomeserverUrl } from "../services/synapse.js";
|
||||
import { publicHomeserverUrl, logoutAllSessions } from "../services/synapse.js";
|
||||
import {
|
||||
getMatrixAccessToken,
|
||||
invalidateMatrixAccessToken,
|
||||
@@ -98,50 +98,86 @@ export async function chatRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/api/chat/room", { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const parsed = roomQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
||||
}
|
||||
const { teamId } = parsed.data;
|
||||
// BUG-10: regular logout used to leave the cached Matrix token (and the one
|
||||
// Synapse itself issued) valid for up to its full 1h TTL. Only suspend/leave
|
||||
// revoked it via deactivateUser(). This lets the frontend revoke on logout too.
|
||||
app.post("/api/chat/logout", { preHandler: app.authenticate }, async (request, reply) => {
|
||||
const rows = await db
|
||||
.select({ matrixUserId: members.matrixUserId })
|
||||
.from(members)
|
||||
.where(and(eq(members.userId, request.user.sub), eq(members.status, "active")));
|
||||
|
||||
const [team] = await db
|
||||
.select({ organizationId: teams.organizationId })
|
||||
.from(teams)
|
||||
.where(eq(teams.id, teamId))
|
||||
.limit(1);
|
||||
if (!team) {
|
||||
return reply.code(404).send({ error: "Team not found" });
|
||||
for (const row of rows) {
|
||||
if (row.matrixUserId) {
|
||||
invalidateMatrixAccessToken(row.matrixUserId);
|
||||
try {
|
||||
await logoutAllSessions(row.matrixUserId);
|
||||
} catch (err) {
|
||||
request.log.warn({ err }, "[chat] failed to revoke matrix sessions on logout");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only members of that very team may read its room id.
|
||||
const [membership] = await db
|
||||
.select({ id: teamMembers.id })
|
||||
.from(teamMembers)
|
||||
.innerJoin(members, eq(members.id, teamMembers.memberId))
|
||||
.where(
|
||||
and(
|
||||
eq(teamMembers.teamId, teamId),
|
||||
eq(members.userId, request.user.sub),
|
||||
eq(members.status, "active"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!membership) {
|
||||
return reply.code(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
const [room] = await db
|
||||
.select({ matrixRoomId: chatRooms.matrixRoomId })
|
||||
.from(chatRooms)
|
||||
.where(and(eq(chatRooms.teamId, teamId), eq(chatRooms.scope, "team")))
|
||||
.limit(1);
|
||||
|
||||
if (!room) {
|
||||
// Room creation job has not run yet — UI shows "Raum wird eingerichtet".
|
||||
return reply.code(409).send({ error: "room_not_provisioned" });
|
||||
}
|
||||
|
||||
return reply.send({ matrixRoomId: room.matrixRoomId });
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/api/chat/room",
|
||||
{
|
||||
preHandler: app.authenticate,
|
||||
// BUG-9: this fires on every setup-poll iteration alongside /api/chat/token,
|
||||
// so it needs its own limit rather than relying on the global 100/min.
|
||||
config: { rateLimit: { max: 30, timeWindow: "1 minute" } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = roomQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: parsed.error.issues[0].message });
|
||||
}
|
||||
const { teamId } = parsed.data;
|
||||
|
||||
const [team] = await db
|
||||
.select({ organizationId: teams.organizationId })
|
||||
.from(teams)
|
||||
.where(eq(teams.id, teamId))
|
||||
.limit(1);
|
||||
|
||||
// BUG-9: a non-existent team and "team exists but you're not on it" used to
|
||||
// return different status codes (404 vs 403), letting anyone probe team IDs
|
||||
// across org boundaries. Both now say 404 — indistinguishable from the
|
||||
// outside, low real impact since these are UUIDs, but no reason to leak it.
|
||||
if (!team) {
|
||||
return reply.code(404).send({ error: "Team not found" });
|
||||
}
|
||||
|
||||
const [membership] = await db
|
||||
.select({ id: teamMembers.id })
|
||||
.from(teamMembers)
|
||||
.innerJoin(members, eq(members.id, teamMembers.memberId))
|
||||
.where(
|
||||
and(
|
||||
eq(teamMembers.teamId, teamId),
|
||||
eq(members.userId, request.user.sub),
|
||||
eq(members.status, "active"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!membership) {
|
||||
return reply.code(404).send({ error: "Team not found" });
|
||||
}
|
||||
|
||||
const [room] = await db
|
||||
.select({ matrixRoomId: chatRooms.matrixRoomId })
|
||||
.from(chatRooms)
|
||||
.where(and(eq(chatRooms.teamId, teamId), eq(chatRooms.scope, "team")))
|
||||
.limit(1);
|
||||
|
||||
if (!room) {
|
||||
// Room creation job has not run yet — UI shows "Raum wird eingerichtet".
|
||||
return reply.code(409).send({ error: "room_not_provisioned" });
|
||||
}
|
||||
|
||||
return reply.send({ matrixRoomId: room.matrixRoomId });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ async function provisionMatrixUser(
|
||||
memberId: string,
|
||||
): Promise<void> {
|
||||
const password = randomBytes(24).toString("hex");
|
||||
const displayName = `${firstName} ${lastName}`;
|
||||
const displayName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const localpart = toLocalpart();
|
||||
|
||||
@@ -56,14 +56,40 @@ export async function teamRoutes(app: FastifyInstance) {
|
||||
createdAt: teams.createdAt,
|
||||
// Exposed so the frontend can run one app-level Matrix client for
|
||||
// unread-badge tiles instead of minting a client per opened team (BUG-4).
|
||||
// BUG-14: filtered on scope='team' so a stray department-scope or
|
||||
// duplicate room for the same team can't produce a second joined row.
|
||||
matrixRoomId: chatRooms.matrixRoomId,
|
||||
})
|
||||
.from(teams)
|
||||
.leftJoin(chatRooms, eq(chatRooms.teamId, teams.id))
|
||||
.leftJoin(chatRooms, and(eq(chatRooms.teamId, teams.id), eq(chatRooms.scope, "team")))
|
||||
.where(eq(teams.organizationId, organizationId))
|
||||
.orderBy(teams.name);
|
||||
|
||||
return { teams: rows };
|
||||
// BUG-13: matrixRoomId used to go out for every team in the org, regardless
|
||||
// of whether the requester actually belongs to it. Null it out for teams
|
||||
// they're not an active member of.
|
||||
const [requesterMember] = await db
|
||||
.select({ id: members.id })
|
||||
.from(members)
|
||||
.where(and(eq(members.userId, request.user.sub), eq(members.status, "active")))
|
||||
.limit(1);
|
||||
const ownTeamIds = requesterMember
|
||||
? new Set(
|
||||
(
|
||||
await db
|
||||
.select({ teamId: teamMembers.teamId })
|
||||
.from(teamMembers)
|
||||
.where(eq(teamMembers.memberId, requesterMember.id))
|
||||
).map((r) => r.teamId),
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
return {
|
||||
teams: rows.map((row) => ({
|
||||
...row,
|
||||
matrixRoomId: ownTeamIds.has(row.id) ? row.matrixRoomId : null,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.post("/api/teams", { preHandler: app.authenticate }, async (request, reply) => {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import "dotenv/config";
|
||||
import { db } from "../db/index.js";
|
||||
import { chatRooms } from "../db/schema.js";
|
||||
import { setRoomHistoryVisibility } from "../services/synapse.js";
|
||||
|
||||
/**
|
||||
* BUG-6 one-off backfill: createRoom() now sets history_visibility=shared at
|
||||
* creation time, but rooms created before that change don't have it. Run once
|
||||
* per environment after deploying the fix:
|
||||
*
|
||||
* npx tsx src/scripts/backfill-history-visibility.ts
|
||||
*/
|
||||
async function main() {
|
||||
const rooms = await db.select({ matrixRoomId: chatRooms.matrixRoomId }).from(chatRooms);
|
||||
console.log(`Backfilling history_visibility on ${rooms.length} room(s)...`);
|
||||
|
||||
for (const room of rooms) {
|
||||
try {
|
||||
await setRoomHistoryVisibility(room.matrixRoomId);
|
||||
console.log(` ok: ${room.matrixRoomId}`);
|
||||
} catch (err) {
|
||||
console.error(` failed: ${room.matrixRoomId}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
+10
-1
@@ -1,6 +1,7 @@
|
||||
import "dotenv/config";
|
||||
import Fastify, { type FastifyRequest, type FastifyReply, type FastifyError } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import helmet from "@fastify/helmet";
|
||||
import jwt from "@fastify/jwt";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
@@ -20,7 +21,15 @@ import { startSyncWorker, startReconciliationWorker } from "./services/sync-work
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
// BUG-21: `origin: true` reflected every Origin header back — verified live to
|
||||
// accept e.g. https://evil.example.com, which is unnecessary exposure for a
|
||||
// route (POST /api/chat/token) that hands out a Matrix access token. Only
|
||||
// TalkTime's own frontend origins may call the API now.
|
||||
const allowedOrigins = [process.env.APP_BASE_URL, "http://localhost:5173"].filter(
|
||||
(origin): origin is string => Boolean(origin),
|
||||
);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: allowedOrigins });
|
||||
await app.register(rateLimit, { max: 100, timeWindow: "1 minute" });
|
||||
await app.register(jwt, { secret: process.env.JWT_SECRET! });
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@ export async function getMatrixAccessToken(matrixUserId: string): Promise<Cached
|
||||
pending = loginAsUser(matrixUserId, expiresAt)
|
||||
.then(({ accessToken }) => {
|
||||
const entry: CacheEntry = { accessToken, expiresAt };
|
||||
// BUG-9: Map.set() on an existing key keeps its original insertion
|
||||
// position, so prune()'s "oldest first" eviction would preferentially
|
||||
// drop the cache's most frequently active users. Delete first so a
|
||||
// refresh moves the entry to the end like a real LRU.
|
||||
cache.delete(matrixUserId);
|
||||
cache.set(matrixUserId, entry);
|
||||
return entry;
|
||||
})
|
||||
|
||||
@@ -99,6 +99,13 @@ export async function deactivateUser(userId: string, erase = false): Promise<voi
|
||||
});
|
||||
}
|
||||
|
||||
/** BUG-10: invalidates every access token Synapse has issued for this user, without deactivating the account. */
|
||||
export async function logoutAllSessions(userId: string): Promise<void> {
|
||||
await synapseFetch(`/_synapse/admin/v1/users/${encodeURIComponent(userId)}/logout_all`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export async function reactivateUser(userId: string, newPassword: string): Promise<void> {
|
||||
await synapseFetch(`/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, {
|
||||
method: "PUT",
|
||||
@@ -159,6 +166,17 @@ export async function createRoom(params: {
|
||||
return { roomId: result.room_id };
|
||||
}
|
||||
|
||||
/** BUG-6: used by the one-off backfill script for rooms created before createRoom() set this at creation time. */
|
||||
export async function setRoomHistoryVisibility(roomId: string): Promise<void> {
|
||||
await synapseFetch(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.history_visibility`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ history_visibility: "shared" }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function addUserToRoom(roomId: string, userId: string): Promise<void> {
|
||||
await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -42,7 +42,7 @@ const handlers: {
|
||||
}
|
||||
|
||||
const password = randomUUID().replace(/-/g, "");
|
||||
const displayName = `${firstName} ${lastName}`;
|
||||
const displayName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
const localpart = toLocalpart();
|
||||
|
||||
@@ -659,3 +659,38 @@ Live verifiziert: neuer Vereinsersteller bekommt `@u747fcd631af6db20:matrix.perl
|
||||
**Empfohlene Fix-Reihenfolge:** BUG-18 → BUG-19 → BUG-12 → BUG-11 → BUG-20 (Architekturentscheidung: Raum-Erstellung sperren und/oder ein Synapse pro Verein) → BUG-1 (committen) → BUG-13/14 → BUG-5/15 → BUG-6/21 → Rest.
|
||||
|
||||
**Nach den Fixes zwingend nachzuholen:** der bislang in allen drei Runden ausgefallene **Cross-Browser- und Responsive-Test (Chrome/Firefox/Safari; 375/768/1440 px)** inklusive XSS-Gegenprobe im echten Browser-Rendering.
|
||||
|
||||
## Vollständiger Bugfix-Durchlauf (2026-09-11) — restliche Bugs
|
||||
|
||||
Auf Nutzerwunsch alle verbliebenen offenen Bugs durchgegangen. Alle Fixes end-to-end auf `talkhub`/`matrix` verifiziert (neuer Verein → Abteilung/Saison/Team → Team-Mitgliedschaft → Chat-Token → Raum → Nachricht senden), Backend- und Frontend-Typecheck sowie Production-Build durchgehend grün, Testdaten aufgeräumt.
|
||||
|
||||
**BUG-21 (Medium) — behoben, live verifiziert.** `@fastify/helmet` registriert, CORS von `origin: true` auf eine Allowlist (`APP_BASE_URL` + `localhost:5173` für Dev) umgestellt. Live: eigene Origin bekommt `access-control-allow-origin` + alle Security-Header (HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`); eine fremde Origin bekommt keinen `access-control-allow-origin`-Header mehr zurück.
|
||||
|
||||
**BUG-13 (Medium) — behoben, live verifiziert.** `GET /api/teams` liefert `matrixRoomId` jetzt nur noch für Teams, in denen der Anfragende aktives Mitglied ist (sonst `null`) — vorher sah jedes Vereinsmitglied die Raum-IDs aller Teams. Live bestätigt: Org-Ersteller ohne Team-Mitgliedschaft sieht `matrixRoomId: null` und bekommt von `GET /api/chat/room` ein `404`; nach Hinzufügen als Team-Mitglied erscheint die korrekte Raum-ID.
|
||||
|
||||
**BUG-14 (Medium) — behoben.** `leftJoin` in `GET /api/teams` filtert jetzt zusätzlich auf `scope = 'team'`. Zusätzlich neuer partieller Unique-Index `chat_rooms_team_id_team_scope_unique` (`chat_rooms.team_id` wo `scope = 'team'`) in der Datenbank — verhindert das zugrunde liegende Problem (zwei Räume pro Team) strukturell, nicht nur kosmetisch in der Abfrage. Migration `0002_melted_imperial_guard.sql` erstellt und live angewendet.
|
||||
|
||||
**BUG-9 (Low, alle vier Teilpunkte) — behoben.**
|
||||
- Route-Rate-Limit für `GET /api/chat/room` ergänzt (30/min).
|
||||
- `GET /api/chat/room` liefert jetzt einheitlich `404` sowohl für nicht-existente Teams als auch für Teams, denen der Anfragende nicht angehört (vorher 404 vs. 403 — Existenz-Orakel).
|
||||
- `matrix-token-cache.ts`: `cache.delete()` vor `cache.set()` beim Refresh, damit die Map-Reihenfolge (für die Verdrängung) aktive Nutzer nicht mehr bevorzugt entfernt.
|
||||
- `frontend/eslint.config.js` neu angelegt (Flat Config, ESLint 9) — `npm run lint` lief zuvor gar nicht. Zusätzlich `MessageList.tsx`: Scroll-Position bleibt beim Nachladen älterer Nachrichten erhalten (vorher: Sprung an unvorhersehbare Stelle).
|
||||
|
||||
**BUG-6 (Medium) — behoben, live verifiziert.** Neues One-off-Script `backend/src/scripts/backfill-history-visibility.ts` (`npx tsx src/scripts/backfill-history-visibility.ts`) setzt `history_visibility: shared` auf alle bestehenden Räume nach. Auf `talkhub`/`matrix` ausgeführt — der eine bestehende Bestandsraum ist jetzt nachgezogen.
|
||||
|
||||
**BUG-5 (Medium) — behoben.** `use-team-chat.ts`: Setup-Poll bei `ChatSetupPendingError` nutzt jetzt exponentielles Backoff (5s → max. 60s) statt fest 15s. Bei `SyncState.Error` wird der Client jetzt gestoppt (`teardownClient()`) und mit Backoff neu verbunden, statt dass matrix-js-sdk im Hintergrund endlos gegen Synapse weiterretryt.
|
||||
|
||||
**BUG-15 (Medium) — gemildert.** `lib/matrix.ts`: kleiner modul-scope Credential-Cache — beide Hooks (`useTeamChat`, `useTeamUnreadCounts`) teilen sich jetzt einen gefetchten Token statt unabhängig `POST /api/chat/token` aufzurufen, solange er noch mindestens 60s gültig ist. Reduziert die Token-Endpunkt-Last spürbar, verhindert das 10/min-Limit aber nicht vollständig bei sehr schnellem Team-Wechsel (kein In-Flight-Dedup zwischen beiden Hooks) — daher „gemildert", nicht „behoben".
|
||||
|
||||
**BUG-16 (Low) — behoben.** `use-team-unread-counts.ts`: `refreshCounts()` prüft jetzt `cancelled`, bevor `setCounts` aufgerufen wird.
|
||||
|
||||
**BUG-17 (Low) — behoben in `use-team-chat.ts`, bewusst nicht in `use-team-unread-counts.ts`.** Ersteres nutzt jetzt `getRoomUnreadNotificationCount(NotificationCountType.Total)` statt der deprecated `getUnreadNotificationCount()`. Im Unread-Counts-Hook bewusst bei der alten API belassen — `NotificationCountType` ist ein Laufzeit-Enum, dessen Import den Bundle-Regression-Fix aus BUG-11 wieder aufheben würde. Mit Kommentar im Code begründet.
|
||||
|
||||
**BUG-10 (Low) — behoben.** Neuer Endpunkt `POST /api/chat/logout` invalidiert den Server-Cache-Eintrag **und** ruft Synapses `logout_all`-Admin-Endpunkt auf (widerruft alle Zugriffstokens des Nutzers, ohne den Account zu deaktivieren). `AuthProvider.logout()` im Frontend ruft das best-effort auf und leert zusätzlich den neuen Frontend-Credential-Cache (BUG-15).
|
||||
|
||||
**BUG-22 (Low) — teilweise behoben.** `.trim()` auf die `displayName`-Konstruktion in `invitations.ts` und `sync-worker.ts` ergänzt (kein Leerzeichen mehr am Ende bei leerem Nachnamen). Der ursprünglich gemeldete Localpart-Trailing-Punkt (`@qaa18686.`) ist durch den BUG-20-Fix ohnehin gegenstandslos geworden — Localparts sind jetzt zufällig, nicht mehr namensbasiert.
|
||||
|
||||
### Verbleibend offen
|
||||
Keine der ursprünglich gemeldeten Bugs (BUG-1 bis BUG-22) ist mehr unbearbeitet. Bewusst nur gemildert statt vollständig gelöst: **BUG-11** (SDK lädt weiterhin für jeden Besucher der Team-Liste mit mind. einem Team-Raum, nur zeitlich verschoben — echte Lösung bräuchte serverseitig aggregierte Zähler) und **BUG-15** (Token-Sharing reduziert, aber kein vollständiger Schutz vor Rate-Limit bei sehr schnellem Klicken). Beide sind als offene Architekturfragen dokumentiert. Nicht durchführbar in dieser Umgebung: Cross-Browser/Responsive-Test (kein Browser verfügbar) — bleibt für einen manuellen Testdurchlauf offen.
|
||||
|
||||
Empfehlung: `/qa` erneut laufen lassen für eine finale Bestätigungsrunde, idealerweise mit echtem Browser-Zugriff für den bislang ausstehenden Cross-Browser/Responsive-Test.
|
||||
@@ -0,0 +1,28 @@
|
||||
// BUG-9: eslint.config.js was missing entirely, so `npm run lint` failed
|
||||
// outright under ESLint 9 (flat config) and PROJ-11 shipped unlinted.
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+376
-3
@@ -44,15 +44,20 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^15.15.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
},
|
||||
@@ -923,6 +928,19 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
@@ -2887,6 +2905,301 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz",
|
||||
"integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.70.0",
|
||||
"@typescript-eslint/type-utils": "8.70.0",
|
||||
"@typescript-eslint/utils": "8.70.0",
|
||||
"@typescript-eslint/visitor-keys": "8.70.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.70.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz",
|
||||
"integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz",
|
||||
"integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.70.0",
|
||||
"@typescript-eslint/types": "8.70.0",
|
||||
"@typescript-eslint/typescript-estree": "8.70.0",
|
||||
"@typescript-eslint/visitor-keys": "8.70.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz",
|
||||
"integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.70.0",
|
||||
"@typescript-eslint/types": "^8.70.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz",
|
||||
"integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.70.0",
|
||||
"@typescript-eslint/visitor-keys": "8.70.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz",
|
||||
"integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz",
|
||||
"integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.70.0",
|
||||
"@typescript-eslint/typescript-estree": "8.70.0",
|
||||
"@typescript-eslint/utils": "8.70.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz",
|
||||
"integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz",
|
||||
"integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.70.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.70.0",
|
||||
"@typescript-eslint/types": "8.70.0",
|
||||
"@typescript-eslint/visitor-keys": "8.70.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz",
|
||||
"integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.70.0",
|
||||
"@typescript-eslint/types": "8.70.0",
|
||||
"@typescript-eslint/typescript-estree": "8.70.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz",
|
||||
"integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.70.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -3587,6 +3900,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
|
||||
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-refresh": {
|
||||
"version": "0.4.26",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
|
||||
"integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"eslint": ">=8.40"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "8.4.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
|
||||
@@ -3929,9 +4265,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
|
||||
"version": "15.15.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz",
|
||||
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5350,6 +5686,19 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-interface-checker": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
|
||||
@@ -5390,6 +5739,30 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.70.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz",
|
||||
"integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.70.0",
|
||||
"@typescript-eslint/parser": "8.70.0",
|
||||
"@typescript-eslint/typescript-estree": "8.70.0",
|
||||
"@typescript-eslint/utils": "8.70.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
||||
@@ -46,15 +46,20 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^9",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^15.15.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,40 @@ export function MessageList({
|
||||
onLoadMore,
|
||||
}: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
// BUG-9: "Ältere Nachrichten laden" used to jump the view to a random spot
|
||||
// because prepending messages shifts everything below down without any
|
||||
// compensating scroll adjustment. Capture the scroll anchor right before a
|
||||
// load-more, then restore the reader's position relative to it afterwards.
|
||||
const restoreScrollRef = useRef<{ scrollTop: number; scrollHeight: number } | null>(null);
|
||||
const groups = useMemo(() => groupByDay(messages), [messages]);
|
||||
const lastId = messages[messages.length - 1]?.id;
|
||||
|
||||
function getViewport(): HTMLDivElement | null {
|
||||
return rootRef.current?.querySelector<HTMLDivElement>("[data-radix-scroll-area-viewport]") ?? null;
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
const viewport = getViewport();
|
||||
if (viewport) {
|
||||
restoreScrollRef.current = { scrollTop: viewport.scrollTop, scrollHeight: viewport.scrollHeight };
|
||||
}
|
||||
onLoadMore();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const pending = restoreScrollRef.current;
|
||||
if (pending) {
|
||||
const viewport = getViewport();
|
||||
if (viewport) {
|
||||
const delta = viewport.scrollHeight - pending.scrollHeight;
|
||||
viewport.scrollTop = pending.scrollTop + delta;
|
||||
}
|
||||
restoreScrollRef.current = null;
|
||||
return;
|
||||
}
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [lastId]);
|
||||
}, [lastId, messages.length]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -73,11 +101,11 @@ export function MessageList({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<ScrollArea className="h-full" ref={rootRef}>
|
||||
<div className="space-y-4 p-4" role="log" aria-live="polite" aria-label="Chatverlauf">
|
||||
{canLoadMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" onClick={onLoadMore} disabled={loadingMore}>
|
||||
<Button variant="outline" size="sm" onClick={handleLoadMore} disabled={loadingMore}>
|
||||
{loadingMore ? "Lädt…" : "Ältere Nachrichten laden"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ClientEvent,
|
||||
Direction,
|
||||
EventType,
|
||||
NotificationCountType,
|
||||
RoomEvent,
|
||||
SyncState,
|
||||
type MatrixClient,
|
||||
@@ -45,8 +46,8 @@ interface UseTeamChatResult {
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
const PAGE_SIZE = 30;
|
||||
const MAX_POLL_DELAY_MS = 60_000;
|
||||
|
||||
function readReceiptsFor(room: Room, eventId: string, ownUserId: string): string[] {
|
||||
const event = room.findEventById(eventId);
|
||||
@@ -96,7 +97,8 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
|
||||
const userId = clientRef.current?.getUserId();
|
||||
if (!room || !userId) return;
|
||||
setMessages(toMessages(room, userId));
|
||||
setUnreadCount(room.getUnreadNotificationCount() ?? 0);
|
||||
// BUG-17: getUnreadNotificationCount() is deprecated in matrix-js-sdk.
|
||||
setUnreadCount(room.getRoomUnreadNotificationCount(NotificationCountType.Total) ?? 0);
|
||||
setCanLoadMore(Boolean(room.getLiveTimeline().getPaginationToken(Direction.Backward)));
|
||||
}, []);
|
||||
|
||||
@@ -109,9 +111,20 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
|
||||
let cancelled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let client: MatrixClient | null = null;
|
||||
let pollAttempt = 0;
|
||||
|
||||
const onTimeline = () => !cancelled && refresh();
|
||||
|
||||
function teardownClient() {
|
||||
if (client) {
|
||||
client.removeAllListeners();
|
||||
client.stopClient();
|
||||
client = null;
|
||||
}
|
||||
clientRef.current = null;
|
||||
roomRef.current = null;
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
@@ -153,10 +166,19 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
|
||||
}
|
||||
roomRef.current = room;
|
||||
setStatus("ready");
|
||||
pollAttempt = 0;
|
||||
refresh();
|
||||
} else if (state === SyncState.Error) {
|
||||
// BUG-5: this used to just set status="error" while matrix-js-sdk kept
|
||||
// retrying /sync against Synapse in the background forever — exactly
|
||||
// the "Retry-Loop, der Synapse flutet" the edge case rules out. Stop
|
||||
// the client and reconnect ourselves with backoff instead.
|
||||
setStatus("error");
|
||||
setError("Verbindung zum Chat-Server fehlgeschlagen.");
|
||||
teardownClient();
|
||||
const delay = Math.min(1000 * 2 ** pollAttempt, MAX_POLL_DELAY_MS);
|
||||
pollAttempt += 1;
|
||||
pollTimer = setTimeout(() => void connect(), delay);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -166,8 +188,12 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
|
||||
if (err instanceof ChatSetupPendingError) {
|
||||
setStatus("pending");
|
||||
setPendingReason(err.reason);
|
||||
// Poll instead of erroring until provisioning caught up.
|
||||
pollTimer = setTimeout(() => void connect(), POLL_INTERVAL_MS);
|
||||
// BUG-5: poll instead of erroring until provisioning catches up, but
|
||||
// with backoff — this used to fire a fixed 15s regardless of how long
|
||||
// it had already been pending, adding unnecessary token-endpoint load.
|
||||
const delay = Math.min(5000 * 2 ** pollAttempt, MAX_POLL_DELAY_MS);
|
||||
pollAttempt += 1;
|
||||
pollTimer = setTimeout(() => void connect(), delay);
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
@@ -180,12 +206,7 @@ export function useTeamChat(authToken: string | null, teamId: string | null): Us
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
if (client) {
|
||||
client.removeAllListeners();
|
||||
client.stopClient();
|
||||
}
|
||||
clientRef.current = null;
|
||||
roomRef.current = null;
|
||||
teardownClient();
|
||||
};
|
||||
}, [authToken, teamId, attempt, refresh]);
|
||||
|
||||
|
||||
@@ -62,10 +62,19 @@ export function useTeamUnreadCounts(
|
||||
let reconnectAttempt = 0;
|
||||
|
||||
function refreshCounts() {
|
||||
if (!client) return;
|
||||
// BUG-16: without this guard, a Room.timeline/receipt event already in
|
||||
// flight when the effect tears down (unmount, or roomIds/authToken
|
||||
// change) could still call setCounts after cleanup.
|
||||
if (cancelled || !client) return;
|
||||
const next: Record<string, number> = {};
|
||||
for (const [roomId, teamId] of roomIdToTeamId.current) {
|
||||
const room = client.getRoom(roomId);
|
||||
// BUG-17: getUnreadNotificationCount() is deprecated in favor of
|
||||
// getRoomUnreadNotificationCount(NotificationCountType.Total) — kept
|
||||
// here deliberately, since importing NotificationCountType (a runtime
|
||||
// enum, not a type) would reintroduce the BUG-11 bundle regression this
|
||||
// file otherwise avoids. use-team-chat.ts uses the new API since it
|
||||
// already imports matrix-js-sdk enums statically either way.
|
||||
if (room) next[teamId] = room.getUnreadNotificationCount() ?? 0;
|
||||
}
|
||||
setCounts(next);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { clearCachedChatCredentials } from "@/lib/matrix";
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
@@ -58,9 +59,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
// BUG-10: revoke the Matrix session on logout instead of leaving the cached
|
||||
// token valid for up to its full 1h TTL. Best-effort — logout must not get
|
||||
// stuck waiting on this, and the token still expires on its own either way.
|
||||
if (token) {
|
||||
void fetch("/api/chat/logout", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
clearCachedChatCredentials();
|
||||
setTokenState(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
}, [token]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ token, user, login, setToken: applyToken, logout }),
|
||||
|
||||
@@ -25,11 +25,23 @@ async function readError(res: Response): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
// BUG-15: useTeamChat and useTeamUnreadCounts each independently minted their
|
||||
// own token (plus the 15s setup poll from BUG-5), which could burn through
|
||||
// POST /api/chat/token's 10/min limit just from a user clicking through a few
|
||||
// teams. A tiny module-level cache lets every caller in this tab share one
|
||||
// fetch instead of each hitting the backend on its own.
|
||||
let cachedCredentials: { creds: ChatCredentials; expiresAt: number } | null = null;
|
||||
const CREDENTIAL_REFRESH_MARGIN_MS = 60_000;
|
||||
|
||||
/** Asks the TalkTime backend for a short-lived Matrix access token for the logged-in user. */
|
||||
export async function fetchChatCredentials(
|
||||
token: string,
|
||||
options: { refresh?: boolean } = {},
|
||||
): Promise<ChatCredentials> {
|
||||
if (!options.refresh && cachedCredentials && cachedCredentials.expiresAt - Date.now() > CREDENTIAL_REFRESH_MARGIN_MS) {
|
||||
return cachedCredentials.creds;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/chat/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -46,7 +58,14 @@ export async function fetchChatCredentials(
|
||||
if (!res.ok) {
|
||||
throw new Error(await readError(res));
|
||||
}
|
||||
return (await res.json()) as ChatCredentials;
|
||||
const creds = (await res.json()) as ChatCredentials;
|
||||
cachedCredentials = { creds, expiresAt: Date.now() + creds.expiresInMs };
|
||||
return creds;
|
||||
}
|
||||
|
||||
/** BUG-10: drop the shared credential cache on logout so a stale token isn't reused. */
|
||||
export function clearCachedChatCredentials(): void {
|
||||
cachedCredentials = null;
|
||||
}
|
||||
|
||||
/** Resolves the Matrix room id of a team. */
|
||||
|
||||
Reference in new issue
Block a user