feat: add prelaunch site controls and refresh branding
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const databaseRow = vi.hoisted(() => ({ value: undefined as Record<string, unknown> | undefined }));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => ({
|
||||
prepare: vi.fn(async () => ({
|
||||
get: vi.fn(async () => databaseRow.value)
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
|
||||
import {
|
||||
getSiteAccessBlock,
|
||||
getSiteAccessSettings,
|
||||
invalidateSiteAccessSettings,
|
||||
isLaunchGateExempt,
|
||||
siteSectionForPath,
|
||||
type SiteAccessSettings,
|
||||
type SiteSectionKey
|
||||
} from './site-access';
|
||||
|
||||
function accessSettings(
|
||||
overrides: Partial<Pick<SiteAccessSettings, 'comingSoonEnabled' | 'accountCreationEnabled'>> & {
|
||||
sections?: Partial<SiteAccessSettings['sections']>;
|
||||
} = {}
|
||||
): SiteAccessSettings {
|
||||
return {
|
||||
comingSoonEnabled: overrides.comingSoonEnabled ?? false,
|
||||
accountCreationEnabled: overrides.accountCreationEnabled ?? true,
|
||||
sections: {
|
||||
news: true,
|
||||
database: true,
|
||||
builds: true,
|
||||
guides: true,
|
||||
builder: true,
|
||||
...overrides.sections
|
||||
},
|
||||
updatedAt: '2026-07-27T00:00:00.000Z'
|
||||
};
|
||||
}
|
||||
|
||||
describe('siteSectionForPath', () => {
|
||||
it.each([
|
||||
['/news', 'news'],
|
||||
['/news/latest', 'news'],
|
||||
['/database', 'database'],
|
||||
['/database/weapons/first-wave-vector', 'database'],
|
||||
['/builds', 'builds'],
|
||||
['/builds/shared-loadout', 'builds'],
|
||||
['/guides', 'guides'],
|
||||
['/guides/combat-medic', 'guides'],
|
||||
['/builder', 'builder'],
|
||||
['/builder/loadout-id', 'builder']
|
||||
])('maps %s to the %s launch section', (pathname, section) => {
|
||||
expect(siteSectionForPath(pathname)).toBe(section);
|
||||
});
|
||||
|
||||
it.each(['/', '/newsroom', '/database-tools', '/build', '/guide', '/builder-tools', '/isac'])(
|
||||
'does not treat %s as a gated section',
|
||||
(pathname) => {
|
||||
expect(siteSectionForPath(pathname)).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('isLaunchGateExempt', () => {
|
||||
it.each([
|
||||
'/coming-soon',
|
||||
'/sign-in',
|
||||
'/terms',
|
||||
'/dmca',
|
||||
'/contact',
|
||||
'/auth/discord',
|
||||
'/auth/discord/callback',
|
||||
'/api/health/database',
|
||||
'/api/internal/notifications',
|
||||
'/_app/immutable/entry/start.js',
|
||||
'/favicon.svg'
|
||||
])('keeps %s reachable while the launch gate is active', (pathname) => {
|
||||
expect(isLaunchGateExempt(pathname)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/',
|
||||
'/news',
|
||||
'/isac',
|
||||
'/coming-soon/preview',
|
||||
'/sign-in/help',
|
||||
'/auth',
|
||||
'/api/health',
|
||||
'/api/internal',
|
||||
'/favicon.png'
|
||||
])('does not exempt %s', (pathname) => {
|
||||
expect(isLaunchGateExempt(pathname)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSiteAccessBlock', () => {
|
||||
it('gives the global launch gate priority over a disabled section for anonymous visitors', () => {
|
||||
const settings = accessSettings({
|
||||
comingSoonEnabled: true,
|
||||
sections: { builder: false }
|
||||
});
|
||||
|
||||
expect(getSiteAccessBlock(settings, '/builder', false)).toEqual({ kind: 'launch' });
|
||||
});
|
||||
|
||||
it('lets authenticated users bypass the global launch gate when the requested section is enabled', () => {
|
||||
const settings = accessSettings({ comingSoonEnabled: true });
|
||||
|
||||
expect(getSiteAccessBlock(settings, '/builder', true)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['/news', 'news'],
|
||||
['/database/weapons', 'database'],
|
||||
['/builds/agent-loadout', 'builds'],
|
||||
['/guides/combat-medic', 'guides'],
|
||||
['/builder', 'builder']
|
||||
] satisfies Array<[string, SiteSectionKey]>)(
|
||||
'blocks %s for authenticated and anonymous visitors when the %s section is disabled',
|
||||
(pathname, section) => {
|
||||
const settings = accessSettings({
|
||||
sections: { [section]: false }
|
||||
});
|
||||
|
||||
expect(getSiteAccessBlock(settings, pathname, false)).toEqual({ kind: 'section', section });
|
||||
expect(getSiteAccessBlock(settings, pathname, true)).toEqual({ kind: 'section', section });
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['/coming-soon', '/sign-in', '/terms', '/dmca', '/contact', '/auth/discord/callback'])(
|
||||
'keeps exempt route %s available while the global gate is active',
|
||||
(pathname) => {
|
||||
const settings = accessSettings({ comingSoonEnabled: true });
|
||||
|
||||
expect(getSiteAccessBlock(settings, pathname, false)).toBeNull();
|
||||
expect(getSiteAccessBlock(settings, pathname, true)).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('allows an enabled section when the global launch gate is off', () => {
|
||||
const settings = accessSettings();
|
||||
|
||||
expect(getSiteAccessBlock(settings, '/news', false)).toBeNull();
|
||||
expect(getSiteAccessBlock(settings, '/news', true)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSiteAccessSettings', () => {
|
||||
beforeEach(() => {
|
||||
databaseRow.value = undefined;
|
||||
invalidateSiteAccessSettings();
|
||||
});
|
||||
|
||||
it('fails safely to the launch page while leaving registration and sections staged on a new database', async () => {
|
||||
await expect(getSiteAccessSettings()).resolves.toEqual({
|
||||
comingSoonEnabled: true,
|
||||
accountCreationEnabled: true,
|
||||
sections: {
|
||||
news: true,
|
||||
database: true,
|
||||
builds: true,
|
||||
guides: true,
|
||||
builder: true
|
||||
},
|
||||
updatedAt: '2026-07-27T00:00:00.000Z'
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes persisted integer flags into the public settings shape', async () => {
|
||||
databaseRow.value = {
|
||||
coming_soon_enabled: 0,
|
||||
account_creation_enabled: 0,
|
||||
news_enabled: 1,
|
||||
database_enabled: 0,
|
||||
builds_enabled: 1,
|
||||
guides_enabled: 0,
|
||||
builder_enabled: 1,
|
||||
updated_at: '2026-08-01T12:30:00.000Z'
|
||||
};
|
||||
|
||||
await expect(getSiteAccessSettings()).resolves.toEqual({
|
||||
comingSoonEnabled: false,
|
||||
accountCreationEnabled: false,
|
||||
sections: {
|
||||
news: true,
|
||||
database: false,
|
||||
builds: true,
|
||||
guides: false,
|
||||
builder: true
|
||||
},
|
||||
updatedAt: '2026-08-01T12:30:00.000Z'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
|
||||
export const SITE_SECTION_KEYS = ['news', 'database', 'builds', 'guides', 'builder'] as const;
|
||||
export type SiteSectionKey = (typeof SITE_SECTION_KEYS)[number];
|
||||
export type SiteAccessSettings = {
|
||||
comingSoonEnabled: boolean;
|
||||
accountCreationEnabled: boolean;
|
||||
sections: Record<SiteSectionKey, boolean>;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type SiteAccessBlock =
|
||||
| { kind: 'launch' }
|
||||
| { kind: 'section'; section: SiteSectionKey };
|
||||
|
||||
const DEFAULT_SETTINGS: SiteAccessSettings = {
|
||||
comingSoonEnabled: true,
|
||||
accountCreationEnabled: true,
|
||||
sections: { news: true, database: true, builds: true, guides: true, builder: true },
|
||||
updatedAt: '2026-07-27T00:00:00.000Z'
|
||||
};
|
||||
|
||||
let cached: { expiresAt: number; value: SiteAccessSettings } | null = null;
|
||||
|
||||
function normalized(row: Record<string, unknown> | undefined): SiteAccessSettings {
|
||||
if (!row) return structuredClone(DEFAULT_SETTINGS);
|
||||
return {
|
||||
comingSoonEnabled: Boolean(row.coming_soon_enabled),
|
||||
accountCreationEnabled: Boolean(row.account_creation_enabled),
|
||||
sections: {
|
||||
news: Boolean(row.news_enabled),
|
||||
database: Boolean(row.database_enabled),
|
||||
builds: Boolean(row.builds_enabled),
|
||||
guides: Boolean(row.guides_enabled),
|
||||
builder: Boolean(row.builder_enabled)
|
||||
},
|
||||
updatedAt: String(row.updated_at ?? DEFAULT_SETTINGS.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
export function invalidateSiteAccessSettings() {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
export async function getSiteAccessSettings() {
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
const database = await getDatabase();
|
||||
const row = await (await database.prepare("SELECT * FROM site_launch_settings WHERE id='primary'")).get();
|
||||
const value = normalized(row);
|
||||
cached = { value, expiresAt: Date.now() + 5_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
export function siteSectionForPath(pathname: string): SiteSectionKey | null {
|
||||
for (const section of SITE_SECTION_KEYS) {
|
||||
if (pathname === `/${section}` || pathname.startsWith(`/${section}/`)) return section;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isLaunchGateExempt(pathname: string) {
|
||||
return pathname === '/coming-soon' ||
|
||||
pathname === '/sign-in' ||
|
||||
pathname === '/terms' ||
|
||||
pathname === '/dmca' ||
|
||||
pathname === '/contact' ||
|
||||
pathname.startsWith('/auth/') ||
|
||||
pathname.startsWith('/api/health/') ||
|
||||
pathname.startsWith('/api/internal/') ||
|
||||
pathname.startsWith('/_app/') ||
|
||||
pathname === '/favicon.svg';
|
||||
}
|
||||
|
||||
export function getSiteAccessBlock(
|
||||
settings: SiteAccessSettings,
|
||||
pathname: string,
|
||||
authenticated: boolean
|
||||
): SiteAccessBlock | null {
|
||||
if (isLaunchGateExempt(pathname)) return null;
|
||||
if (!authenticated && settings.comingSoonEnabled) return { kind: 'launch' };
|
||||
|
||||
const section = siteSectionForPath(pathname);
|
||||
if (section && !settings.sections[section]) return { kind: 'section', section };
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user