From 85e1975fa58c54ce676f80bac9e6b423f27e00c7 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:44:16 +0200 Subject: [PATCH] fix(permissions): deny space permissions to non-members (invite-bypass) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computePermissions() returned the space @everyone role's permissions without verifying the caller had joined the space. Because CREATE_INVITE is in DEFAULT_EVERYONE_PERMISSIONS, any authenticated user could mint an invite code for a request-only space — whose id is listed by /api/spaces/explore — and then self-join via /api/spaces/:id/join, bypassing the join-request approval flow. The same gap let non-members read message history and search default channels. Root cause: - computePermissions now returns 0n for non-members (space owner and instance admin still short-circuit first, so they are unaffected). Defense in depth (request-only spaces are approval-gated, never invite-joinable): - both invite-code join endpoints reject visibility='request' (private stays invite-joinable — its only entry path; public too). - POST /api/spaces/:id/invite refuses to hand out a code for request spaces. - POST /api/dm/space-invite refuses to card a local request space, checked by space id against the local table so a spoofed spaceInstanceOrigin can't slip past it. - InviteModal hides the invite affordances for request spaces. Also removes the unused computeCategoryPermissions(), which duplicated the resolution algorithm without the membership gate. Adds unit + route + component tests covering non-member/member/owner/admin resolution and the request/private/public visibility matrix. Reported-by: BadAtCaptchas (#2) --- docs/systems/permissions.md | 10 +- docs/systems/spaces.md | 6 +- .../server/src/routes/dm.spaceInvite.test.ts | 51 +++++ packages/server/src/routes/dm.ts | 13 ++ .../src/routes/spaces.joinVisibility.test.ts | 178 ++++++++++++++++++ packages/server/src/routes/spaces.ts | 19 ++ packages/server/src/utils/permissions.test.ts | 127 +++++++++++++ packages/server/src/utils/permissions.ts | 68 +------ .../server/src/ws/spaceVoiceState.test.ts | 14 ++ .../components/modals/InviteModal.test.tsx | 26 +++ .../web/src/components/modals/InviteModal.tsx | 23 ++- 11 files changed, 470 insertions(+), 65 deletions(-) create mode 100644 packages/server/src/routes/spaces.joinVisibility.test.ts create mode 100644 packages/server/src/utils/permissions.test.ts diff --git a/docs/systems/permissions.md b/docs/systems/permissions.md index 460c01d2..a8c8a9aa 100644 --- a/docs/systems/permissions.md +++ b/docs/systems/permissions.md @@ -45,6 +45,15 @@ Storage: Bigint decimal strings in SQLite TEXT columns (bigint not JSON-safe). ### Step 1: Owner/Admin Check - Space owner OR instance admin (`isAdmin === 1`) → return ALL_PERMISSIONS +### Step 1b: Membership Gate +- If the user is **not** a member of the space (`getMember` returns nothing) → return `0n` +- A non-member has no permissions in a space they have not joined. Without this, + the @everyone role in Step 2 would leak default member rights (VIEW_CHANNEL, + READ_MESSAGE_HISTORY, CREATE_INVITE, …) to any authenticated non-member — + allowing them to read channels and mint invite codes for spaces they never + joined. Owner and instance admin are already resolved in Step 1, so they are + unaffected. + ### Step 2: Compute Base (space-level) - Start with @everyone role permissions (role where `id === spaceId`) - OR together all permissions from user's assigned roles @@ -89,7 +98,6 @@ if channelOverride: base = (base & ~deny) | allow | `permissionsToString(perms)` | Bigint → decimal string for JSON | | `stringToPermissions(str)` | Decimal string → bigint (supports legacy JSON array format) | | `computePermissions(userId, spaceId, channelId?)` | Full resolution algorithm | -| `computeCategoryPermissions(userId, spaceId, categoryId)` | Stops at category level (no channel overrides) | | `hasPermission(userId, spaceId, permission, channelId?)` | Boolean wrapper | | `getMember/isMember/isSpaceOwner` | Membership checks | | `isDmMember/isBanned` | DM/ban checks | diff --git a/docs/systems/spaces.md b/docs/systems/spaces.md index 17515d23..2edb0da2 100644 --- a/docs/systems/spaces.md +++ b/docs/systems/spaces.md @@ -113,6 +113,8 @@ Cross-references: [database.md](database.md) (table schemas), [permissions.md](p **Behavior:** Returns existing `inviteCode` if one exists. Only generates a new one (`crypto.randomBytes(4).toString('hex')`) if the space has no invite code. Invite codes are permanent (no expiration). +**Visibility gate:** returns `403` for `request`-visibility spaces — they are approval-gated and have no usable invite link (the join endpoints reject invite-code joins for them), so the endpoint refuses to hand one out. The client (`InviteModal`) shows an "invite by join request" notice instead of the invite UI for such spaces, and `POST /api/dm/space-invite` likewise rejects a `request`-visibility **local** space with `403 space_requires_approval` (remote request spaces are enforced by their home instance at join time). + **Response:** `{ inviteCode: string }` ### Invite URL Format @@ -168,7 +170,9 @@ Two endpoints serve the same purpose: | `POST /api/spaces/:id/join` | Join when spaceId is known (body: `{ inviteCode }`) | | `POST /api/spaces/join` | Join by code only, spaceId looked up from `inviteCode` | -**Validations:** invite code match, not banned, not already a member. +**Validations:** invite code match, not banned, not already a member, and **space visibility is not `request`**. + +**Visibility gate:** invite-code joins are rejected (`403`) for `request`-visibility spaces — entry to a request-only space must go through `POST /api/spaces/:id/request-join` + manager approval, never a bearer invite code. `private` spaces remain invite-joinable (an invite is their only entry path); `public` spaces are joinable by code or via `POST /api/spaces/:id/public-join`. Combined with the permission membership gate (a non-member cannot obtain `CREATE_INVITE`, see [permissions.md](permissions.md)), this closes the invite-bypass path where a non-member could mint a code for a request-only space and self-join without approval. **Side effects:** 1. Insert `space_members` row diff --git a/packages/server/src/routes/dm.spaceInvite.test.ts b/packages/server/src/routes/dm.spaceInvite.test.ts index 8bf4de3f..b5ad17a8 100644 --- a/packages/server/src/routes/dm.spaceInvite.test.ts +++ b/packages/server/src/routes/dm.spaceInvite.test.ts @@ -200,6 +200,57 @@ describe('POST /api/dm/space-invite', () => { expect(messageCount).toBe(0); }); + it('rejects a space invite for a local request-only space (approval required)', async () => { + // Real local space with request visibility; the snapshot is mocked to match. + testDb.insert(schema.spaces).values({ + id: 'S-REQ', name: 'Req', ownerId: 'alice', inviteCode: 'reqcode', + visibility: 'request', createdAt: 1, + }).run(); + (getLocalInviteSnapshot as unknown as ReturnType).mockReturnValueOnce({ + spaceId: 'S-REQ', spaceName: 'Req', description: null, icon: null, + avatarColor: null, memberCount: 1, instanceName: 'Backspace', + }); + const res = await app.inject({ + method: 'POST', + url: '/api/dm/space-invite', + payload: { + target: { userId: 'bob' }, + spaceId: 'S-REQ', + spaceInstanceOrigin: '', + inviteCode: 'reqcode', + }, + }); + expect(res.statusCode).toBe(403); + // No DM card should be inserted for a request-only space. + expect(testDb.select().from(schema.dmMessages).all().length).toBe(0); + }); + + it('rejects a local request-only space even when spaceInstanceOrigin is spoofed to look remote', async () => { + // A caller can send an origin variant (trailing slash / different case) so + // `isLocal` is false and the fetch path is taken, but the space is genuinely + // local + request. The guard must not depend on the claimed origin. + testDb.insert(schema.spaces).values({ + id: 'S-REQ2', name: 'Req2', ownerId: 'alice', inviteCode: 'reqcode2', + visibility: 'request', createdAt: 1, + }).run(); + (fetchSpaceInviteSnapshot as unknown as ReturnType).mockResolvedValueOnce({ + spaceId: 'S-REQ2', spaceName: 'Req2', description: null, icon: null, + avatarColor: null, memberCount: 1, instanceName: 'Backspace', + }); + const res = await app.inject({ + method: 'POST', + url: '/api/dm/space-invite', + payload: { + target: { userId: 'bob' }, + spaceId: 'S-REQ2', + spaceInstanceOrigin: 'https://local.test/', // trailing slash defeats strict isLocal compare + inviteCode: 'reqcode2', + }, + }); + expect(res.statusCode).toBe(403); + expect(testDb.select().from(schema.dmMessages).all().length).toBe(0); + }); + it('inserts a type=system message with parseable space_invite content on success', async () => { (getLocalInviteSnapshot as unknown as ReturnType).mockReturnValueOnce({ spaceId: 'S1', diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 9f8acb0e..ff3df818 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -2393,6 +2393,19 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'invite_invalid', statusCode: 400 }); } + // Request-only spaces are approval-gated and have no usable invite links, so + // refuse to send an invite card that would dead-end at the recipient's join + // guard. This is checked against our LOCAL spaces table by id, independent of + // the caller-supplied spaceInstanceOrigin: if the space is genuinely local and + // request-only we reject even when the origin is spoofed to look remote. A + // truly remote space is absent from this table (undefined → allowed); its own + // home instance enforces the same rule when the recipient tries to join. + const localSpace = db.select({ visibility: schema.spaces.visibility }) + .from(schema.spaces).where(eq(schema.spaces.id, body.spaceId)).get(); + if (localSpace?.visibility === 'request') { + return reply.code(403).send({ error: 'space_requires_approval', statusCode: 403 }); + } + // 4. Resolve / create the 1-on-1 DM (delegate to dedup helper). const dmChannelId = ensureOneOnOneDmChannel(callerId, targetUser, db); diff --git a/packages/server/src/routes/spaces.joinVisibility.test.ts b/packages/server/src/routes/spaces.joinVisibility.test.ts new file mode 100644 index 00000000..2ac4efbe --- /dev/null +++ b/packages/server/src/routes/spaces.joinVisibility.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let currentUserId = 'joiner'; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../utils/auth.js', () => ({ + authenticate: async (req: { userId?: string }) => { + req.userId = currentUserId; + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + addUserSpace: vi.fn(), + sendToSpace: vi.fn(), + }, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +const OWNER_ID = 'owner'; +const now = 1_700_000_000_000; + +async function buildApp(): Promise { + const { spaceRoutes } = await import('./spaces.js'); + const f = Fastify(); + await f.register(spaceRoutes); + return f; +} + +let app: FastifyInstance; + +function makeSpace(id: string, visibility: 'public' | 'request' | 'private', inviteCode: string): void { + testDb.insert(schema.spaces).values({ + id, + name: `space-${visibility}`, + ownerId: OWNER_ID, + inviteCode, + visibility, + createdAt: now, + }).run(); +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + currentUserId = 'joiner'; + + for (const id of [OWNER_ID, 'joiner']) { + testDb.insert(schema.users).values({ + id, username: id, passwordHash: 'x', createdAt: now, + }).run(); + } + + app = await buildApp(); +}); + +function isMember(spaceId: string, userId: string): boolean { + return testDb.select().from(schema.spaceMembers).all() + .some(m => m.spaceId === spaceId && m.userId === userId); +} + +describe('POST /api/spaces/:id/join — visibility guard', () => { + it('rejects an invite-code join for a request-only space (approval required)', async () => { + makeSpace('s-req', 'request', 'code-req'); + const res = await app.inject({ + method: 'POST', + url: '/api/spaces/s-req/join', + payload: { inviteCode: 'code-req' }, + }); + expect(res.statusCode).toBe(403); + expect(isMember('s-req', 'joiner')).toBe(false); + }); + + it('allows an invite-code join for a private space (invite is the only entry path)', async () => { + makeSpace('s-priv', 'private', 'code-priv'); + const res = await app.inject({ + method: 'POST', + url: '/api/spaces/s-priv/join', + payload: { inviteCode: 'code-priv' }, + }); + expect(res.statusCode).toBe(200); + expect(isMember('s-priv', 'joiner')).toBe(true); + }); + + it('allows an invite-code join for a public space', async () => { + makeSpace('s-pub', 'public', 'code-pub'); + const res = await app.inject({ + method: 'POST', + url: '/api/spaces/s-pub/join', + payload: { inviteCode: 'code-pub' }, + }); + expect(res.statusCode).toBe(200); + expect(isMember('s-pub', 'joiner')).toBe(true); + }); +}); + +describe('POST /api/spaces/join (codeless) — visibility guard', () => { + it('rejects an invite-code join for a request-only space', async () => { + makeSpace('s-req2', 'request', 'code-req2'); + const res = await app.inject({ + method: 'POST', + url: '/api/spaces/join', + payload: { inviteCode: 'code-req2' }, + }); + expect(res.statusCode).toBe(403); + expect(isMember('s-req2', 'joiner')).toBe(false); + }); + + it('allows an invite-code join for a private space', async () => { + makeSpace('s-priv2', 'private', 'code-priv2'); + const res = await app.inject({ + method: 'POST', + url: '/api/spaces/join', + payload: { inviteCode: 'code-priv2' }, + }); + expect(res.statusCode).toBe(200); + expect(isMember('s-priv2', 'joiner')).toBe(true); + }); +}); + +describe('POST /api/spaces/:id/invite — visibility guard', () => { + // Caller is the owner (a member with CREATE_INVITE) so we exercise the + // visibility guard, not the permission/membership gate. + it('refuses to mint/return an invite code for a request-only space', async () => { + currentUserId = OWNER_ID; + makeSpace('s-req-inv', 'request', 'code-req-inv'); + const res = await app.inject({ method: 'POST', url: '/api/spaces/s-req-inv/invite' }); + expect(res.statusCode).toBe(403); + }); + + it('returns an invite code for a private space', async () => { + currentUserId = OWNER_ID; + makeSpace('s-priv-inv', 'private', 'code-priv-inv'); + const res = await app.inject({ method: 'POST', url: '/api/spaces/s-priv-inv/invite' }); + expect(res.statusCode).toBe(200); + expect(res.json().inviteCode).toBe('code-priv-inv'); + }); + + it('returns an invite code for a public space', async () => { + currentUserId = OWNER_ID; + makeSpace('s-pub-inv', 'public', 'code-pub-inv'); + const res = await app.inject({ method: 'POST', url: '/api/spaces/s-pub-inv/invite' }); + expect(res.statusCode).toBe(200); + expect(res.json().inviteCode).toBe('code-pub-inv'); + }); +}); diff --git a/packages/server/src/routes/spaces.ts b/packages/server/src/routes/spaces.ts index 688f7bf0..f2e51687 100644 --- a/packages/server/src/routes/spaces.ts +++ b/packages/server/src/routes/spaces.ts @@ -564,6 +564,13 @@ export async function spaceRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Missing CREATE_INVITE permission', statusCode: 403 }); } + // Request-only spaces are approval-gated and never joinable by invite code + // (see the join endpoints), so they have no usable invite links. Refuse to + // hand one out rather than mint a code that would dead-end at the join guard. + if (server.visibility === 'request') { + return reply.code(403).send({ error: 'Request-only spaces do not use invite links; entry is by join request', statusCode: 403 }); + } + // Return existing invite code if one exists, otherwise generate a new one if (server.inviteCode) { return reply.code(200).send({ inviteCode: server.inviteCode }); @@ -605,6 +612,13 @@ export async function spaceRoutes(app: FastifyInstance): Promise { return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 }); } + // Request-only spaces are gated by manager approval: entry must go through + // POST /request-join + approval, never a bearer invite code. (Private spaces + // remain invite-joinable — that is their only entry path; public too.) + if (server.visibility === 'request') { + return reply.code(403).send({ error: 'This space requires an approved join request', statusCode: 403 }); + } + const now = Date.now(); db.insert(schema.spaceMembers).values({ spaceId: id, @@ -661,6 +675,11 @@ export async function spaceRoutes(app: FastifyInstance): Promise { return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 }); } + // Request-only spaces are gated by manager approval (see POST /:id/join). + if (server.visibility === 'request') { + return reply.code(403).send({ error: 'This space requires an approved join request', statusCode: 403 }); + } + const now = Date.now(); db.insert(schema.spaceMembers).values({ spaceId: server.id, diff --git a/packages/server/src/utils/permissions.test.ts b/packages/server/src/utils/permissions.test.ts new file mode 100644 index 00000000..a463053a --- /dev/null +++ b/packages/server/src/utils/permissions.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { + PermissionBits, + ALL_PERMISSIONS, + DEFAULT_EVERYONE_PERMISSIONS, + permissionsToString, +} from '@backspace/shared/src/permissions.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +const OWNER_ID = 'owner-1'; +const MEMBER_ID = 'member-1'; +const OUTSIDER_ID = 'outsider-1'; +const ADMIN_ID = 'admin-1'; +const SPACE_ID = 'space-1'; +const now = 1_700_000_000_000; + +function seed(): void { + for (const [id, isAdmin] of [ + [OWNER_ID, 0], + [MEMBER_ID, 0], + [OUTSIDER_ID, 0], + [ADMIN_ID, 1], + ] as const) { + testDb.insert(schema.users).values({ + id, + username: id, + passwordHash: 'x', + isAdmin, + createdAt: now, + }).run(); + } + + testDb.insert(schema.spaces).values({ + id: SPACE_ID, + name: 'Test Space', + ownerId: OWNER_ID, + inviteCode: 'code-1', + visibility: 'request', + createdAt: now, + }).run(); + + // @everyone role (id === spaceId) carries the default member permissions. + testDb.insert(schema.roles).values({ + id: SPACE_ID, + spaceId: SPACE_ID, + name: '@everyone', + permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS), + createdAt: now, + }).run(); + + // Owner and one ordinary member are enrolled; OUTSIDER and ADMIN are not. + testDb.insert(schema.spaceMembers).values({ spaceId: SPACE_ID, userId: OWNER_ID, joinedAt: now }).run(); + testDb.insert(schema.spaceMembers).values({ spaceId: SPACE_ID, userId: MEMBER_ID, joinedAt: now }).run(); +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + seed(); +}); + +describe('computePermissions', () => { + it('returns 0n for a user who is not a member of the space', async () => { + const { computePermissions } = await import('./permissions.js'); + expect(computePermissions(OUTSIDER_ID, SPACE_ID)).toBe(0n); + }); + + it('does not grant CREATE_INVITE to a non-member via @everyone', async () => { + const { hasPermission } = await import('./permissions.js'); + expect(hasPermission(OUTSIDER_ID, SPACE_ID, PermissionBits.CREATE_INVITE)).toBe(false); + }); + + it('does not grant read access to a non-member via @everyone', async () => { + const { hasPermission } = await import('./permissions.js'); + const read = PermissionBits.VIEW_CHANNEL | PermissionBits.READ_MESSAGE_HISTORY; + expect(hasPermission(OUTSIDER_ID, SPACE_ID, read)).toBe(false); + }); + + it('grants @everyone permissions to an enrolled member', async () => { + const { computePermissions } = await import('./permissions.js'); + const perms = computePermissions(MEMBER_ID, SPACE_ID); + expect(perms).toBe(DEFAULT_EVERYONE_PERMISSIONS); + expect(perms & PermissionBits.CREATE_INVITE).toBe(PermissionBits.CREATE_INVITE); + }); + + it('grants ALL_PERMISSIONS to the space owner', async () => { + const { computePermissions } = await import('./permissions.js'); + expect(computePermissions(OWNER_ID, SPACE_ID)).toBe(ALL_PERMISSIONS); + }); + + it('grants ALL_PERMISSIONS to an instance admin even when not a member', async () => { + const { computePermissions } = await import('./permissions.js'); + expect(computePermissions(ADMIN_ID, SPACE_ID)).toBe(ALL_PERMISSIONS); + }); +}); diff --git a/packages/server/src/utils/permissions.ts b/packages/server/src/utils/permissions.ts index c2de7850..0d154e4c 100644 --- a/packages/server/src/utils/permissions.ts +++ b/packages/server/src/utils/permissions.ts @@ -36,6 +36,13 @@ export function computePermissions(userId: string, spaceId: string, channelId?: const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); if (userRow?.isAdmin === 1) return ALL_PERMISSIONS; + // 1c. Membership gate — a user who has not joined the space has NO permissions + // in it. Without this, the @everyone role below leaks default member rights + // (VIEW_CHANNEL, READ_MESSAGE_HISTORY, CREATE_INVITE, …) to any authenticated + // non-member, which allowed reading channels and minting invite codes for + // spaces the caller never joined. Owner and instance admin are handled above. + if (!getMember(spaceId, userId)) return 0n; + // 2. Base permissions from @everyone role (id === spaceId) const everyoneRole = db.select().from(schema.roles) .where(and(eq(schema.roles.id, spaceId), eq(schema.roles.spaceId, spaceId))) @@ -146,67 +153,6 @@ export function computePermissions(userId: string, spaceId: string, channelId?: return base; } -/** - * Compute permissions at the category level (no channel step). - * Used for determining if a category is "private" for a user. - */ -export function computeCategoryPermissions(userId: string, spaceId: string, categoryId: string): bigint { - const db = getDb(); - - const space = db.select().from(schema.spaces).where(eq(schema.spaces.id, spaceId)).get(); - if (!space) return 0n; - if (space.ownerId === userId) return ALL_PERMISSIONS; - - const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); - if (userRow?.isAdmin === 1) return ALL_PERMISSIONS; - - const everyoneRole = db.select().from(schema.roles) - .where(and(eq(schema.roles.id, spaceId), eq(schema.roles.spaceId, spaceId))) - .get(); - let base = everyoneRole ? stringToPermissions(everyoneRole.permissions) : 0n; - - const memberRoleRows = db.select().from(schema.memberRoles) - .where(and(eq(schema.memberRoles.spaceId, spaceId), eq(schema.memberRoles.userId, userId))) - .all(); - const assignedRoleIds = memberRoleRows.map(mr => mr.roleId); - - for (const roleId of assignedRoleIds) { - const role = db.select().from(schema.roles).where(eq(schema.roles.id, roleId)).get(); - if (role) base |= stringToPermissions(role.permissions); - } - - if ((base & PermissionBits.ADMINISTRATOR) !== 0n) return ALL_PERMISSIONS; - - const catOverrides = db.select().from(schema.categoryOverrides) - .where(eq(schema.categoryOverrides.categoryId, categoryId)) - .all(); - - if (catOverrides.length === 0) return base; - - const everyoneOverride = catOverrides.find(o => o.targetType === 'role' && o.targetId === spaceId); - if (everyoneOverride) { - base = (base & ~stringToPermissions(everyoneOverride.deny)) | stringToPermissions(everyoneOverride.allow); - } - - let combinedAllow = 0n; - let combinedDeny = 0n; - for (const roleId of assignedRoleIds) { - const roleOverride = catOverrides.find(o => o.targetType === 'role' && o.targetId === roleId); - if (roleOverride) { - combinedAllow |= stringToPermissions(roleOverride.allow); - combinedDeny |= stringToPermissions(roleOverride.deny); - } - } - base = (base & ~combinedDeny) | combinedAllow; - - const memberOverride = catOverrides.find(o => o.targetType === 'member' && o.targetId === userId); - if (memberOverride) { - base = (base & ~stringToPermissions(memberOverride.deny)) | stringToPermissions(memberOverride.allow); - } - - return base; -} - /** * Check if a user has a specific permission in a space/channel. */ diff --git a/packages/server/src/ws/spaceVoiceState.test.ts b/packages/server/src/ws/spaceVoiceState.test.ts index cdc3c615..2f435d2f 100644 --- a/packages/server/src/ws/spaceVoiceState.test.ts +++ b/packages/server/src/ws/spaceVoiceState.test.ts @@ -54,6 +54,18 @@ function seedSpace(spaceId: string): void { }).run(); } +// Enroll a user as a space member. computePermissions grants @everyone +// permissions only to actual members, and every real join path inserts this row +// before voice state is built/pushed — so visibility tests must seed it too. +function seedMember(spaceId: string, userId: string): void { + seedUser(userId); + testDb.insert(schema.spaceMembers).values({ + spaceId, + userId, + joinedAt: Date.now(), + }).run(); +} + function seedChannel(id: string, spaceId: string, type: 'text' | 'voice'): void { testDb.insert(schema.channels).values({ id, @@ -178,6 +190,7 @@ describe('connectionManager.buildSpaceVoiceState', () => { const privateCh = 'vc-private-1'; seedSpace(spaceId); seedEveryoneRole(spaceId); + seedMember(spaceId, 'u-viewer'); seedChannel(publicCh, spaceId, 'voice'); seedChannel(privateCh, spaceId, 'voice'); seedDenyViewOverride(privateCh, spaceId); @@ -204,6 +217,7 @@ describe('connectionManager.addUserSpace voice-state push', () => { const voiceCh = 'vc-push-1'; seedSpace(spaceId); seedEveryoneRole(spaceId); + seedMember(spaceId, 'u-joiner'); seedChannel(voiceCh, spaceId, 'voice'); cm.createRoom(voiceCh, 'space', { type: 'space', spaceId }); diff --git a/packages/web/src/components/modals/InviteModal.test.tsx b/packages/web/src/components/modals/InviteModal.test.tsx index f384228e..9ab96382 100644 --- a/packages/web/src/components/modals/InviteModal.test.tsx +++ b/packages/web/src/components/modals/InviteModal.test.tsx @@ -425,6 +425,32 @@ describe('InviteModal', () => { expect(screen.getByText('Sam')).toBeInTheDocument(); }); + it('shows an approval-required notice and hides invite affordances for request-only spaces', async () => { + const generateInvite = vi.fn().mockResolvedValue('should-not-be-used'); + useUIStore.setState({ activeModal: 'invite', modalData: {} }); + useSpaceStore.setState({ + currentSpaceId: 'space-1', + spaces: [makeSpace({ visibility: 'request' })] as any, + members: [], + generateInvite, + } as any); + useSocialStore.setState({ + friends: [makeFriend({ id: 'f1', username: 'alex', displayName: 'Alex' })], + } as any); + useAuthStore.setState({ user: { id: 'me', username: 'me' } } as any); + + render(); + + // Explanatory copy replaces the invite UI. + expect(screen.getByText(/join request/i)).toBeInTheDocument(); + // None of the invite affordances render. + expect(screen.queryByPlaceholderText('Search friends...')).not.toBeInTheDocument(); + expect(screen.queryByText('Or share a link')).not.toBeInTheDocument(); + expect(screen.queryByText('Alex')).not.toBeInTheDocument(); + // No invite code is requested for a request-only space (the endpoint 403s). + expect(generateInvite).not.toHaveBeenCalled(); + }); + it('passes federated target shape for remote friends', async () => { const user = userEvent.setup(); mockSpaceInvite.mockResolvedValue({}); diff --git a/packages/web/src/components/modals/InviteModal.tsx b/packages/web/src/components/modals/InviteModal.tsx index efce5291..93892327 100644 --- a/packages/web/src/components/modals/InviteModal.tsx +++ b/packages/web/src/components/modals/InviteModal.tsx @@ -149,6 +149,10 @@ export function InviteModal() { const isOpen = activeModal === 'invite'; const currentSpace = spaces.find((s) => s.id === currentSpaceId); const instanceOrigin = currentSpace?._instanceOrigin ?? ''; + // Request-only spaces are approval-gated: they have no usable invite link and + // the /invite endpoint 403s. Show an explanatory notice instead of the invite + // affordances, and skip the invite-code fetch entirely. + const isRequestOnly = currentSpace?.visibility === 'request'; const [inviteCode, setInviteCode] = useState(''); const [codeError, setCodeError] = useState(''); @@ -167,7 +171,7 @@ export function InviteModal() { // Fetch / generate the per-space invite code on open. useEffect(() => { - if (!isOpen || !currentSpaceId) return; + if (!isOpen || !currentSpaceId || isRequestOnly) return; setCodeLoading(true); setCodeError(''); generateInvite(currentSpaceId).then( @@ -180,7 +184,7 @@ export function InviteModal() { setCodeLoading(false); }, ); - }, [isOpen, currentSpaceId, generateInvite]); + }, [isOpen, currentSpaceId, generateInvite, isRequestOnly]); // Reset modal state on open. useEffect(() => { @@ -333,6 +337,20 @@ export function InviteModal() { title="Invite Friends" mobileStyle="sheet" > + {isRequestOnly ? ( +
+

+ This space uses join requests — people join by requesting approval + from a manager, so it has no invite link to share. +

+ +
+ ) : (

Send to friends, or share a link. @@ -460,6 +478,7 @@ export function InviteModal() {

+ )} ); }