@timekast/factory 2.2.0 → 2.3.0

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.
@@ -63,7 +63,8 @@ export const E2E_DATABASE_URL_SECRET = 'DATABASE_URL';
63
63
  * `${MAIL_SUBDOMAIN}.<base>` — NEVER the apex `<base>`, which holds the org's (or a
64
64
  * client's) corporate MX/SPF (Google Workspace, M365, …). Verifying Resend on the
65
65
  * apex would collide with that mail; an isolated subdomain keeps both working. Same
66
- * convention for the org (`updates.timekast.mx`) and every client (`updates.<client>`).
66
+ * convention for the org (`updates.timekast.com`) and, with `--client-mail`, a client
67
+ * (`updates.<client>`).
67
68
  */
68
69
  export const MAIL_SUBDOMAIN = 'updates';
69
70
  /** Build the transactional-mail domain for a base domain: `updates.<base>`. */
@@ -72,10 +73,11 @@ export function mailDomainFor(base) {
72
73
  }
73
74
  /**
74
75
  * The canonical, ORG-LEVEL sender for transactional mail on provisioned stacks
75
- * (issue §4 + §5). Sends from `noreply@updates.timekast.mx` — the dedicated subdomain
76
- * verified in Resend, NOT the apex `timekast.mx` (which is on Google Workspace). The
77
- * client's own sender is set in the `--domain` flow (PROV-006), using the same
78
- * `updates.<client>` convention. The unit test pins this value.
76
+ * (issue §4 + §5). Sends from `noreply@updates.timekast.com` — the dedicated subdomain
77
+ * verified in Resend, NOT the apex (which is on Google Workspace). It stays the sender
78
+ * after the `--domain` go-live (PROV-006): the Resend account has no room for a domain
79
+ * per client, so the client's own `updates.<client>` is opt-in (`--client-mail`). The
80
+ * unit test pins this value.
79
81
  */
80
82
  export const PROVISION_EMAIL_FROM = `noreply@${mailDomainFor(TIMEKAST_ZONE_NAME)}`;
81
83
  // `provisionDomains` moved to `cloudflare-api.ts` (next to TIMEKAST_ZONE_NAME) so
@@ -250,6 +252,11 @@ export function parseProvisionFlags(argv) {
250
252
  throw new CLIError('`--no-vault` y `--services=…vault…` se contradicen: uno pide omitir la bóveda y el otro ' +
251
253
  'crearla. Quita uno de los dos.');
252
254
  }
