Runbook: migrate a plaintext connection password to Infisical
Who runs this: a human. Agents cannot β the secret guard blocks
infisical secrets set and every other secret write.
Pending as of 2026-08-10: connection
9238201b-d167-4a07-8e67-ede3ba88b507 (the CK sandbox pricing connection,
per scripts/eng-419-sync-sandbox-pricing.py) still carries a plaintext
password in extra_config.database_config.password.
Do not hand-write this
Section titled βDo not hand-write thisβThere is already a migration script and it is the supported path:
bun run packages/db/scripts/migrate-database-config-to-infisical.tsIt finds every erp_connections row with a plaintext
extra_config.database_config.password and no databaseSecretsPath, stores
the whole database_config in Infisical through
CredentialService.storeDatabaseCredentials() (path ERP_DBCRED_{UUID}),
then sets password to null and writes databaseSecretsPath back β in that
order, so the secret exists before the plaintext goes away.
It is idempotent (skips rows that already have databaseSecretsPath) and
safe (skips null/missing passwords without error), so a re-run after a
partial failure is fine.
One caveat: it re-checks nothing at write time. Selection filters on the
password and databaseSecretsPath, but the update is keyed on the row id
alone (.where(eq(erpConnections.id, conn.id))), so a change landing between
select and update is overwritten rather than detected. Run it when nothing else
is editing these rows, and re-verify afterwards (step 3) rather than assuming
the printed count is the final state.
Ad-hoc SQL for this is a trap in three separate ways, all of which have been gotten wrong before:
- the column is
extra_config, notconfig; - the contract sets
database_config.passwordto JSONnull, it does not remove the key β consumers read the key; - the Infisical path is derived by
CredentialService, not chosen by hand.
1. Confirm what will be migrated. The script processes every matching connection, not just the one you have in mind, so look before running it:
SELECT id, name, erp_type, extra_config->'database_config'->>'username' AS username, extra_config->>'databaseSecretsPath' AS secrets_pathFROM erp_connectionsWHERE extra_config->'database_config'->'password' IS NOT NULL AND extra_config->'database_config'->'password' != 'null'::jsonb AND extra_config->>'databaseSecretsPath' IS NULL;That predicate is the scriptβs own selection criteria. Expect
9238201b-d167-4a07-8e67-ede3ba88b507 in the result. If other rows appear,
decide deliberately whether they should migrate in the same pass.
2. Run it against the intended environment. Test writes go to the Staging
Neon DB, never prod β confirm which database DATABASE_URL points at before
running.
bun run packages/db/scripts/migrate-database-config-to-infisical.tsIt exits non-zero if any row errored.
3. Verify. Re-run the step 1 query β it should return no rows. Then confirm the credential actually resolves, which the DB row alone does not prove:
SELECT id, extra_config->'database_config'->'password' AS password_json, -- expect null extra_config->>'databaseSecretsPath' AS secrets_path -- expect ERP_DBCRED_<uuid>FROM erp_connectionsWHERE id = '9238201b-d167-4a07-8e67-ede3ba88b507';Then exercise the connection itself β a sync or health check. A row that looks right is not evidence the credential reads back.
Rollback
Section titled βRollbackβThere is no automatic rollback, and the script does not retain the old value:
recovery is re-reading the password from Infisical at ERP_DBCRED_{UUID} and
writing it back into extra_config.database_config.password. That is why step
3βs verification matters β confirm the Infisical secret resolves before you
consider the migration done.
The failure mode to avoid is a connection with neither a live Infisical secret nor a legacy password: no recoverable credential anywhere. Six production connections sat in exactly that state once. The scriptβs ordering prevents it; hand-written SQL is what caused it.
Note on the op CLI
Section titled βNote on the op CLIβop in this repo is a read-only service account β op item create fails
even for the user. If a value needs storing in 1Password rather than Infisical,
do it in the 1Password UI. Also: infisical secrets set echoes the value back
on success, so redirect its output if you ever run it directly.