@fabrika/provider-zerops 0.4.8 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/provider-zerops",
3
- "version": "0.4.8",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,13 +43,14 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "zod": "^3.25.76",
46
- "@fabrika/auth": "0.4.8",
47
- "@fabrika/auth-core": "0.4.8",
48
- "@fabrika/iam-contract": "0.4.8",
49
- "@fabrika/installation-init": "0.4.8",
50
- "@fabrika/operations-contract": "0.4.8",
51
- "@fabrika/provider-contract": "0.4.8",
52
- "@fabrika/proxy-core": "0.4.8",
46
+ "@fabrika/auth": "0.5.1",
47
+ "@fabrika/auth-core": "0.5.1",
48
+ "@fabrika/email": "0.5.1",
49
+ "@fabrika/iam-contract": "0.5.1",
50
+ "@fabrika/installation-init": "0.5.1",
51
+ "@fabrika/operations-contract": "0.5.1",
52
+ "@fabrika/provider-contract": "0.5.1",
53
+ "@fabrika/proxy-core": "0.5.1",
53
54
  "yaml": "^2.9.1"
54
55
  }
55
56
  }
package/src/execution.ts CHANGED
@@ -275,7 +275,7 @@ const migrateScript = (appId: string): readonly string[] => [
275
275
 
276
276
  // The server starts only after the migrations it expects are applied, the committed schema types match the migrated
277
277
  // schema and every generated isolation test passed on a throwaway schema, so any failure fails the readiness check,
278
- // whether or not a failed `initCommands` stops the activation (an unverified platform fact). The build has no
278
+ // whether or not a failed `initCommands` stops the activation (it does, verified live). The build has no
279
279
  // database, so this is where the types and the generated tests run.
280
280
  const startScript = (appId: string, database: boolean): readonly string[] => [
281
281
  '#!/bin/sh',
@@ -126,6 +126,12 @@ fabrika platform install --provider=zerops --create-project --client-id=<clientI
126
126
  - A project that already holds generated secrets is refused. Do not try to force it: a second bring-up
127
127
  over the same project would destroy the vault key. Start over in a new empty project.
128
128
  - The account must be in the default region, or set `FABRIKA_ZEROPS_API_URL` for every command.
129
+ - Mail is optional. Without `--email-provider` the installation sends no mail. With one, add
130
+ `--email-provider=<resend|smartemailing> --email-from=<address>`, optionally `--email-from-name=<name>`, and
131
+ export the provider's variables (`FABRIKA_EMAIL_RESEND_API_KEY`, or `FABRIKA_EMAIL_SMARTEMAILING_USERNAME`,
132
+ `FABRIKA_EMAIL_SMARTEMAILING_API_KEY` and optionally `FABRIKA_EMAIL_SMARTEMAILING_TAG`) from their own `0600`
133
+ files. They have no flag. The install writes the set on `iam` and `control`, the credentials sensitive. They
134
+ are the human's own, so they are not copied into the secrets file.
129
135
 
130
136
  **Check.** The log ends with the `Hand off` step: where the credentials are, `The console answers at
131
137
  https://<console host>`, and an `ADMIT YOURSELF` box that holds the step 2 command with `--iam-host` filled
@@ -139,9 +145,10 @@ in. The secrets file exists with mode `0600`. The three hosts are printed earlie
139
145
  fabrika platform admin --provider=zerops --email=<address> --iam-host=<IAM host from step 1> )