255
+ const clientMail = argv.includes('--client-mail');
256
+ const domain = readValueFlag(argv, 'domain');
257
+ if (clientMail && !domain) {
258
+ throw new CLIError('`--client-mail` solo aplica al go-live: pásalo junto con `--domain=<dominio del cliente>`.');
259
+ }
253
260
  return {
254
261
  services,
255
262
  noVault,
@@ -259,7 +266,8 @@ export function parseProvisionFlags(argv) {
259
266
  remint: argv.includes('--remint'),
260
267
  slug: readValueFlag(argv, 'slug'),
261
268
  repo: parseRepoFlag(readValueFlag(argv, 'repo')),
262
- domain: readValueFlag(argv, 'domain'),
269
+ domain,
270
+ clientMail,
263
271
  dns: parseDnsRoute(readValueFlag(argv, 'dns')),
264
272
  verifyTimeout: parseVerifyTimeout(readValueFlag(argv, 'verify-timeout')),
265
273
  confirm: argv.includes('--confirm'),
@@ -1923,12 +1931,15 @@ const RESEND_KEY_RESUME_HINT = 'repite la corrida con `--resume` y `dns` en `--s
1923
1931
  'hasta publicarla';
1924
1932
  /**
1925
1933
  * NO-vault: the sending domain a fresh project key is limited to — the client's mail subdomain
1926
- * once its go-live is recorded (Resend already knows it: the go-live registers it), else the
1927
- * interim org domain. A `--resume` that retries the key after a go-live must not move the mail
1928
- * back to the interim domain.
1929
- */
1930
- function noVaultMintDomain(state) {
1931
- const client = state.dns?.clientDomainStatus === 'live' ? state.dns.clientDomain : undefined;
1934
+ * once a `--client-mail` go-live is recorded (Resend already knows it: that go-live registers
1935
+ * it), else the org domain. A `--resume` that retries the key after such a go-live must not move
1936
+ * the mail back to the org domain. `sharedMail` marks a go-live that kept the org sender; a
1937
+ * `live` state without it predates the flag, when every go-live moved the mail.
1938
+ */
1939
+ export function noVaultMintDomain(state) {
1940
+ const client = state.dns?.clientDomainStatus === 'live' && state.dns.sharedMail !== true
1941
+ ? state.dns.clientDomain
1942
+ : undefined;
1932
1943
  return client ? mailDomainFor(client) : sendingDomainOf(PROVISION_EMAIL_FROM);
1933
1944
  }
1934
1945
  /**
@@ -4711,8 +4722,9 @@ async function resolveDnsToken(route, rail, headless, rootDir) {
4711
4722
  /**
4712
4723
  * PROV-006 — attach the client's DEFINITIVE domain to the Vercel project, create
4713
4724
  * (or print, for `registrar`) its DNS record, poll Vercel until verified, and on
4714
- * success: retire the interim prod domain (with explicit confirmation), update
4715
- * `EMAIL_FROM` + `NEXT_PUBLIC_APP_URL`, and register the domain in Resend.
4725
+ * success: retire the interim prod domain (with explicit confirmation) and update
4726
+ * `NEXT_PUBLIC_APP_URL`. The mail stays on the org sender unless `deps.clientMail`
4727
+ * (`--client-mail`): then `EMAIL_FROM` moves to `updates.<domain>`, registered in Resend.
4716
4728
  *
4717
4729
  * Standalone-safe: runnable as a PATCH over an existing provision (the `dns` step
4718
4730
  * of PROV-005 already attached the interim domains) or inside the initial run. It
@@ -4745,6 +4757,7 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
4745
4757
  'Corre el paso de Vercel/GitHub primero (`--services=github,...`) y reintenta con `--resume`.');
4746
4758
  }
4747
4759
  const route = deps.dnsRoute ?? 'org';
4760
+ const clientMail = deps.clientMail === true;
4748
4761
  const rail = deps.rail ?? defaultRail();
4749
4762
  const fetchImpl = deps.fetchImpl ?? fetch;
4750
4763
  const vercelToken = await readVercelToken(rail);
@@ -4823,27 +4836,32 @@ export async function provisionClientDomain(rootDir, state, teamId, domain, deps
4823
4836
  // 5. Update EMAIL_FROM + NEXT_PUBLIC_APP_URL + the WebAuthn rpID/related origins in Vercel env
4824
4837
  // (idempotent — §8), and in the vault's `main:/` when the repo has one (VAULT-002).
4825
4838
  const hosts = existingProvisionDomains(state.projectName, state.dns);
4826
- await updateDomainEnv(projectId, vercelToken, teamId, domain, vercelDeps, { dev: hosts.dev, interim: hosts.prod, interimRetired }, deps.vault ?? null);
4827
- // 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
4828
- // apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
4829
- // Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
4830
- // BOOT-003: also CREATES the DKIM/SPF/MX records Resend returns (in the same
4831
- // client zone as the CNAME) and polls until Resend reports `verified`.
4832
- await registerResendDomain(mailDomainFor(domain), rail, deps, fetchImpl, resendDns);
4833
- // 6b. VAULT-002 — the project's Resend key is limited to its sending domain, which just
4834
- // changed: re-scope it to `updates.<domain>` (vault, when there is one, + deploys) and
4835
- // revoke the old one once the new one reached them. Without a vault too: the deploys carry
4836
- // the project's key there as well (never the rail's team key).
4837
- await rescopeProjectResendKey(rootDir, state, deps.vault ?? null, mailDomainFor(domain), { projectId, token: vercelToken, teamId, deps: vercelDeps }, rail, fetchImpl, { sleep: deps.sleep, now: deps.now });
4839
+ await updateDomainEnv(projectId, vercelToken, teamId, domain, vercelDeps, { dev: hosts.dev, interim: hosts.prod, interimRetired }, deps.vault ?? null, clientMail);
4840
+ if (clientMail) {
4841
+ // 6. Register the client's MAIL SUBDOMAIN (`updates.<domain>`) in Resend — NOT the
4842
+ // apex, which holds the client's corporate MX/SPF (Google Workspace, M365, …).
4843
+ // Verifying Resend on a dedicated subdomain keeps the client's own mail intact.
4844
+ // BOOT-003: also CREATES the DKIM/SPF/MX records Resend returns (in the same
4845
+ // client zone as the CNAME) and polls until Resend reports `verified`.
4846
+ await registerResendDomain(mailDomainFor(domain), rail, deps, fetchImpl, resendDns);
4847
+ // 6b. VAULT-002 — the project's Resend key is limited to its sending domain, which just
4848
+ // changed: re-scope it to `updates.<domain>` (vault, when there is one, + deploys) and
4849
+ // revoke the old one once the new one reached them. Without a vault too: the deploys
4850
+ // carry the project's key there as well (never the rail's team key).
4851
+ await rescopeProjectResendKey(rootDir, state, deps.vault ?? null, mailDomainFor(domain), { projectId, token: vercelToken, teamId, deps: vercelDeps }, rail, fetchImpl, { sleep: deps.sleep, now: deps.now });
4852
+ }
4853
+ else {
4854
+ logSharedMail();
4855
+ }
4838
4856
  // 7. Record the client domain as LIVE and persist.
4839
- state.dns = {
4857
+ state.dns = withMailChoice({
4840
4858
  ...(state.dns ?? {
4841
4859
  domain: provisionDomains(state.projectName).prod,
4842
4860
  devDomain: provisionDomains(state.projectName).dev,
4843
4861
  }),
4844
4862
  clientDomain: domain,
4845
4863
  clientDomainStatus: 'live',
4846
- };
4864
+ }, clientMail);
4847
4865
  writeProvisionState(rootDir, state);
4848
4866
  }
4849
4867
  /** RWAY-005 — the two records the owner creates by hand for `--dns=registrar` on Railway. */
@@ -4890,8 +4908,9 @@ async function pollRailwayDomainVerification(client, scope, dns, deps) {
4890
4908
  * only PRINTS both.
4891
4909
  * 3. Poll the domain's state in Railway (`--verify-timeout`); timeout → `pending-verification`.
4892
4910
  * 4. Retire the interim `{slug}` from Railway with the same confirmation (`--confirm` headless).
4893
- * 5. `EMAIL_FROM`, `NEXT_PUBLIC_APP_URL`, `WEBAUTHN_RP_ID`/`_RELATED_ORIGINS` → `main:/` of the
4894
- * vault ONLY (its sync is Railway's only writer); Resend as on Vercel.
4911
+ * 5. `NEXT_PUBLIC_APP_URL`, `WEBAUTHN_RP_ID`/`_RELATED_ORIGINS` (+ `EMAIL_FROM` with
4912
+ * `--client-mail`) → `main:/` of the vault ONLY (its sync is Railway's only writer); Resend
4913
+ * as on Vercel (only with `--client-mail`).
4895
4914
  * 🔴 No request to Vercel. Requires the vault and the `railway` block (none → `--adopt`).
4896
4915
  */
4897
4916
  async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
@@ -4901,6 +4920,7 @@ async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
4901
4920
  assertRailwayDomainIds(state);
4902
4921
  const rw = state.railway;
4903
4922
  const route = deps.dnsRoute ?? 'org';
4923
+ const clientMail = deps.clientMail === true;
4904
4924
  const rail = deps.rail ?? defaultRail();
4905
4925
  const fetchImpl = deps.fetchImpl ?? fetch;
4906
4926
  const client = railwayClientFor(rail, deps.railway);
@@ -4987,15 +5007,21 @@ async function provisionRailwayClientDomain(rootDir, state, domain, deps) {
4987
5007
  dev: hosts.dev,
4988
5008
  interim: hosts.prod,
4989
5009
  interimRetired,
4990
- });
4991
- // 6. Resend — unchanged by the destination (mail subdomain + the project's key re-scoped).
4992
- await registerResendDomain(mailDomainFor(domain), rail, paced, fetchImpl, resendDns);
4993
- await rescopeProjectResendKey(rootDir, state, vault, mailDomainFor(domain), null, rail, fetchImpl, {
4994
- sleep: paced.sleep,
4995
- now: paced.now,
4996
- });
5010
+ }, clientMail);
5011
+ // 6. Resend — unchanged by the destination (mail subdomain + the project's key re-scoped),
5012
+ // and only with `--client-mail`.
5013
+ if (clientMail) {
5014
+ await registerResendDomain(mailDomainFor(domain), rail, paced, fetchImpl, resendDns);
5015
+ await rescopeProjectResendKey(rootDir, state, vault, mailDomainFor(domain), null, rail, fetchImpl, {
5016
+ sleep: paced.sleep,
5017
+ now: paced.now,
5018
+ });
5019
+ }
5020
+ else {
5021
+ logSharedMail();
5022
+ }
4997
5023
  // 7. Live.
4998
- state.dns = { ...dnsBlock(), clientDomainStatus: 'live' };
5024
+ state.dns = withMailChoice({ ...dnsBlock(), clientDomainStatus: 'live' }, clientMail);
4999
5025
  writeProvisionState(rootDir, state);
5000
5026
  }
5001
5027
  /**
@@ -5125,12 +5151,15 @@ async function deleteInterimCname(interim, rail, fetchImpl) {
5125
5151
  *
5126
5152
  * No-echo: only KEY NAMES + targets print (the values here are public, but the discipline holds).
5127
5153
  */
5128
- export async function updateDomainEnv(projectId, token, teamId, domain, deps, hosts, vault = null) {
5154
+ export async function updateDomainEnv(projectId, token, teamId, domain, deps, hosts, vault = null, clientMail = false) {
5129
5155
  const { appOrigin, emailFrom, relatedOrigins } = domainEnvValues(domain, hosts);
5130
5156
  const plan = [
5131
- // EMAIL_FROM uses the dedicated mail subdomain (`updates.<domain>`), NOT the apex
5132
- // — the apex carries the client's corporate mail.
5133
- { key: 'EMAIL_FROM', value: emailFrom, target: ['production', 'preview'] },
5157
+ // EMAIL_FROM only with `--client-mail`: the dedicated mail subdomain (`updates.<domain>`),
5158
+ // NOT the apex — the apex carries the client's corporate mail. Without it the sender
5159
+ // already in place (the org's, or one a team set by hand) is left alone.
5160
+ ...(clientMail
5161
+ ? [{ key: 'EMAIL_FROM', value: emailFrom, target: ['production', 'preview'] }]
5162
+ : []),
5134
5163
  // 🔴 AUTH_URL / NEXTAUTH_URL are DELIBERATELY ABSENT — do not re-add them.
5135
5164
  // `next-auth/lib/env.js` rewrites the origin of every auth request to their value,
5136
5165
  // so scoping either to `preview` pins all branch deployments' callbacks to this one
@@ -5169,7 +5198,7 @@ export async function updateDomainEnv(projectId, token, teamId, domain, deps, ho
5169
5198
  }
5170
5199
  }
5171
5200
  if (vault) {
5172
- await writeDomainEnvToVault(vault, domain, hosts);
5201
+ await writeDomainEnvToVault(vault, domain, hosts, clientMail);
5173
5202
  }
5174
5203
  else {
5175
5204
  warnRpIdMoved(domain);
@@ -5191,10 +5220,10 @@ function domainEnvValues(domain, hosts) {
5191
5220
  * reference for the shared ones; the syncs deliver them to production (Vercel or Railway, SYNC-001).
5192
5221
  * `NEXT_PUBLIC_APP_URL` goes to `main:/` only: `develop:/` keeps develop's origin.
5193
5222
  */
5194
- async function writeDomainEnvToVault(vault, domain, hosts) {
5223
+ async function writeDomainEnvToVault(vault, domain, hosts, clientMail) {
5195
5224
  const { appOrigin, emailFrom, relatedOrigins } = domainEnvValues(domain, hosts);
5196
5225
  await vaultWrite(vault, VAULT_MAIN_ROOT, {
5197
- EMAIL_FROM: emailFrom,
5226
+ ...(clientMail ? { EMAIL_FROM: emailFrom } : {}),
5198
5227
  NEXT_PUBLIC_APP_URL: appOrigin,
5199
5228
  WEBAUTHN_RP_ID: domain,
5200
5229
  WEBAUTHN_RELATED_ORIGINS: relatedOrigins,
@@ -5211,9 +5240,22 @@ function warnRpIdMoved(domain) {
5211
5240
  'bajo el rpID interino queda invalidada — esos usuarios deben registrar una nueva ' +
5212
5241
  'passkey en el dominio definitivo.');
5213
5242
  }
5243
+ /** The go-live without `--client-mail`: say where the mail stays and how to move it. */
5244
+ function logSharedMail() {
5245
+ console.log(` ⏭ correo: se queda en ${sendingDomainOf(PROVISION_EMAIL_FROM)} ` +
5246
+ '(usa `--client-mail` para enviar desde el dominio del cliente)');
5247
+ }
5248
+ /**
5249
+ * The go-live's mail choice in the state: `sharedMail: true` when the mail stayed on the org
5250
+ * sender, absent when it moved to the client ({@link noVaultMintDomain} reads it).
5251
+ */
5252
+ function withMailChoice(dns, clientMail) {
5253
+ const { sharedMail: _previous, ...rest } = dns;
5254
+ return clientMail ? rest : { ...rest, sharedMail: true };
5255
+ }
5214
5256
  /**
5215
- * VAULT-002 — re-scope the project's Resend key after the go-live: the key is limited to its
5216
- * sending domain, and `EMAIL_FROM` just moved to `updates.<client>`. Mints a new send-only key
5257
+ * VAULT-002 — re-scope the project's Resend key after a `--client-mail` go-live: the key is
5258
+ * limited to its sending domain, and `EMAIL_FROM` just moved to `updates.<client>`. Mints a new send-only key
5217
5259
  * for the new domain, upserts it into `main:/` (the one copy the other environments reference;
5218
5260
  * skipped without a vault) and into the deploys, records its id, and revokes the previous key.
5219
5261
  *
@@ -5399,7 +5441,7 @@ function goLiveRetryHint(mailDomain) {
5399
5441
  * (PROV-006 §8): the exact domain, the DNS route, whether the CNAME is created or
5400
5442
  * printed, the interim-retire intent, and the env updates — mutating NOTHING.
5401
5443
  */
5402
- function printClientDomainPlan(domain, interim, route, confirm, target) {
5444
+ function printClientDomainPlan(domain, interim, route, confirm, target, clientMail = false) {
5403
5445
  console.log(`\nPlan de \`--domain=${domain}\` (ruta DNS: ${route}):`);
5404
5446
  if (target === 'railway') {
5405
5447
  // RWAY-005: the CNAME's destination and the TXT are what Railway returns for the domain.
@@ -5426,15 +5468,22 @@ function printClientDomainPlan(domain, interim, route, confirm, target) {
5426
5468
  }
5427
5469
  console.log(` ${confirm ? '-' : '? (requiere --confirm o prompt)'} retira el interino ${interim} ` +
5428
5470
  `(el dev ${domain}-dev se conserva)`);
5429
- console.log(` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · NEXT_PUBLIC_APP_URL=https://${domain}`);
5471
+ console.log(clientMail
5472
+ ? ` ↻ EMAIL_FROM=noreply@${mailDomainFor(domain)} · NEXT_PUBLIC_APP_URL=https://${domain}`
5473
+ : ` ↻ NEXT_PUBLIC_APP_URL=https://${domain}`);
5430
5474
  console.log(` ↻ WEBAUTHN_RP_ID=${domain} (production y preview — las passkeys del rpID interino quedan invalidadas) · ` +
5431
5475
  `WEBAUTHN_RELATED_ORIGINS = origen de develop${confirm ? '' : ' (+ el interino si se conserva)'}`);
5432
- console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
5433
- if (route === 'registrar') {
5434
- console.log(' · (registrar) los records DNS de Resend (DKIM/SPF/MX) se crean a mano en el dashboard de Resend');
5476
+ if (clientMail) {
5477
+ console.log(` + Resend: POST /domains { name: ${mailDomainFor(domain)} } (subdominio mail, no el apex)`);
5478
+ if (route === 'registrar') {
5479
+ console.log(' · (registrar) los records DNS de Resend (DKIM/SPF/MX) se crean a mano en el dashboard de Resend');
5480
+ }
5481
+ else {
5482
+ console.log(` + records DNS de Resend (DKIM/SPF/MX, los que devuelva) en la zona ${apexZoneOf(domain)} + sondeo hasta verified`);
5483
+ }
5435
5484
  }
5436
5485
  else {
5437
- console.log(` + records DNS de Resend (DKIM/SPF/MX, los que devuelva) en la zona ${apexZoneOf(domain)} + sondeo hasta verified`);
5486
+ logSharedMail();
5438
5487
  }
5439
5488
  console.log('\n(`--dry-run`) No se ejecutó ninguna llamada a API ni se escribió estado.');
5440
5489
  }
@@ -8867,7 +8916,11 @@ export async function runProvision(flags, deps = {}) {
8867
8916
  result.initialMigration = true;
8868
8917
  }
8869
8918
  if (hasDomain) {
8870
- result.clientDomain = { domain: flags.domain, route: flags.dns };
8919
+ result.clientDomain = {
8920
+ domain: flags.domain,
8921
+ route: flags.dns,
8922
+ clientMail: flags.clientMail === true,
8923
+ };
8871
8924
  }
8872
8925
  if (flags.services.includes('vault')) {
8873
8926
  result.vault = buildVaultPlan(projectName, flags.services);
@@ -8904,7 +8957,7 @@ export async function runProvision(flags, deps = {}) {
8904
8957
  // Same rule as the real retire (DOM-001): the plan must print the interim the
8905
8958
  // project ACTUALLY has, not one re-derived in the current org zone.
8906
8959
  const interim = existingProvisionDomains(projectName, state?.dns).prod;
8907
- printClientDomainPlan(flags.domain, interim, flags.dns, flags.confirm, target);
8960
+ printClientDomainPlan(flags.domain, interim, flags.dns, flags.confirm, target, flags.clientMail === true);
8908
8961
  }
8909
8962
  }
8910
8963
  return result;
@@ -9110,6 +9163,7 @@ export async function runProvision(flags, deps = {}) {
9110
9163
  const headless = !process.stdin.isTTY;
9111
9164
  await provisionClientDomain(rootDir, runState, flags.team, flags.domain, {
9112
9165
  dnsRoute: flags.dns,
9166
+ clientMail: flags.clientMail === true,
9113
9167
  verifyTimeoutMin: flags.verifyTimeout,
9114
9168
  preConfirmed: flags.confirm,
9115
9169
  headless,
@@ -946,7 +946,10 @@ export function createVaultClient(deps = {}) {
946
946
  if (app !== 'railway')
947
947
  assertDestination(app);
948
948
  const operation = `consultar la conexión de la organización con ${app}`;
949
- const body = asObject(ok(await call(operation, 'GET', `/api/v1/app-connections/${app}`), operation));
949
+ // `/available`, not the bare list: the list needs the org `Read` permission on app
950
+ // connections, which only admins hold; `/available` needs `Connect` — the same one a sync
951
+ // needs — and the built-in `member` role has it. With the list, a member got a 403.
952
+ const body = asObject(ok(await call(operation, 'GET', `/api/v1/app-connections/${app}/available`), operation));
950
953
  const connections = asArray(body?.appConnections).flatMap((entry) => {
951
954
  const connection = asObject(entry);
952
955
  const id = asString(connection?.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timekast/factory",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Public, thin CLI to bootstrap and maintain TimeKast Factory derived projects.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",