From edc05d4d1e0562e11b22632ff7b725a74fd2807c Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 11 Sep 2026 23:10:37 +0200 Subject: [PATCH] fix(PROJ-11): clear the remaining QA backlog (BUG-5/6/9/10/13/14/15/16/17/21/22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67 --- .../drizzle/0002_melted_imperial_guard.sql | 1 + backend/drizzle/meta/0002_snapshot.json | 1494 +++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/package-lock.json | 53 +- backend/package.json | 1 + backend/src/db/schema.ts | 13 +- backend/src/routes/chat.ts | 122 +- backend/src/routes/invitations.ts | 2 +- backend/src/routes/teams.ts | 30 +- .../scripts/backfill-history-visibility.ts | 32 + backend/src/server.ts | 11 +- backend/src/services/matrix-token-cache.ts | 5 + backend/src/services/synapse.ts | 18 + backend/src/services/sync-worker.ts | 2 +- features/PROJ-11-chat.md | 35 + frontend/eslint.config.js | 28 + frontend/package-lock.json | 379 ++++- frontend/package.json | 5 + frontend/src/components/chat/MessageList.tsx | 34 +- frontend/src/hooks/use-team-chat.ts | 41 +- frontend/src/hooks/use-team-unread-counts.ts | 11 +- frontend/src/lib/auth-context.tsx | 13 +- frontend/src/lib/matrix.ts | 21 +- 23 files changed, 2288 insertions(+), 70 deletions(-) create mode 100644 backend/drizzle/0002_melted_imperial_guard.sql create mode 100644 backend/drizzle/meta/0002_snapshot.json create mode 100644 backend/src/scripts/backfill-history-visibility.ts create mode 100644 frontend/eslint.config.js diff --git a/backend/drizzle/0002_melted_imperial_guard.sql b/backend/drizzle/0002_melted_imperial_guard.sql new file mode 100644 index 0000000..68ecae1 --- /dev/null +++ b/backend/drizzle/0002_melted_imperial_guard.sql @@ -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'; \ No newline at end of file diff --git a/backend/drizzle/meta/0002_snapshot.json b/backend/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..ab1fca5 --- /dev/null +++ b/backend/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1494 @@ +{ + "id": "735c3afb-31cc-42ea-ab27-8a26f33cc915", + "prevId": "7623ad60-f099-46c4-8027-42012f2d062f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_organization_id_idx": { + "name": "audit_logs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_organization_id_organizations_id_fk": { + "name": "audit_logs_organization_id_organizations_id_fk", + "tableFrom": "audit_logs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_user_id_users_id_fk": { + "name": "audit_logs_actor_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_rooms": { + "name": "chat_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "matrix_room_id": { + "name": "matrix_room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "chat_room_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_rooms_organization_id_idx": { + "name": "chat_rooms_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_rooms_team_id_team_scope_unique": { + "name": "chat_rooms_team_id_team_scope_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_rooms\".\"scope\" = 'team'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_rooms_organization_id_organizations_id_fk": { + "name": "chat_rooms_organization_id_organizations_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_rooms_department_id_departments_id_fk": { + "name": "chat_rooms_department_id_departments_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_rooms_team_id_teams_id_fk": { + "name": "chat_rooms_team_id_teams_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_rooms_matrix_room_id_unique": { + "name": "chat_rooms_matrix_room_id_unique", + "nullsNotDistinct": false, + "columns": [ + "matrix_room_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.departments": { + "name": "departments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "departments_organization_id_idx": { + "name": "departments_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "departments_organization_id_organizations_id_fk": { + "name": "departments_organization_id_organizations_id_fk", + "tableFrom": "departments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_token_unique": { + "name": "invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "matrix_user_id": { + "name": "matrix_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_organization_id_email_unique": { + "name": "members_organization_id_email_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_roles": { + "name": "org_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_roles_organization_id_idx": { + "name": "org_roles_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_roles_member_id_idx": { + "name": "org_roles_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_roles_organization_id_organizations_id_fk": { + "name": "org_roles_organization_id_organizations_id_fk", + "tableFrom": "org_roles", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_roles_member_id_members_id_fk": { + "name": "org_roles_member_id_members_id_fk", + "tableFrom": "org_roles", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_roles_department_id_departments_id_fk": { + "name": "org_roles_department_id_departments_id_fk", + "tableFrom": "org_roles", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_roles_scope_unique": { + "name": "org_roles_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "member_id", + "department_id", + "role" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parent_links": { + "name": "parent_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_member_id": { + "name": "child_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_member_id": { + "name": "parent_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "parent_links_child_member_id_members_id_fk": { + "name": "parent_links_child_member_id_members_id_fk", + "tableFrom": "parent_links", + "tableTo": "members", + "columnsFrom": [ + "child_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "parent_links_parent_member_id_members_id_fk": { + "name": "parent_links_parent_member_id_members_id_fk", + "tableFrom": "parent_links", + "tableTo": "members", + "columnsFrom": [ + "parent_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "parent_links_child_parent_unique": { + "name": "parent_links_child_parent_unique", + "nullsNotDistinct": false, + "columns": [ + "child_member_id", + "parent_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_tokens_token_unique": { + "name": "password_reset_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "seasons_organization_id_idx": { + "name": "seasons_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_organization_id_organizations_id_fk": { + "name": "seasons_organization_id_organizations_id_fk", + "tableFrom": "seasons", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_jobs": { + "name": "sync_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_jobs_organization_id_idx": { + "name": "sync_jobs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_jobs_status_idx": { + "name": "sync_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_jobs_organization_id_organizations_id_fk": { + "name": "sync_jobs_organization_id_organizations_id_fk", + "tableFrom": "sync_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_member_id_idx": { + "name": "team_members_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_member_id_members_id_fk": { + "name": "team_members_member_id_members_id_fk", + "tableFrom": "team_members", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_members_team_id_member_id_role_unique": { + "name": "team_members_team_id_member_id_role_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id", + "member_id", + "role" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_department_id_idx": { + "name": "teams_department_id_idx", + "columns": [ + { + "expression": "department_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_department_id_departments_id_fk": { + "name": "teams_department_id_departments_id_fk", + "tableFrom": "teams", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_season_id_seasons_id_fk": { + "name": "teams_season_id_seasons_id_fk", + "tableFrom": "teams", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.chat_room_scope": { + "name": "chat_room_scope", + "schema": "public", + "values": [ + "organization", + "department", + "team" + ] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "revoked", + "expired" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "suspended", + "left" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "ORG_ADMIN", + "ORG_MANAGER", + "DEPARTMENT_ADMIN", + "TEAM_MANAGER", + "TEAM_LEAD", + "ASSISTANT_LEAD", + "SUPERVISOR", + "MEMBER", + "GUARDIAN" + ] + }, + "public.sync_job_status": { + "name": "sync_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "done", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 16df185..5c57533 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 8e2631e..cd15aed 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index f770446..99a3133 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 797607e..b06d5ca 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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", [ diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index e4f6009..c32b673 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -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 }); + }, + ); } diff --git a/backend/src/routes/invitations.ts b/backend/src/routes/invitations.ts index df82104..50bd6a5 100644 --- a/backend/src/routes/invitations.ts +++ b/backend/src/routes/invitations.ts @@ -44,7 +44,7 @@ async function provisionMatrixUser( memberId: string, ): Promise { 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(); diff --git a/backend/src/routes/teams.ts b/backend/src/routes/teams.ts index 808a28e..f681776 100644 --- a/backend/src/routes/teams.ts +++ b/backend/src/routes/teams.ts @@ -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(); + + return { + teams: rows.map((row) => ({ + ...row, + matrixRoomId: ownTeamIds.has(row.id) ? row.matrixRoomId : null, + })), + }; }); app.post("/api/teams", { preHandler: app.authenticate }, async (request, reply) => { diff --git a/backend/src/scripts/backfill-history-visibility.ts b/backend/src/scripts/backfill-history-visibility.ts new file mode 100644 index 0000000..e08d203 --- /dev/null +++ b/backend/src/scripts/backfill-history-visibility.ts @@ -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); + }); diff --git a/backend/src/server.ts b/backend/src/server.ts index fad8f25..689b759 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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! }); diff --git a/backend/src/services/matrix-token-cache.ts b/backend/src/services/matrix-token-cache.ts index a46e3b8..4acb594 100644 --- a/backend/src/services/matrix-token-cache.ts +++ b/backend/src/services/matrix-token-cache.ts @@ -64,6 +64,11 @@ export async function getMatrixAccessToken(matrixUserId: string): Promise { 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; }) diff --git a/backend/src/services/synapse.ts b/backend/src/services/synapse.ts index a382119..2bcd051 100644 --- a/backend/src/services/synapse.ts +++ b/backend/src/services/synapse.ts @@ -99,6 +99,13 @@ export async function deactivateUser(userId: string, erase = false): Promise { + await synapseFetch(`/_synapse/admin/v1/users/${encodeURIComponent(userId)}/logout_all`, { + method: "POST", + }); +} + export async function reactivateUser(userId: string, newPassword: string): Promise { 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 { + 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 { await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, { method: "POST", diff --git a/backend/src/services/sync-worker.ts b/backend/src/services/sync-worker.ts index a3dbd6d..04a2200 100644 --- a/backend/src/services/sync-worker.ts +++ b/backend/src/services/sync-worker.ts @@ -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(); diff --git a/features/PROJ-11-chat.md b/features/PROJ-11-chat.md index 7a14735..cfb34d0 100644 --- a/features/PROJ-11-chat.md +++ b/features/PROJ-11-chat.md @@ -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. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..746ed6f --- /dev/null +++ b/frontend/eslint.config.js @@ -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: "^_" }], + }, + }, +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ec8b995..5b20250 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 67b8567..71a94f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/components/chat/MessageList.tsx b/frontend/src/components/chat/MessageList.tsx index fb501ed..b2df35e 100644 --- a/frontend/src/components/chat/MessageList.tsx +++ b/frontend/src/components/chat/MessageList.tsx @@ -45,12 +45,40 @@ export function MessageList({ onLoadMore, }: MessageListProps) { const bottomRef = useRef(null); + const rootRef = useRef(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("[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 ( - +
{canLoadMore && (
-
diff --git a/frontend/src/hooks/use-team-chat.ts b/frontend/src/hooks/use-team-chat.ts index 34e652e..fb1f8f2 100644 --- a/frontend/src/hooks/use-team-chat.ts +++ b/frontend/src/hooks/use-team-chat.ts @@ -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 | 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]); diff --git a/frontend/src/hooks/use-team-unread-counts.ts b/frontend/src/hooks/use-team-unread-counts.ts index 2fad92e..ff6639f 100644 --- a/frontend/src/hooks/use-team-unread-counts.ts +++ b/frontend/src/hooks/use-team-unread-counts.ts @@ -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 = {}; 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); diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx index 762baed..e3fb914 100644 --- a/frontend/src/lib/auth-context.tsx +++ b/frontend/src/lib/auth-context.tsx @@ -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 }), diff --git a/frontend/src/lib/matrix.ts b/frontend/src/lib/matrix.ts index e79d5b2..ccb6fff 100644 --- a/frontend/src/lib/matrix.ts +++ b/frontend/src/lib/matrix.ts @@ -25,11 +25,23 @@ async function readError(res: Response): Promise { } } +// 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 { + 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. */