Credentials at runtime

Hand a channel its token from a database or a settings page instead of .env, and swap it while the process runs. The channel restarts on the new values, like docker down and up without the docker.

.env is right for most apps. It is wrong for one kind: the app whose users enter the Discord webhook or the Mastodon token on a settings page. Those values live in a database, and changing them must not need a redeploy.

configure() is for that. Every channel has it.

Copy this

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-pigeon'],
  nuxtPigeon: {
    channels: {
      mastodon: { credentials: 'runtime', receive: true },
    },
  },
})
server/plugins/pigeon-settings.ts
export default defineNitroPlugin(async () => {
  const settings = await loadSettings() // your database, your shape

  if (settings.mastodon) {
    await mastodon.configure(settings.mastodon)
  }
})
server/api/settings.put.ts
export default defineEventHandler(async (event) => {
  const body = await readBody<{ instance: string; token: string }>(event)

  await saveSettings({ mastodon: body })
  await mastodon.configure(body) // applies now, the poller restarts on the new account

  return mastodon.status()
})

That runs. Nothing is read from .env for Mastodon, the poller starts when the plugin hands the values over, and the settings route swaps them without anyone restarting anything.

What just happened

  • credentials: 'runtime' told the channel to ignore .env and wait. Without it the channel would have started on whatever the environment had, and the configure() in the plugin would have restarted it a second later.
  • configure() stopped, set, and started. For a channel that polls, the poller was torn down and a new one began with no memory of the old account: its first round only marks the position, so nothing is replayed. Bluesky drops its session as well.
  • The values live in memory. A process restart forgets them, which is why the plugin hands them over again at every boot.

The four verbs

Every channel has them, and they do the same thing everywhere:

CallDoes
configure({ … })Sets new credentials and starts, or restarts, on them
configure()Goes back to the environment. In runtime mode, where there is none, that means off
restart()Same credentials, fresh state. A poller starts over from now
stop()Off. The values stay, so restart() brings it back
status(){ running, source, configured }, for the settings page to show what is going on

source is 'static' for the environment, 'runtime' after a configure(), and 'none' when nothing is there. running is only ever true for Mastodon and Bluesky, the two that poll. The others have nothing that runs.

What configure() takes

The same fields the environment would, camel cased. Everything is optional, and in the default mode a field you leave out keeps its environment value.

ChannelFieldsNeeded to work
telegramtoken, chatId, secretTokentoken
discordwebhookUrlwebhookUrl
slackbotToken, channel, webhookUrl, signingSecretbotToken or webhookUrl
ntfyserver, topic, tokentopic
mastodoninstance, tokenboth
blueskyservice, identifier, passwordidentifier and password
webhookurl, secret, headers, endpoints: { name: { … } }nothing, a call can name its url

Hand over less than what is needed and configure() throws the same sentence a send would, naming the field.

Two modes, one option

credentialsAt startupconfigure()
'static', the defaultReads .env, starts if there is something thereReplaces it, warns that the channel started once for nothing
'runtime'Ignores .env, waitsStarts the channel. No comment

The warning in the default mode is the whole reason the option exists. Seeing it means one line in nuxt.config makes it go away, and until then nothing is broken: the values from configure() win either way.

In 'runtime' mode a leftover PIGEON_MASTODON_TOKEN in .env is ignored and mentioned once in the log. That is on purpose. The alternative, starting on the old account and switching a moment later, would have polled as the wrong person.

What will cost you an hour

  • A new Telegram token is a different bot. The webhook is registered per bot, so after telegram.configure({ token }) Telegram still delivers to the old one, which means to nobody. Call telegram.setWebhook(baseUrl) again.
  • Telegram and Slack receive on a route, which reads its secretToken or signingSecret per request. A configure() applies to the next delivery, there is nothing to restart. Before the first configure() in runtime mode the route answers 503, nothing is accepted on a guess.
  • Serverless has no process to keep the values in. Every isolate starts empty, and the pollers do not run there anyway. Use the environment there, that is what it is for.
  • configure() is not for switching between accounts per request. It is one set of credentials per process, replaced when they change. To post into a second Discord channel, pass webhookUrl on the call, the same way chatId works for Telegram:
    await discord.send('Release 1.2', { webhookUrl: process.env.DISCORD_RELEASES })
    

    edit and delete need it again. The url is the credential and is never kept in the handle, which is an object you pass around and log.

Read more

  • Installation - where the environment variables are read, and in what order
  • Receiving - what polls and what listens on a route