Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/login-methods-env-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'seamless-auth-api': patch
---

Env-mapped system config (e.g. `LOGIN_METHODS`) now takes effect over
migration-seeded defaults. Previously the login-policy migration hard-seeded
`login_methods` and `bootstrapSystemConfig` only seeded missing rows, so the env
var was permanently ignored. Now bootstrap re-applies env values over config that
was never changed through the admin API (`updatedBy IS NULL`), admin edits record
`updatedBy` so they are preserved, and a migration re-applies env to existing
un-edited rows.
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 20 additions & 2 deletions src/config/bootstrapSystemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,31 @@ export async function bootstrapSystemConfig() {

for (const [key, envVar] of Object.entries(SYSTEM_CONFIG_ENV_MAP)) {
const existing = await SystemConfig.findByPk(key);
const envValue = process.env[envVar];

if (existing) {
resolvedConfig[key] = existing.value;
// Re-apply the env value over config that hasn't been changed through the
// admin API (updatedBy IS NULL), so env-mapped config stays authoritative
// unless an admin has overridden it at runtime. Without this, a migration
// that seeds a default would permanently shadow the env var.
if (envValue && existing.updatedBy == null) {
const parsed = parseSystemConfigEnvValue(
key as keyof typeof SYSTEM_CONFIG_ENV_MAP,
envValue,
);

if (JSON.stringify(existing.value) !== JSON.stringify(parsed)) {
await existing.update({ value: parsed });
}

resolvedConfig[key] = parsed;
} else {
resolvedConfig[key] = existing.value;
}

continue;
}

const envValue = process.env[envVar];
if (!envValue) {
const defaultValue = SYSTEM_CONFIG_DEFAULTS[key as keyof typeof SYSTEM_CONFIG_DEFAULTS];

Expand Down
4 changes: 4 additions & 0 deletions src/controllers/systemConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,16 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) {

const existingMap = Object.fromEntries(existingRows.map((row) => [row.key, row.value]));

const updatedBy = typeof req.clientId === 'function' ? req.clientId() : (req.clientId ?? null);

await SystemConfig.sequelize!.transaction(async (tx) => {
for (const [key, value] of Object.entries(updates)) {
await SystemConfig.upsert(
{
key,
value,
// Mark the row as admin-managed so bootstrap won't overwrite it from env.
updatedBy,
},
{ transaction: tx },
);
Expand Down
36 changes: 36 additions & 0 deletions src/migrations/20260628120000-reseed-env-system-config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict';

// Re-apply env-mapped values over migration-seeded defaults so env vars like
// LOGIN_METHODS take effect on existing installs. Only touches rows never changed
// through the admin API (updatedBy IS NULL); bootstrapSystemConfig keeps them in
// sync on every boot going forward.
module.exports = {
async up(queryInterface) {
const loginMethods = process.env.LOGIN_METHODS;
if (loginMethods) {
const arr = loginMethods
.split(',')
.map((s) => s.trim())
.filter(Boolean);
await queryInterface.sequelize.query(
`UPDATE system_config SET value = CAST(:val AS jsonb), "updatedAt" = NOW()
WHERE key = 'login_methods' AND "updatedBy" IS NULL`,
{ replacements: { val: JSON.stringify(arr) } },
);
}

const fallback = process.env.PASSKEY_LOGIN_FALLBACK_ENABLED;
if (fallback) {
const val = fallback.trim().toLowerCase() === 'true';
await queryInterface.sequelize.query(
`UPDATE system_config SET value = CAST(:val AS jsonb), "updatedAt" = NOW()
WHERE key = 'passkey_login_fallback_enabled' AND "updatedBy" IS NULL`,
{ replacements: { val: JSON.stringify(val) } },
);
}
},

async down() {
// No-op: re-seeding from env has no meaningful inverse.
},
};
42 changes: 42 additions & 0 deletions tests/unit/config/bootstrapSystemConfig.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,48 @@ describe('bootstrapSystemConfig', () => {
expect(result).toBeDefined();
});

it('re-applies env over an existing row that was not admin-edited', async () => {
const { SystemConfig } = await import('../../../src/models/systemConfig');
const { parseSystemConfigEnvValue } = await import('../../../src/utils/parseEnvConfigs');
const { SystemConfigSchema } = await import('../../../src/schemas/systemConfig.schema');

const update = vi.fn();
(SystemConfig.findByPk as any).mockResolvedValue({ value: 'old', updatedBy: null, update });

process.env.APP_NAME = 'TestApp';
process.env.RATE_LIMIT = '100';
(parseSystemConfigEnvValue as any).mockReturnValue('parsed');
(SystemConfigSchema.safeParse as any).mockReturnValue({ success: true, data: {} });

const { bootstrapSystemConfig } = await import('../../../src/config/bootstrapSystemConfig');
await bootstrapSystemConfig();

expect(update).toHaveBeenCalledWith({ value: 'parsed' });
});

it('preserves a row that was changed via the admin API (updatedBy set)', async () => {
const { SystemConfig } = await import('../../../src/models/systemConfig');
const { parseSystemConfigEnvValue } = await import('../../../src/utils/parseEnvConfigs');
const { SystemConfigSchema } = await import('../../../src/schemas/systemConfig.schema');

const update = vi.fn();
(SystemConfig.findByPk as any).mockResolvedValue({
value: 'admin-value',
updatedBy: 'admin-id',
update,
});

process.env.APP_NAME = 'TestApp';
process.env.RATE_LIMIT = '100';
(parseSystemConfigEnvValue as any).mockReturnValue('parsed');
(SystemConfigSchema.safeParse as any).mockReturnValue({ success: true, data: {} });

const { bootstrapSystemConfig } = await import('../../../src/config/bootstrapSystemConfig');
await bootstrapSystemConfig();

expect(update).not.toHaveBeenCalled();
});

it('throws when env missing', async () => {
const { SystemConfig } = await import('../../../src/models/systemConfig');

Expand Down
Loading