140
146
  ```
141
147
 
142
- It prints one enrollment URL and the time it expires. Nothing is e-mailed: the installation has no mail
143
- service, so do not tell the human to wait for a message. The URL is a credential: give it to the human only,
144
- and do not log it. The human opens it, sets a password and signs in at the console origin.
148
+ Without a mail provider it prints one enrollment URL and the time it expires. Nothing is e-mailed then, so
149
+ do not tell the human to wait for a message. The URL is a credential: give it to the human only, and do not
150
+ log it. The human opens it, sets a password and signs in at the console origin. With a mail provider, IAM
151
+ e-mails the enrollment instead, and the command reports the address and the expiry.
145
152
 
146
153
  The URL is valid for ONE HOUR. Hand it over at once. A re-run creates nothing new: while the enrollment is
147
154
  unused it only reports that one is outstanding, and it never prints the URL again. To get a new URL, after
@@ -344,6 +351,28 @@ It re-pins the namespace and queues the reconcile that rebuilds its proxy; poll
344
351
  service variable, which Zerops caps at about 32 KB: when a deploy fails with `serviceEnvValueTooLarge`, this is
345
352
  the fix, and it also accepts a namespace in `failed`.
346
353
 
354
+ ## Set or rotate the mail transport
355
+
356
+ The installation's mail transport lives on the `iam` and `control` services. Zerops is its record (ADR-0004),
357
+ and Control hands a copy to every application whose job declares `mailer: 'installation'`. To add one to an
358
+ existing installation, change the provider, or rotate a key:
359
+
360
+ No `fabrika` command does this: `platform upgrade` and `platform deploy` write no `FABRIKA_EMAIL_*` and remove
361
+ none, and `install` cannot be re-run.
362
+
363
+ 1. Set the same `FABRIKA_EMAIL_*` variables on BOTH `iam` and `control`, in the Zerops GUI or with `zcli`:
364
+ `FABRIKA_EMAIL_PROVIDER`, `FABRIKA_EMAIL_FROM`, optionally `FABRIKA_EMAIL_FROM_NAME`, and the provider's
365
+ variables. Mark the credentials sensitive. Delete the variables of a provider you leave.
366
+ 2. Restart `iam` and `control`. Each reads the transport at boot. IAM refuses to start on an invalid one.
367
+ Control starts, logs a line that names the invalid variables, and refuses every deploy that needs the transport
368
+ until you fix them and restart it.
369
+ 3. Redeploy every environment whose contract uses `installation` mail. An application gets the new transport
370
+ only at its next deploy. Until then it sends with the old one, and a revoked key fails its mail with
371
+ `provider_unauthorized`.
372
+
373
+ Without a transport, a contract whose job declares `mailer: 'installation'` still deploys. Its jobs see that mail is
374
+ not available and leave their messages queued. After step 3 the next run sends them.
375
+
347
376
  ## When something fails
348
377
 
349
378
  - Every refusal names its flag or variable. Fix that one value and re-run. `init`, `admin`, `deploy` and
@@ -1,4 +1,5 @@
1
1
  import { reconcileSchema } from '@fabrika/auth'
2
+ import { builtinEmailProviders } from '@fabrika/email/providers'
2
3
  import {
3
4
  describeArgument,
4
5
  info as consoleInfo,
@@ -44,6 +45,9 @@ export {
44
45
  resolvePlatformProxyManifest,
45
46
  } from './proxy-manifest'
46
47
 
48
+ const EMAIL_PROVIDERS = Object.keys(builtinEmailProviders).join(', ')
49
+ const EMAIL_PROVIDER_VARIABLES = Object.values(builtinEmailProviders).flatMap((provider) => provider.variables.map((variable) => variable.name))
50
+
47
51
  const USAGE = `Zerops installation
48
52
 
