feat(auth): add username sign-in mode and recovery kits (#3215)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-05 14:10:07 +02:00
1 parent bf3d73a5f7
commit e1eecc3b6c
355 files changed
+52138 -24097

No files matched your search

@@ -6,6 +6,12 @@ import type {APIConfig, BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/ap
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
import {
type AccountIdentity,
resolveAccountIdentity,
type StoredAccountIdentity,
setCachedAccountIdentity,
} from '@app/api/instance/AccountIdentityModeCache';
import {
getDefaultDateOfBirthCollection,
setCachedDateOfBirthCollection,
@@ -20,9 +26,15 @@ import {
isStripeServiceable,
setStoredBillingConfig,
} from '@app/api/stripe/BillingConfigCache';
import {InstanceConfiguration} from '@app/api/Tables';
import {InstanceConfiguration, Users} from '@app/api/Tables';
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '@app/api/utils/AttachmentDecay';
import {isJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
import {
type AccountIdentityMode,
AccountIdentityModes,
type TagStyle,
TagStyles,
} from '@fluxer/constants/src/AccountIdentityConstants';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ConflictError} from '@fluxer/errors/src/domains/core/ConflictError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
@@ -95,6 +107,7 @@ const LIMIT_CONFIG_KEY = 'limit_config';
const INSTANCE_INTEGRATIONS_CONFIG_KEY = 'instance_integrations_config';
const INSTANCE_MEDIA_CONFIG_KEY = 'instance_media_config';
const INSTANCE_BILLING_CONFIG_KEY = 'instance_billing_config';
const ACCOUNT_IDENTITY_CONFIG_KEY = 'account_identity_config';
export const INSTANCE_CONFIG_REFRESH_CHANNEL = 'instance-config-refresh';
export const REGISTRATION_PENDING_APPROVAL_TRAIT = 'registration_pending_approval';
export const REGISTRATION_REJECTED_TRAIT = 'registration_rejected';
@@ -347,6 +360,7 @@ const FETCH_CONFIG_QUERY = InstanceConfiguration.selectCql({
limit: 1,
});
const FETCH_ALL_CONFIG_QUERY = InstanceConfiguration.selectCql();
const FETCH_ANY_USER_QUERY = Users.selectCql({columns: ['user_id'], limit: 1, unordered: true});
const FETCH_LIMIT_CONFIG_INPUTS_QUERY = InstanceConfiguration.selectCql({
where: InstanceConfiguration.where.in('key', 'keys'),
});
@@ -423,7 +437,8 @@ type StoredConfigSection =
| 'registration URLs'
| 'pending registrations'
| 'SSO flags'
| 'SSO allowed domains';
| 'SSO allowed domains'
| 'account identity';
function parseStoredConfigValue(raw: string | null, section: StoredConfigSection): unknown {
if (raw === null) return {};
@@ -535,6 +550,62 @@ function checkStoredConfig(section: StoredConfigSection, decode: () => unknown):
readStoredConfigOrDefault(section, decode, () => null);
}
export type AccountIdentityDecisionSource = 'new_instance' | 'existing_instance' | 'setup';
const StoredAccountIdentityConfigSchema = z.object({
mode: z.enum([AccountIdentityModes.EMAIL, AccountIdentityModes.USERNAME]),
tag_style: z.enum([TagStyles.NONE, 'zero_first', TagStyles.RANDOM]).optional(),
unique_usernames: z.boolean().optional(),
decided_at: z.string(),
source: z.enum(['new_instance', 'existing_instance', 'setup']),
});
export type StoredAccountIdentityConfig = z.infer<typeof StoredAccountIdentityConfigSchema>;
function parseStoredAccountIdentityConfig(raw: string | null): StoredAccountIdentityConfig | null {
if (raw === null) return null;
return validateStoredConfig(
StoredAccountIdentityConfigSchema,
parseStoredConfigValue(raw, 'account identity'),
'account identity',
);
}
function readStoredAccountIdentity(raw: string | null): StoredAccountIdentity | null {
return readStoredConfigOrDefault(
'account identity',
() => {
const stored = parseStoredAccountIdentityConfig(raw);
return stored ? {mode: stored.mode, tagStyle: readStoredTagStyle(stored)} : null;
},
() => null,
);
}
function readStoredAccountIdentitySource(raw: string | null): AccountIdentityDecisionSource | null {
return readStoredConfigOrDefault(
'account identity',
() => parseStoredAccountIdentityConfig(raw)?.source ?? null,
() => null,
);
}
function readStoredTagStyle(stored: StoredAccountIdentityConfig): TagStyle {
if (stored.mode === AccountIdentityModes.USERNAME) return TagStyles.NONE;
if (stored.tag_style === 'zero_first') return TagStyles.NONE;
if (stored.tag_style) return stored.tag_style;
return stored.unique_usernames === true ? TagStyles.NONE : TagStyles.RANDOM;
}
function serialiseAccountIdentity(identity: StoredAccountIdentity, source: AccountIdentityDecisionSource): string {
return JSON.stringify({
mode: identity.mode,
tag_style: identity.tagStyle,
decided_at: new Date().toISOString(),
source,
});
}
function decodeGatewayRolloutConfig(value: unknown): GatewayRolloutConfig {
const input =
isJsonRecord(value) &&
@@ -1276,6 +1347,7 @@ export class InstanceConfigRepository {
setStoredBillingConfig(parseStoredInstanceBillingConfig(snapshot.get(INSTANCE_BILLING_CONFIG_KEY) ?? null));
const appPublic = parseStoredAppPublicConfig(snapshot.get(APP_PUBLIC_CONFIG_KEY) ?? null);
setCachedDateOfBirthCollection(appPublic.registration.collect_date_of_birth);
setCachedAccountIdentity(readStoredAccountIdentity(snapshot.get(ACCOUNT_IDENTITY_CONFIG_KEY) ?? null));
}
private async publishRefresh(sourceId: string): Promise<void> {
@@ -1293,6 +1365,7 @@ export class InstanceConfigRepository {
}
clearCacheForTesting(): void {
setCachedAccountIdentity(null);
const shutdown = this.shutdown();
this.configCache = this.createConfigCache(shutdown);
void shutdown.catch((error) => {
@@ -1308,6 +1381,131 @@ export class InstanceConfigRepository {
await this.setConfig(ADMIN_BOOTSTRAP_KEY, 'true');
}
async getAccountIdentity(): Promise<AccountIdentity> {
if (!Config.instance.selfHosted) return resolveAccountIdentity(null);
const stored = readStoredAccountIdentity(await this.getConfig(ACCOUNT_IDENTITY_CONFIG_KEY));
setCachedAccountIdentity(stored);
return resolveAccountIdentity(stored);
}
async getAccountIdentityMode(): Promise<AccountIdentityMode> {
return (await this.getAccountIdentity()).mode;
}
async getTagStyle(): Promise<TagStyle> {
return (await this.getAccountIdentity()).tagStyle;
}
async usesUniqueUsernames(): Promise<boolean> {
return (await this.getTagStyle()) === TagStyles.NONE;
}
async isAccountIdentityLocked(): Promise<boolean> {
if (!Config.instance.selfHosted) return true;
if (Config.instance.setup.configured) return true;
if (readStoredAccountIdentitySource(await this.getConfig(ACCOUNT_IDENTITY_CONFIG_KEY)) === 'existing_instance') {
return true;
}
const appPublic = await this.getAppPublicConfig();
if (appPublic.setup.configured) return true;
if (await this.isAdminBootstrapped()) return true;
return await this.hasAnyUser();
}
async setAccountIdentityMode(
mode: AccountIdentityMode,
source: AccountIdentityDecisionSource,
tagStyle: TagStyle = TagStyles.NONE,
): Promise<void> {
if (!Config.instance.selfHosted) {
throw new Error('The account identity mode is fixed on the hosted service');
}
const identity: StoredAccountIdentity = {
mode,
tagStyle: mode === AccountIdentityModes.USERNAME ? TagStyles.NONE : tagStyle,
};
const value = serialiseAccountIdentity(identity, source);
const cache = this.configCache;
await this.compareAndSetStoredValue(cache, ACCOUNT_IDENTITY_CONFIG_KEY, () => ({value, result: undefined}));
setCachedAccountIdentity(identity);
await this.publishRefresh(cache.sourceId);
}
async ensureAccountIdentityMode(): Promise<void> {
if (!Config.instance.selfHosted) return;
const cache = this.configCache;
let identity: StoredAccountIdentity;
let source: AccountIdentityDecisionSource;
try {
if ((await this.fetchConfigForWrite(ACCOUNT_IDENTITY_CONFIG_KEY)) !== null) return;
const existing = await this.hasExistingInstanceSignals();
const defaultMode = (await this.hasEmailDeliverySignal())
? AccountIdentityModes.EMAIL
: AccountIdentityModes.USERNAME;
const mode = existing ? AccountIdentityModes.EMAIL : (Config.instance.accountIdentity ?? defaultMode);
if (!existing && mode === AccountIdentityModes.USERNAME && Config.instance.tagStyle === TagStyles.RANDOM) {
Logger.warn('FLUXER_TAG_STYLE=random only applies to email sign-in, username sign-in always uses no tags');
}
identity = {
mode,
tagStyle: existing
? TagStyles.RANDOM
: mode === AccountIdentityModes.USERNAME
? TagStyles.NONE
: (Config.instance.tagStyle ?? TagStyles.NONE),
};
source = existing ? 'existing_instance' : 'new_instance';
} catch (error) {
Logger.error({error}, 'Could not decide the account identity mode, leaving it on email until the next boot');
return;
}
const value = serialiseAccountIdentity(identity, source);
try {
const {written} = await this.compareAndSetStoredValue(cache, ACCOUNT_IDENTITY_CONFIG_KEY, (raw) => ({
value: raw === null ? value : null,
result: undefined,
}));
if (!written) {
setCachedAccountIdentity(
readStoredAccountIdentity(await this.fetchConfigFromDatabase(ACCOUNT_IDENTITY_CONFIG_KEY)),
);
return;
}
setCachedAccountIdentity(identity);
Logger.info({mode: identity.mode, tag_style: identity.tagStyle, source}, 'Stamped the account identity mode');
await this.publishRefresh(cache.sourceId);
} catch (error) {
Logger.error({error}, 'Could not store the account identity mode, leaving it on email until the next boot');
}
}
private async hasExistingInstanceSignals(): Promise<boolean> {
if (Config.instance.setup.configured) return true;
const [adminBootstrapped, appPublicRaw] = await Promise.all([
this.fetchConfigFromDatabase(ADMIN_BOOTSTRAP_KEY),
this.fetchConfigFromDatabase(APP_PUBLIC_CONFIG_KEY),
]);
if (adminBootstrapped === 'true') return true;
const appPublic = parseStoredConfigValue(appPublicRaw, 'app public');
const setupConfigured = isJsonRecord(appPublic) && isJsonRecord(appPublic.setup) && appPublic.setup.configured;
if (setupConfigured === true) return true;
return await this.hasAnyUser();
}
private async hasEmailDeliverySignal(): Promise<boolean> {
if (Config.email.enabled) return true;
const integrations = parseStoredConfigValue(
await this.fetchConfigFromDatabase(INSTANCE_INTEGRATIONS_CONFIG_KEY),
'integrations',
);
return isJsonRecord(integrations) && isJsonRecord(integrations.email) && integrations.email.enabled === true;
}
private async hasAnyUser(): Promise<boolean> {
const rows = await fetchMany<{user_id: bigint}>(FETCH_ANY_USER_QUERY, {});
return rows.length > 0;
}
async getGatewayRolloutConfig(): Promise<GatewayRolloutConfig> {
const raw = await this.getConfig(GATEWAY_ROLLOUT_CONFIG_KEY);
return parseStoredGatewayRolloutConfig(raw);
@@ -1647,9 +1845,10 @@ export class InstanceConfigRepository {
fromName,
smtp,
};
const usernameMode = (await this.getAccountIdentityMode()) === AccountIdentityModes.USERNAME;
return {
...next,
enabled: next.enabled && hasCompleteSmtpConfig(next),
enabled: !usernameMode && next.enabled && hasCompleteSmtpConfig(next),
};
}