From 8a21cffdd96e20cc751d54203d8da3f937c78fc5 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 23:38:08 -0500 Subject: [PATCH] fix(config): compare env-backed config regardless of JSONB key order JSONB does not keep key order, so JSON.stringify called object values changed on every boot: the row was rewritten and a warning said an admin change was being reverted. --- .changeset/system-config-key-order.md | 5 ++++ src/config/bootstrapSystemConfig.ts | 6 ++++- .../unit/config/bootstrapSystemConfig.spec.ts | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .changeset/system-config-key-order.md diff --git a/.changeset/system-config-key-order.md b/.changeset/system-config-key-order.md new file mode 100644 index 00000000..e8269ac4 --- /dev/null +++ b/.changeset/system-config-key-order.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': patch +--- + +Starting the server no longer rewrites an env-backed `system_config` row, or warns that an admin change is being reverted, when the stored value only orders its keys differently. Postgres JSONB does not keep key order, so object values such as `lockout_policy` and `flow_rate_limits` looked changed on every boot. diff --git a/src/config/bootstrapSystemConfig.ts b/src/config/bootstrapSystemConfig.ts index 49e3d31b..1fc95e27 100644 --- a/src/config/bootstrapSystemConfig.ts +++ b/src/config/bootstrapSystemConfig.ts @@ -4,6 +4,8 @@ * See LICENSE file in the project root for full license information */ +import { isDeepStrictEqual } from 'node:util'; + import { oidcProviderConfigProblems } from '../lib/oidcProviderPolicy.js'; import { SystemConfig } from '../models/systemConfig.js'; import { SystemConfigSchema } from '../schemas/systemConfig.schema.js'; @@ -45,7 +47,9 @@ export async function bootstrapSystemConfig() { envValue, ); - if (JSON.stringify(existing.value) !== JSON.stringify(parsed)) { + // Not a comparison of the serialized values: JSONB does not keep key order, so an + // object read back from the row can spell the same value in a different order. + if (!isDeepStrictEqual(existing.value, parsed)) { logger.warn( `Overwriting system_config "${key}" from env ${envVar}: the stored value differs and ` + `the row is not marked admin-managed (updatedBy IS NULL). If this value was changed ` + diff --git a/tests/unit/config/bootstrapSystemConfig.spec.ts b/tests/unit/config/bootstrapSystemConfig.spec.ts index f9e7488f..e8234ad5 100644 --- a/tests/unit/config/bootstrapSystemConfig.spec.ts +++ b/tests/unit/config/bootstrapSystemConfig.spec.ts @@ -142,6 +142,33 @@ describe('bootstrapSystemConfig', () => { expect(loggerWarn).not.toHaveBeenCalled(); }); + it('does not rewrite a row whose JSONB value only orders its keys differently', 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: { otp: { perIp: 500 }, magicLink: { perIp: 500 } }, + updatedBy: null, + update, + }); + + process.env.APP_NAME = 'TestApp'; + process.env.RATE_LIMIT = '100'; + (parseSystemConfigEnvValue as any).mockReturnValue({ + magicLink: { perIp: 500 }, + otp: { perIp: 500 }, + }); + (SystemConfigSchema.safeParse as any).mockReturnValue({ success: true, data: {} }); + + const { bootstrapSystemConfig } = await import('../../../src/config/bootstrapSystemConfig'); + await bootstrapSystemConfig(); + + expect(update).not.toHaveBeenCalled(); + expect(loggerWarn).not.toHaveBeenCalled(); + }); + 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');