49
53
  New here, or an agent? Run \`fabrika platform guide --provider=zerops\` first: the whole bring-up in order,
@@ -86,7 +90,8 @@ is not consent.
86
90
  all of them to \`--secrets-file\` (mode 0600) before the first one is placed
87
91
  4. pass 1: give the proxy an EMPTY manifest and build it, so it publishes one public hostname per
88
92
  HTTP port; the three platform hosts are then READ off those, never composed
89
- 5. write every remaining variable on all four runtime services
93
+ 5. write every remaining variable on all four runtime services; the mail transport, when one is
94
+ given, goes to \`iam\` and \`control\` only, its credentials sensitive and not in the secrets file
90
95
  6. pass 2: hand the whole ordered sequence to \`platform deploy\`
91
96
  7. print the next commands; they read their credentials from the secrets file. Zerops holds the
92
97
  same values as SENSITIVE variables and does not hand them back, so that file is the only copy
@@ -118,11 +123,19 @@ Options (a flag beats the environment variable beside it):
118
123
  --source-dir=<checkout> required clean full checkout running this CLI
119
124
  --tier=<light|standard> FABRIKA_PLATFORM_TIER default light
120
125
  --secrets-file=<path> FABRIKA_PLATFORM_SECRETS_FILE required: a NEW file outside the checkout
126
+ --email-provider=<name> FABRIKA_EMAIL_PROVIDER default none; or one of: ${EMAIL_PROVIDERS}
127
+ --email-from=<address> FABRIKA_EMAIL_FROM the sender, required with a provider
128
+ --email-from-name=<name> FABRIKA_EMAIL_FROM_NAME the sender's display name, optional
121
129
  ${UNATTENDED_FLAG} answer every confirmation yes —
122
130
  required when stdin is not a terminal
123
131
 
124
132
  FABRIKA_ZEROPS_ACCESS_TOKEN required Zerops access token, environment only and no flag
125
133
  FABRIKA_ZEROPS_API_URL optional region API base, when not the default
134
+ FABRIKA_EMAIL_<PROVIDER>_* the selected provider's variables, environment only and no flag:
135
+ ${EMAIL_PROVIDER_VARIABLES.join(', ')}
136
+
137
+ With a mail provider, IAM sends its own mail and Control hands the same transport to every application
138
+ whose job declares \`mailer: 'installation'\`. To set or rotate it later, see \`platform guide\`.
126
139
 
127
140
  ── platform init ─────────────────────────────────────────────────────────────────────────────────
128
141
 
@@ -266,8 +279,9 @@ Access plane refuses. A re-run creates nobody twice, grants nothing twice and is
266
279
  enrollment — a password that is already set, or an enrollment already outstanding, is REPORTED. Pass
267
280
  \`${REISSUE_FLAG}\` to replace an outstanding one, which is the way out when the first expired unopened.
268
281
 
269
- Nothing is e-mailed: a Zerops installation has no mail service. Hand the URL to the administrator
270
- yourself. It is a credential: it is printed once, on a line of its own, and stored nowhere.
282
+ Unless \`install\` was given a mail provider, nothing is e-mailed: IAM then has no mail service. Hand the
283
+ URL to the administrator yourself. It is a credential: it is printed once, on a line of its own, and
284
+ stored nowhere. With a mail provider, IAM e-mails the enrollment and the URL is not printed.
271
285
 
272
286
  \`install\` reports the host this needs as \`✓ iam <host>\` and ends with this command ready to run.
273
287
 
@@ -1,3 +1,4 @@
1
+ import type { InstallationEmail } from '@fabrika/installation-init'
1
2
  import { encodeProxyManifestJson } from '@fabrika/proxy-core/manifest'
2
3
  import type { ZeropsNamespaceProxySource } from '../index'
3
4
  import type { PlatformDeployService, ResolvedService } from './deploy'
@@ -64,6 +65,8 @@ export interface InstallationEnvInput {
64
65
  readonly secrets: InstallationSecrets
65
66
  /** The Zerops integration token minted for the control plane. Never logged, never printed. */
66
67
  readonly zeropsAccessToken: string
68
+ /** IAM sends its own mail with it, and Control hands it to applications whose job declares `mailer: 'installation'`. */
69
+ readonly email: InstallationEmail
67
70
  }
68
71
 
69
72
  /**
@@ -120,6 +123,8 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
120
123
  // the CLI reads `FABRIKA_ZEROPS_API_URL`; they are different processes reading different sources.
121
124
  control.set('FABRIKA_ZEROPS_API_BASE_URL', input.apiBaseUrl)
122
125
  }
126
+ const email = input.email.kind === 'none' ? [] : [...input.email.variables]
127
+ for (const [key, value] of email) control.set(key, value)
123
128
  return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
124
129
  [
125
130
  'iam',
@@ -136,6 +141,7 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
136
141
  ['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
137
142
  ['FABRIKA_IAM_PROXY_KEY', secrets.proxyKey],
138
143
  ['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
144
+ ...email,
139
145
  ]),
140
146
  ],
141
147
  [
@@ -196,19 +202,19 @@ export const GENERATED_SECRET_KEYS: ReadonlyMap<PlatformDeployService, readonly
196
202
  * Write a set of variables, reporting the KEYS — never a value, not even in a debug line.
197
203
  *
198
204
  * Write-first and unconditional: a generated secret cannot be read back to compare, and `PUT
