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

+15 -1
View File
@@ -11,7 +11,13 @@ import {
parsePublicOrigin,
parseWebOrigin,
} from '@fluxer/config/src/EndpointDerivation';
import {CACHE_PURGE_ADAPTER_NAMES, type MasterConfig, STORE_PRODUCT_SLOT_NAMES} from '@fluxer/config/src/MasterConfig';
import {
ACCOUNT_IDENTITY_MODE_NAMES,
CACHE_PURGE_ADAPTER_NAMES,
type MasterConfig,
STORE_PRODUCT_SLOT_NAMES,
TAG_STYLE_NAMES,
} from '@fluxer/config/src/MasterConfig';
let cachedConfig: MasterConfig | null = null;
@@ -247,6 +253,8 @@ function defaultConfig(): MasterConfig {
setup: {
configured: false,
},
account_identity: null,
tag_style: null,
},
dev: {
relax_registration_rate_limits: false,
@@ -569,6 +577,12 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
assertOneOf(config.integrations.email.provider, ['smtp', 'none'], 'FLUXER_EMAIL_PROVIDER');
assertOneOf(config.integrations.search.engine, ['elasticsearch', 'meilisearch'], 'FLUXER_SEARCH_ENGINE');
assertOneOf(config.integrations.cache_purge.adapter, CACHE_PURGE_ADAPTER_NAMES, 'FLUXER_CACHE_PURGE_ADAPTER');
if (config.instance.tag_style !== null) {
assertOneOf(config.instance.tag_style, TAG_STYLE_NAMES, 'FLUXER_TAG_STYLE');
}
if (config.instance.account_identity !== null) {
assertOneOf(config.instance.account_identity, ACCOUNT_IDENTITY_MODE_NAMES, 'FLUXER_ACCOUNT_IDENTITY');
}
validatePostgresConfig(config);
validateApiWorkerConfig(config);
validateStorageChangeFeedConfig(config);
+6
View File
@@ -9,6 +9,10 @@ export const CACHE_PURGE_ADAPTER_NAMES = ['none', 'http'] as const;
export type CachePurgeAdapterName = (typeof CACHE_PURGE_ADAPTER_NAMES)[number];
export const STORE_PRODUCT_SLOT_NAMES = ['monthly', 'yearly', 'gift_1_month', 'gift_1_year'] as const;
export type StoreProductSlotName = (typeof STORE_PRODUCT_SLOT_NAMES)[number];
export const ACCOUNT_IDENTITY_MODE_NAMES = ['email', 'username'] as const;
export type AccountIdentityModeName = (typeof ACCOUNT_IDENTITY_MODE_NAMES)[number];
export const TAG_STYLE_NAMES = ['none', 'random'] as const;
export type TagStyleName = (typeof TAG_STYLE_NAMES)[number];
export interface InstanceBrandingConfig {
product_name: string;
@@ -299,6 +303,8 @@ export interface MasterConfig {
setup: {
configured: boolean;
};
account_identity: AccountIdentityModeName | null;
tag_style: TagStyleName | null;
};
dev: {
relax_registration_rate_limits: boolean;
@@ -564,6 +564,48 @@ describe('ConfigLoader', () => {
expect(config.instance.setup.configured).toBe(true);
});
test('leaves the account identity unset by default', async () => {
stubMinimalEnv();
const config = await loadConfig();
expect(config.instance.account_identity).toBeNull();
});
test('treats an empty account identity as unset', async () => {
stubMinimalEnv({FLUXER_ACCOUNT_IDENTITY: ' '});
const config = await loadConfig();
expect(config.instance.account_identity).toBeNull();
});
test.each([
['email', 'email'],
['username', 'username'],
[' Username ', 'username'],
])('parses FLUXER_ACCOUNT_IDENTITY=%j', async (raw, expected) => {
stubMinimalEnv({FLUXER_ACCOUNT_IDENTITY: raw});
const config = await loadConfig();
expect(config.instance.account_identity).toBe(expected);
});
test.each([
['none', 'none'],
[' Random ', 'random'],
['', null],
])('parses FLUXER_TAG_STYLE=%j', async (raw, expected) => {
stubMinimalEnv({FLUXER_TAG_STYLE: raw});
const config = await loadConfig();
expect(config.instance.tag_style).toBe(expected);
});
test.each(['sequential', 'zero_first'])('rejects the tag style %j', async (raw) => {
stubMinimalEnv({FLUXER_TAG_STYLE: raw});
await expect(loadConfig()).rejects.toThrow('FLUXER_TAG_STYLE must be none or random');
});
test('rejects an unknown account identity', async () => {
stubMinimalEnv({FLUXER_ACCOUNT_IDENTITY: 'phone'});
await expect(loadConfig()).rejects.toThrow('FLUXER_ACCOUNT_IDENTITY must be email or username');
});
test('leaves both stores off with no apps, packages or products by default', async () => {
stubMinimalEnv();
const config = await loadConfig();
@@ -280,6 +280,8 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_APP_STATUS_PAGE_URL: {path: ['instance', 'branding', 'status_page_url']},
FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: {path: ['instance', 'branding', 'status_page_incident_history_url']},
FLUXER_INSTANCE_SETUP_CONFIGURED: {path: ['instance', 'setup', 'configured'], parse: parseBoolean},
FLUXER_ACCOUNT_IDENTITY: {path: ['instance', 'account_identity'], parse: parseAccountIdentity},
FLUXER_TAG_STYLE: {path: ['instance', 'tag_style'], parse: parseTagStyle},
FLUXER_DISCOVERY_ENABLED: {path: ['discovery', 'enabled'], parse: parseBoolean},
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: {path: ['discovery', 'min_member_count'], parse: parseInteger},
FLUXER_DELETION_GRACE_PERIOD_HOURS: {path: ['deletion_grace_period_hours'], parse: parseInteger},
@@ -326,6 +328,18 @@ function parseBoolean(raw: string): boolean {
}
}
function parseAccountIdentity(raw: string): string {
const value = raw.trim().toLowerCase();
if (value === 'email' || value === 'username') return value;
throw new Error('must be email or username');
}
function parseTagStyle(raw: string): string {
const value = raw.trim().toLowerCase();
if (value === 'none' || value === 'random') return value;
throw new Error('must be none or random');
}
function parseJson(raw: string): unknown {
try {
return JSON.parse(raw);