feat(threads): thread and forum API, admin, schemas and docs (#3251)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:25:12 +02:00
1 parent 5f4295e399
commit 3f4160b138
495 files changed
+39232 -1215

No files matched your search

+10 -2
View File
@@ -3,6 +3,7 @@
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
interface PermissionsDiff {
@@ -10,10 +11,17 @@ interface PermissionsDiff {
removed: Array<string>;
}
export function computePermissionsDiff(oldPermissions: bigint, newPermissions: bigint): PermissionsDiff {
export function computePermissionsDiff(
oldPermissions: bigint,
newPermissions: bigint,
opts?: {threads?: boolean},
): PermissionsDiff {
const added: Array<string> = [];
const removed: Array<string> = [];
for (const [name, value] of Object.entries(Permissions)) {
const entries = opts?.threads
? [...Object.entries(Permissions), ...Object.entries(ThreadPermissionFlags)]
: Object.entries(Permissions);
for (const [name, value] of entries) {
const hadPermission = (oldPermissions & value) !== 0n;
const hasPermission = (newPermissions & value) !== 0n;
if (!hadPermission && hasPermission) {
+42 -14
View File
@@ -1,22 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {Headers} from '@fluxer/constants/src/Headers';
import {THREAD_AWARE_ALL_PERMISSIONS, THREAD_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils';
export const CLIENT_FEATURES_HEADER = Headers.X_FLUXER_FEATURES;
export const CHANNEL_THREADS_CLIENT_FEATURE = 'channel_threads';
export type ThreadPermissionMode = 'control' | 'active' | 'retired';
export interface ProtectedBitActor {
clientFeatures: ReadonlySet<string>;
threadBits?: {mode: ThreadPermissionMode; writer: boolean};
}
function threadBitsWritable(actor: ProtectedBitActor): boolean {
const threadBits = actor.threadBits;
if (!threadBits || threadBits.mode === 'control') return true;
return threadBits.mode === 'active' && threadBits.writer;
}
export function protectedThreadBits(actor: ProtectedBitActor): bigint {
return threadBitsWritable(actor) ? 0n : THREAD_PERMISSIONS;
}
export function permissionWriteMask(actor: ProtectedBitActor): bigint {
return !actor.threadBits || actor.threadBits.mode === 'control' ? ALL_PERMISSIONS : THREAD_AWARE_ALL_PERMISSIONS;
}
const PERMISSION_BIT_CLIENT_FEATURES: ReadonlyArray<{
bit: bigint;
feature: string;
}> = [{bit: Permissions.VIEW_CHANNEL_MEMBERS, feature: 'view_channel_members_permission'}];
writable: (actor: ProtectedBitActor) => boolean;
}> = [
{
bit: Permissions.VIEW_CHANNEL_MEMBERS,
writable: (actor) => actor.clientFeatures.has('view_channel_members_permission'),
},
{bit: THREAD_PERMISSIONS, writable: threadBitsWritable},
];
export function applyProtectedRolePermissions(
requested: bigint,
existing: bigint,
clientFeatures: ReadonlySet<string>,
): bigint {
export function applyProtectedRolePermissions(requested: bigint, existing: bigint, actor: ProtectedBitActor): bigint {
let result = requested;
for (const {bit, feature} of PERMISSION_BIT_CLIENT_FEATURES) {
if (clientFeatures.has(feature)) continue;
for (const {bit, writable} of PERMISSION_BIT_CLIENT_FEATURES) {
if (writable(actor)) continue;
result = (result & ~bit) | (existing & bit);
}
return result;
@@ -31,15 +57,15 @@ export function applyProtectedOverwriteBits(
allow: bigint;
deny: bigint;
},
clientFeatures: ReadonlySet<string>,
actor: ProtectedBitActor,
): {
allow: bigint;
deny: bigint;
} {
let allow = requested.allow;
let deny = requested.deny;
for (const {bit, feature} of PERMISSION_BIT_CLIENT_FEATURES) {
if (clientFeatures.has(feature)) continue;
for (const {bit, writable} of PERMISSION_BIT_CLIENT_FEATURES) {
if (writable(actor)) continue;
allow = (allow & ~bit) | (existing.allow & bit);
deny = (deny & ~bit) | (existing.deny & bit);
}
@@ -50,9 +76,11 @@ const MAX_CLIENT_FEATURES = 64;
const MAX_CLIENT_FEATURE_LENGTH = 64;
const VALID_FEATURE_NAME = /^[a-z0-9_]+$/;
const NO_CLIENT_FEATURES: ReadonlySet<string> = new Set();
export function parseClientFeaturesHeader(headerValue: string | null | undefined): ReadonlySet<string> {
if (!headerValue) {
return new Set();
return NO_CLIENT_FEATURES;
}
const features = new Set<string>();
for (const raw of headerValue.split(',')) {
@@ -42,33 +42,33 @@ describe('applyProtectedRolePermissions', () => {
it('preserves protected bits when client lacks the feature', () => {
const existing = VCM | OTHER_BIT;
const requested = OTHER_BIT;
const result = applyProtectedRolePermissions(requested, existing, new Set());
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set()});
expect((result & VCM) === VCM).toBe(true);
expect((result & OTHER_BIT) === OTHER_BIT).toBe(true);
});
it('respects client-driven clears when feature is opted in', () => {
const existing = VCM | OTHER_BIT;
const requested = OTHER_BIT;
const result = applyProtectedRolePermissions(requested, existing, new Set([FEATURE]));
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set([FEATURE])});
expect((result & VCM) === 0n).toBe(true);
expect((result & OTHER_BIT) === OTHER_BIT).toBe(true);
});
it('does not invent bits the existing value did not have', () => {
const existing = OTHER_BIT;
const requested = OTHER_BIT;
const result = applyProtectedRolePermissions(requested, existing, new Set());
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set()});
expect((result & VCM) === 0n).toBe(true);
});
it('also blocks an old client from setting the bit (the bit is fully invisible without the feature)', () => {
const existing = 0n;
const requested = VCM;
const result = applyProtectedRolePermissions(requested, existing, new Set());
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set()});
expect((result & VCM) === 0n).toBe(true);
});
it('lets a feature-aware client set the bit', () => {
const existing = 0n;
const requested = VCM;
const result = applyProtectedRolePermissions(requested, existing, new Set([FEATURE]));
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set([FEATURE])});
expect((result & VCM) === VCM).toBe(true);
});
});
@@ -77,51 +77,51 @@ describe('applyProtectedOverwriteBits', () => {
it('preserves bit on allow when feature absent', () => {
const existing = {allow: VCM, deny: 0n};
const requested = {allow: 0n, deny: 0n};
const result = applyProtectedOverwriteBits(requested, existing, new Set());
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
expect((result.allow & VCM) === VCM).toBe(true);
expect((result.deny & VCM) === 0n).toBe(true);
});
it('preserves bit on deny when feature absent (the security-relevant case)', () => {
const existing = {allow: 0n, deny: VCM};
const requested = {allow: 0n, deny: 0n};
const result = applyProtectedOverwriteBits(requested, existing, new Set());
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
expect((result.deny & VCM) === VCM).toBe(true);
});
it('preserves the bit on whichever side it was set', () => {
const existing = {allow: VCM | OTHER_BIT, deny: 0n};
const requested = {allow: OTHER_BIT, deny: 0n};
const result = applyProtectedOverwriteBits(requested, existing, new Set());
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
expect((result.allow & VCM) === VCM).toBe(true);
expect((result.allow & OTHER_BIT) === OTHER_BIT).toBe(true);
});
it('respects client-driven clears when feature is opted in (allow)', () => {
const existing = {allow: VCM, deny: 0n};
const requested = {allow: 0n, deny: 0n};
const result = applyProtectedOverwriteBits(requested, existing, new Set([FEATURE]));
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set([FEATURE])});
expect((result.allow & VCM) === 0n).toBe(true);
});
it('respects client-driven clears when feature is opted in (deny)', () => {
const existing = {allow: 0n, deny: VCM};
const requested = {allow: 0n, deny: 0n};
const result = applyProtectedOverwriteBits(requested, existing, new Set([FEATURE]));
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set([FEATURE])});
expect((result.deny & VCM) === 0n).toBe(true);
});
it('handles missing existing overwrite (treat as 0n) and blocks set without feature', () => {
const existing = {allow: 0n, deny: 0n};
const requested = {allow: VCM, deny: 0n};
const result = applyProtectedOverwriteBits(requested, existing, new Set());
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
expect((result.allow & VCM) === 0n).toBe(true);
});
it('lets a feature-aware client set the bit on a fresh overwrite', () => {
const existing = {allow: 0n, deny: 0n};
const requested = {allow: VCM, deny: 0n};
const result = applyProtectedOverwriteBits(requested, existing, new Set([FEATURE]));
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set([FEATURE])});
expect((result.allow & VCM) === VCM).toBe(true);
});
it('does not flip bits between allow and deny', () => {
const existing = {allow: 0n, deny: VCM};
const requested = {allow: 0n, deny: 0n};
const result = applyProtectedOverwriteBits(requested, existing, new Set());
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
expect((result.allow & VCM) === 0n).toBe(true);
expect((result.deny & VCM) === VCM).toBe(true);
});