199
- * /user-data/{id}` replaces in place, so a repeat costs a call and nothing else. Exactly the generated
200
- * secrets are written sensitive; everything else stays readable, because `platform deploy` compares
201
- * and merges it later.
205
+ * /user-data/{id}` replaces in place, so a repeat costs a call and nothing else. Exactly the `sensitive`
206
+ * keys are written sensitive (the generated secrets and the operator's mail credentials); everything else
207
+ * stays readable, because `platform deploy` compares and merges it later.
202
208
  */
203
209
  export const writeServiceEnv = async (
204
210
  service: ResolvedService,
205
211
  desired: ReadonlyMap<string, string>,
206
212
  { api, log, signal }: InstallCollaborators,
213
+ sensitive: ReadonlySet<string>,
207
214
  ): Promise<void> => {
208
- const secrets = GENERATED_SECRET_KEYS.get(service.hostname) ?? []
209
215
  for (const [key, value] of desired) {
210
216
  assertRunning(signal)
211
- await api.putServiceEnv({ serviceId: service.id, key, value, sensitive: secrets.includes(key), signal })
217
+ await api.putServiceEnv({ serviceId: service.id, key, value, sensitive: sensitive.has(key), signal })
212
218
  }
213
219
  log.ok(`${service.hostname}: wrote ${[...desired.keys()].join(', ')}`)
214
220
  }
@@ -8,7 +8,18 @@
8
8
  // operator's disk — but nothing here prompts: a missing required value is an error naming both the flag
9
9
  // and the variable that would supply it, so a mistyped install fails before it contacts anything.
10
10
 
11
- import { describeArgument, readEnv, readFlag, requiredSetting, setting, UNATTENDED_FLAG } from '@fabrika/installation-init'
11
+ import { builtinEmailProviders } from '@fabrika/email/providers'
12
+ import {
13
+ describeArgument,
14
+ EMAIL_FLAGS,
15
+ type InstallationEmail,
16
+ readEnv,
17
+ readFlag,
18
+ readInstallationEmail,
19
+ requiredSetting,
20
+ setting,
21
+ UNATTENDED_FLAG,
22
+ } from '@fabrika/installation-init'
12
23
  import { checkedSecretsFilePath, SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE } from './install-secrets'
13
24
  import { parsePlatformScheme } from './placement-options'
14
25
  import { readInstallationSourceDir } from './source-options'
@@ -47,6 +58,8 @@ export interface PlatformInstallInput {
47
58
  /** Where every credential this run creates is written, mode 0600, before the first is placed. */
48
59
  readonly secretsFile: string
49
60
  readonly tier: PlatformTier
61
+ /** The installation's mail transport, written on `iam` and `control`; `none` when the operator named no provider. */
62
+ readonly email: InstallationEmail
50
63
  /** `--yes`: answer every confirmation yes, for a bring-up with nobody at the keyboard. */
51
64
  readonly unattended: boolean
52
65
  }
@@ -54,7 +67,17 @@ export interface PlatformInstallInput {
54
67
  /** The canonical public release repository used for namespace proxy source identities. */
55
68
  export const FABRIKA_REPOSITORY_URL = 'https://github.com/contember/fabrika-platform'
56
69
 
57
- const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier', SECRETS_FILE_FLAG] as const
70
+ const FLAGS = [
71
+ '--project-id',
72
+ '--project-name',
73
+ '--client-id',
74
+ '--env',
75
+ '--scheme',
76
+ '--source-dir',
77
+ '--tier',
78
+ SECRETS_FILE_FLAG,
79
+ ...EMAIL_FLAGS,
80
+ ] as const
58
81
 
59
82
  /** `--create-project`: bring the project up too, instead of naming one the operator made. */
60
83
  export const CREATE_PROJECT_FLAG = '--create-project'
@@ -151,6 +174,8 @@ export const parsePlatformInstallArgs = (
151
174
  sourceDir,
152
175
  ),
153
176
  tier: readTier(setting(argv, env, '--tier', 'FABRIKA_PLATFORM_TIER')),
177
+ // Optional, so an existing unattended install keeps working and installs without mail.
178
+ email: readInstallationEmail(argv, env, { registry: builtinEmailProviders, required: false }),
154
179
  unattended: argv.includes(UNATTENDED_FLAG),
155
180
  ...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
156
181
  }
@@ -27,7 +27,7 @@ import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-man
27
27
  import { derivePlatformHosts, type PlatformHosts, platformOrigin, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
28
28
  import { checkedEnvironmentName } from './init-options'
29
29
  import { assertRunning, type InstallCollaborators, type InstallPrompts } from './install-collaborators'
30
- import { installationServiceEnv, PASS_ONE_PROXY_MANIFEST, passOneProxyEnv, writeServiceEnv } from './install-environment'
30
+ import { GENERATED_SECRET_KEYS, installationServiceEnv, PASS_ONE_PROXY_MANIFEST, passOneProxyEnv, writeServiceEnv } from './install-environment'
31
31
  import type { PlatformInstallInput } from './install-options'
32
32
  import { resolveInstallationProject } from './install-project'
33
33
  import {
@@ -67,7 +67,7 @@ const passOne = async (
67
67
  if (!(await prompts.confirm(`Write 3 variables on \`proxy\` and build commit ${source.commitSha}? (about 4 minutes)`, true))) {
68
68
  throw new Error('declined: no variable was written, and re-running the install is safe')
69
69
  }
70
- await writeServiceEnv(proxy, passOneProxyEnv(secrets), collaborators)
70
+ await writeServiceEnv(proxy, passOneProxyEnv(secrets), collaborators, new Set(GENERATED_SECRET_KEYS.get(proxy.hostname)))
71
71
  await deployPlatformService(proxy, { source, dryRun: false, proxyManifest: PASS_ONE_PROXY_MANIFEST }, collaborators)
72
72
 
73
73
  const proxyEnv = await readServiceEnv(proxy.id, collaborators)
@@ -177,6 +177,7 @@ const installPreparedPlatform = async (
177
177
  proxySource,
178
178
  secrets,
179
179
  zeropsAccessToken: token.token,
180
+ email: input.email,
180
181
  ...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
181
182
  })
182
183
  const total = [...desired.values()].reduce((sum, keys) => sum + keys.size, 0)
@@ -187,8 +188,11 @@ const installPreparedPlatform = async (
187
188
  + `Keep ${input.secretsFile} and arrange operator-owned recovery, or start a fresh installation in a separate empty project`,
188
189
  )
189
190
  }
191
+ // The mail credentials are the operator's own, so they neither mark an installation nor go to the secrets file.
192
+ const emailSecrets = input.email.kind === 'none' ? [] : [...input.email.secretNames]
190
193
  for (const hostname of PLATFORM_DEPLOY_ORDER) {
191
- await writeServiceEnv(serviceOf(services, hostname), desired.get(hostname) ?? new Map<string, string>(), collaborators)
194
+ const sensitive = new Set([...(GENERATED_SECRET_KEYS.get(hostname) ?? []), ...emailSecrets])
195
+ await writeServiceEnv(serviceOf(services, hostname), desired.get(hostname) ?? new Map<string, string>(), collaborators, sensitive)
192
196
  }
193
197
 
194
198
  log.step('Pass 2 — deploy the installation')
@@ -167,13 +167,15 @@ const WORKSPACE_DEPLOY_FILES = ['package.json', 'bun.lock', 'node_modules', 'pac
167
167
  * `ENVIRONMENT`, `ISSUER` (this service's public origin; it is the `iss` of every minted token AND the
168
168
  * OIDC redirect base, so it must match the domain routed to the proxy in front of it),
169
169
  * `HUMAN_EMAIL_DOMAINS`, `HUMAN_EMAILS`, `IAM_BOOTSTRAP_ADMINS`,
170
- * `FABRIKA_IAM_OIDC_ENABLED`, `FABRIKA_IAM_PASSWORD_ENABLED`, `FABRIKA_EMAIL_PROVIDER`, and
171
- * `FABRIKA_EMAIL_FROM` when email delivery is enabled. OIDC-enabled installations also set
172
- * `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_SCOPES`, `OIDC_REQUIRE_VERIFIED_EMAIL`.
170
+ * `FABRIKA_IAM_OIDC_ENABLED`, `FABRIKA_IAM_PASSWORD_ENABLED`, and the mail transport when email delivery is
171
+ * enabled: `platform install --email-provider` writes `FABRIKA_EMAIL_PROVIDER`, `FABRIKA_EMAIL_FROM`,
172
+ * `FABRIKA_EMAIL_FROM_NAME` and the provider's variables on `iam` and `control`, the provider's secret ones
173
+ * sensitive. OIDC-enabled installations also set `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_SCOPES`,
174
+ * `OIDC_REQUIRE_VERIFIED_EMAIL`.
173
175
  *
174
176
  * Secrets (`envSecrets`): `FABRIKA_IAM_SIGNING_KEYS` (ES256 private JWKs — an empty value is refused at
175
177
  * boot rather than silently issuing ephemeral tokens), `OIDC_CLIENT_SECRET` when OIDC is enabled,
176
- * `FABRIKA_EMAIL_RESEND_API_KEY` when Resend is enabled, `FABRIKA_IAM_RPC_KEY` (authenticates
178
+ * `FABRIKA_IAM_RPC_KEY` (authenticates
177
179
  * the management `/rpc/*` surface; unset 404s it, which is the fail-closed default), `FABRIKA_IAM_PROXY_KEY`
178
180
  * (authenticates `/auth/mint/*`, which the proxy calls on the cold path — a SEPARATE secret by least privilege,
179
181
  * since the proxy is the only publicly routed component and must not also be able to write audit rows),
@@ -4,6 +4,7 @@ import {
4
4
  frameworkVariableReference,
5
5
  isConnectKeyVariable,
6
6
  isConnectUrlVariable,
7
+ isEmailTransportVariable,
7
8
  isFrameworkVariableName,
8
9
  isWorkloadKeyVariable,
9
10
  } from '@fabrika/auth-core'
@@ -67,7 +68,7 @@ export const SENSITIVE_MANAGED_KEYS: ReadonlySet<ManagedKey> = new Set<ManagedKe
67
68
  'FABRIKA_IAM_RPC_KEY',
68
69
  ])
69
70
 
70
- /** A workload or connect key is a credential; a connect URL is an origin. */
71
+ /** A workload or connect key and every mail transport variable are written sensitive; a connect URL is an origin. */
71
72
  export const isSensitiveFamilyVariable = (name: string): boolean => !isConnectUrlVariable(name)
72
73
 
73
74
  export type ManagedValues = Record<ManagedKey, string | null>
@@ -93,16 +94,20 @@ export function parseManagedValues(value: unknown): ManagedValues {
93
94
  }
94
95
 
95
96
  /**
96
- * What Control hands a deploy: the ten managed keys, the key of each job and webhook of the approved contract, and the
97
- * key and owner origin of each connector import.
97
+ * What Control hands a deploy: the ten managed keys, the key of each job and webhook of the approved contract, the
98
+ * key and owner origin of each connector import, and the installation's mail transport when a job sends through it.
98
99
  */
99
100
  export interface ManagedEnvironment {
100
101
  readonly values: ManagedValues
101
- /** `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, and `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`. */
102
+ /**
103
+ * `FABRIKA_<KIND>_<NAME>_KEY` → the workload's `px_` key, `FABRIKA_CONNECT_<IMPORT>_KEY` / `_URL`, and each
104
+ * `FABRIKA_EMAIL_*` of the installation's mail transport.
105
+ */
102
106
  readonly familyVariables: Readonly<Record<string, string>>
103
107
  }
104
108
 
105
109
  const keySchema = z.string().startsWith('px_').min(4)
110
+ const mailValueSchema = z.string().min(1)
106
111
  const originSchema = z.string().refine((value) => {
107
112
  try {
108
113
  return new URL(value).origin === value && value.startsWith('https://')
@@ -119,6 +124,12 @@ export function parseManagedEnvironment(value: Readonly<Record<string, string |
119
124
  managed[name] = entry
120
125
  continue
121
126
  }
127
+ if (isEmailTransportVariable(name)) {
128
+ const parsed = mailValueSchema.safeParse(entry)
129
+ if (!parsed.success) throw new Error(`zerops: ${name} must not be empty`)
130
+ familyVariables[name] = parsed.data
131
+ continue
132
+ }
122
133
  const origin = isConnectUrlVariable(name)
123
134
  if (!origin && !isWorkloadKeyVariable(name) && !isConnectKeyVariable(name)) {
124
135
  throw new Error(`zerops: ${name} is not a variable a deploy writes`)
@@ -132,7 +143,10 @@ export function parseManagedEnvironment(value: Readonly<Record<string, string |
132
143
  return { values: parseManagedValues(managed), familyVariables }
133
144
  }
134
145
 
135
- /** A service variable only the platform writes per workload or import: a stale one is removed with it. */
146
+ /**
147
+ * A service variable only the platform writes per workload, import or the installation's mail transport: a stale one is
148
+ * removed when the deploy no longer hands it.
149
+ */
136
150
  export function isFrameworkFamilyVariable(name: string): boolean {
137
151
  return FRAMEWORK_VARIABLE_PREFIXES.some((prefix) => name.startsWith(prefix))
138
152
  }
package/src/provider.ts CHANGED
@@ -596,7 +596,7 @@ const writeServiceEnv = async (env: StepEnv): Promise<void> => {
596
596
  log(` [dry-run] would write service variables on ${serviceId}: ${written.join(', ') || '(none)'}`)
597
597
  log(` [dry-run] would remove service variables on ${serviceId} if present: ${removed.join(', ') || '(none)'}`)
598
598
  if (Object.keys(familyVariables).length > 0) {
599
- log(` [dry-run] would remove every other job, webhook and connector variable on ${serviceId}`)
599
+ log(` [dry-run] would remove every other job, webhook, connector and mail variable on ${serviceId}`)
600
600
  }
601
601
  return
602
602
  }