@wtfalch/foundry 0.5.0 → 0.6.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.
Files changed (42) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.js +6 -1
  4. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  5. package/dist/calls-sfu-deploy-support.js +10 -1
  6. package/dist/calls-sfu.d.ts +31 -8
  7. package/dist/calls-sfu.js +36 -5
  8. package/dist/index.d.ts +8 -2
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  11. package/dist/migrations/0020_mail_apps.sql +7 -0
  12. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  13. package/dist/org.d.ts +81 -0
  14. package/dist/org.js +88 -0
  15. package/dist/plan/diff.js +93 -10
  16. package/dist/plan/executor.d.ts +236 -35
  17. package/dist/plan/executor.js +274 -76
  18. package/dist/plan/index.d.ts +7 -1
  19. package/dist/plan/index.js +4 -1
  20. package/dist/plan/mail-copy.d.ts +125 -0
  21. package/dist/plan/mail-copy.js +166 -0
  22. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  23. package/dist/plan/mail-identity-allowlist.js +11 -0
  24. package/dist/plan/mail-identity.d.ts +68 -0
  25. package/dist/plan/mail-identity.js +17 -0
  26. package/dist/plan/plan-apply.d.ts +5 -0
  27. package/dist/plan/platform-diff.d.ts +184 -9
  28. package/dist/plan/platform-diff.js +368 -15
  29. package/dist/plan/platform-org.d.ts +3 -2
  30. package/dist/plan/platform-providers.d.ts +138 -24
  31. package/dist/plan/platform-providers.js +1460 -79
  32. package/dist/plan/platform-sfu.d.ts +54 -19
  33. package/dist/plan/platform-sfu.js +64 -22
  34. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  35. package/dist/plan/zitadel-machine-user.js +182 -39
  36. package/dist/requested-changes.d.ts +17 -7
  37. package/dist/requested-changes.js +37 -7
  38. package/dist/store/postgres.js +31 -2
  39. package/dist/store/schema.d.ts +72 -0
  40. package/dist/store/schema.js +13 -0
  41. package/dist/store.d.ts +7 -0
  42. package/package.json +1 -1
@@ -7,7 +7,16 @@ export interface BootstrapPlatformInput {
7
7
  company: BootstrapCompany;
8
8
  owner: BootstrapOwner;
9
9
  }
10
- /** The master keys, read from the host's held keys by the caller. */
10
+ /**
11
+ * The master keys, read from the host's held keys by the caller.
12
+ *
13
+ * #213: `coolifyUrl`, `coolifyToken` and `cloudflareApiToken` are also what
14
+ * `bootstrapPlatform` puts as Archon's own held keys
15
+ * (`platform:master:coolify-url`, `-coolify-token`, `-cloudflare-token`)
16
+ * before Archon's app deploys, so its onboarding can read them back.
17
+ * `cloudflareApiToken` needs "API Tokens Write" for that: Archon mints its
18
+ * own scoped child tokens from it.
19
+ */
11
20
  export interface RootCredentials {
12
21
  coolifyUrl: string;
13
22
  coolifyToken: string;
@@ -32,8 +41,14 @@ export interface BootstrapPlatformDeps {
32
41
  migrateSql: MigrateSql;
33
42
  rootCredentials: () => Promise<RootCredentials>;
34
43
  deliverSecret: (ref: string, secret: string) => Promise<void>;
44
+ /**
45
+ * `put` (#213) is optional, like `PlatformArchonDeps.heldKeys`'s own: a
46
+ * host without one still compiles, and Archon's master keys are reported
47
+ * blocked, not skipped, while it is missing.
48
+ */
35
49
  heldKeys: {
36
50
  resolve(ref: string): Promise<string | null>;
51
+ put?(ref: string, value: string): Promise<void>;
37
52
  };
38
53
  /** Per-step dependencies (auth, mail, files, ai, integrations, archon, sfu). A step without its own is reported blocked. */
39
54
  executor?: BootstrapExecutorDeps;
@@ -106,12 +106,39 @@ export function guardStepDeps(executor, redactor, deliverSecret, heldKeys) {
106
106
  const copy = { ...deps };
107
107
  if ('deliverSecret' in copy)
108
108
  copy.deliverSecret = deliverSecret;
109
- if (key === 'archon' || 'heldKeys' in copy)
109
+ // #201: ai's own default minter needs heldKeys too, to re-sign a mint
110
+ // request against its signing key when an earlier run pinned it but
111
+ // crashed before the management key itself was minted and delivered.
112
+ if (key === 'archon' || key === 'ai' || 'heldKeys' in copy)
110
113
  copy.heldKeys = heldKeys;
111
114
  out[key] = copy;
112
115
  }
113
116
  return out;
114
117
  }
118
+ /**
119
+ * #213: stamps `PlatformArchonDeps.masterKeys` from the root credentials
120
+ * `bootstrapPlatform` already resolved, onto whatever the host gave the
121
+ * archon step through `deps.executor.archon` -- the values are
122
+ * `credentials`' own, not something the host names separately, so this is
123
+ * not one more thing for `guardStepDeps`'s caller to wire by hand. A run
124
+ * with no `archon` deps at all (the step then reported blocked, as before)
125
+ * is passed through unchanged.
126
+ */
127
+ function archonMasterKeys(guarded, credentials) {
128
+ if (!guarded.archon)
129
+ return guarded;
130
+ return {
131
+ ...guarded,
132
+ archon: {
133
+ ...guarded.archon,
134
+ masterKeys: {
135
+ coolifyUrl: credentials.coolifyUrl,
136
+ coolifyToken: credentials.coolifyToken,
137
+ cloudflareParentToken: credentials.cloudflareApiToken,
138
+ },
139
+ },
140
+ };
141
+ }
115
142
  /**
116
143
  * The platform's first apply, run from Archon's onboarding flow: migrates
117
144
  * foundry's tables, declares Company Included and the platform (never
@@ -148,6 +175,17 @@ export async function bootstrapPlatform(input, deps) {
148
175
  redactor.add(value);
149
176
  return value;
150
177
  },
178
+ // #213: only present when the host's own heldKeys can put, so a host
179
+ // without one leaves Archon's master keys reported blocked rather
180
+ // than silently no-op'd.
181
+ ...(deps.heldKeys.put
182
+ ? {
183
+ async put(ref, value) {
184
+ redactor.add(value);
185
+ await deps.heldKeys.put?.(ref, value);
186
+ },
187
+ }
188
+ : {}),
151
189
  };
152
190
  const migrated = await migrate(deps.migrateSql);
153
191
  emit({
@@ -193,7 +231,7 @@ export async function bootstrapPlatform(input, deps) {
193
231
  executor: new FoundryPlatformStepExecutor(cloudflare, orgStore, {
194
232
  coolify,
195
233
  deliverSecret,
196
- ...guardStepDeps(deps.executor, redactor, deliverSecret, heldKeys),
234
+ ...archonMasterKeys(guardStepDeps(deps.executor, redactor, deliverSecret, heldKeys), credentials),
197
235
  }),
198
236
  };
199
237
  }
@@ -49,7 +49,12 @@ export function declaredCompanyOrg(company, owner) {
49
49
  slug: company.slug,
50
50
  displayName: company.name,
51
51
  domain: null,
52
- mail: { mode: 'off', usesDomain: false },
52
+ mail: {
53
+ mode: 'off',
54
+ usesDomain: false,
55
+ ownerMailboxAddress: null,
56
+ noReplyLocalPart: 'no-reply',
57
+ },
53
58
  chat: { usesDomain: false },
54
59
  calls: { enabled: false, usesDomain: false },
55
60
  issuer: { usesDomain: false },
@@ -38,6 +38,14 @@ export declare function verifyHostKey(rawKey: Buffer, expectedFingerprint: strin
38
38
  * this script writes holds no secret. Returns the SFTP file mode to write
39
39
  * with, or `undefined` for anything else (the SFTP server's own default).
40
40
  * PR #125's review, finding 2; extended for #127's second secret file.
41
+ *
42
+ * A trailing `.tmp` is stripped before comparing: `calls-sfu.ts`'s
43
+ * `applyCallsSfu` writes `livekit.yaml` to a `.tmp` path first and renames
44
+ * it into place (#152 round 2 review, finding 3 -- a temp file plus an
45
+ * atomic rename, rather than overwriting the live config two concurrent
46
+ * applies could otherwise tear). The temp file holds the same secret the
47
+ * final path does, so it gets the same 0600 mode; `mv` preserves it across
48
+ * the rename.
41
49
  */
42
50
  export declare function fileWriteMode(path: string): number | undefined;
43
51
  /**
@@ -53,9 +53,18 @@ export function verifyHostKey(rawKey, expectedFingerprint) {
53
53
  * this script writes holds no secret. Returns the SFTP file mode to write
54
54
  * with, or `undefined` for anything else (the SFTP server's own default).
55
55
  * PR #125's review, finding 2; extended for #127's second secret file.
56
+ *
57
+ * A trailing `.tmp` is stripped before comparing: `calls-sfu.ts`'s
58
+ * `applyCallsSfu` writes `livekit.yaml` to a `.tmp` path first and renames
59
+ * it into place (#152 round 2 review, finding 3 -- a temp file plus an
60
+ * atomic rename, rather than overwriting the live config two concurrent
61
+ * applies could otherwise tear). The temp file holds the same secret the
62
+ * final path does, so it gets the same 0600 mode; `mv` preserves it across
63
+ * the rename.
56
64
  */
57
65
  export function fileWriteMode(path) {
58
- return path === LIVEKIT_CONFIG_PATH || path === CLOUDFLARED_ENV_PATH ? 0o600 : undefined;
66
+ const target = path.endsWith('.tmp') ? path.slice(0, -'.tmp'.length) : path;
67
+ return target === LIVEKIT_CONFIG_PATH || target === CLOUDFLARED_ENV_PATH ? 0o600 : undefined;
59
68
  }
60
69
  // ---------------------------------------------------------------------------
61
70
  // 3. Preflight
@@ -21,13 +21,21 @@
21
21
  * platform's own `sfu:livekit` step, `plan/platform-sfu.ts`), never imported
22
22
  * here, and every test in `calls-sfu.test.ts` runs against a fake.
23
23
  *
24
- * The address, the LiveKit API key and its secret are never hardcoded here
24
+ * The address and every LiveKit API key/secret pair are never hardcoded here
25
25
  * ({@link CallsSfuConfig}): PR 344's review on app-template's
26
26
  * `calls-instance` step found its first cut created three Coolify apps
27
27
  * before it ever checked the address was known.
28
28
  * {@link requireCallsSfuAddress} is that check, run first, before either
29
29
  * file is written.
30
30
  *
31
+ * #152: one shared instance, kept apart by *per-company* keys rather than
32
+ * one shared pair -- {@link CallsSfuConfig.livekitKeys} carries every org's
33
+ * own key/secret pair, and {@link livekitConfigYaml} writes all of them into
34
+ * `keys:`. Minting and delivering each org's pair is the platform `sfu`
35
+ * step's job (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`), not
36
+ * this module's -- the same "this package never invents a credential" rule
37
+ * the single-pair design already followed.
38
+ *
31
39
  * #127 adds a second compose service, `cloudflared`, so the SFU has a public
32
40
  * `wss://` address through its own named tunnel -- browsers on an https page
33
41
  * cannot open a plain `ws://` connection, and 7880 was open to the whole
@@ -64,16 +72,24 @@ export declare class MissingCloudflaredTunnelTokenError extends Error {
64
72
  export declare function requireCloudflaredTunnelToken(token: string | undefined | null): string;
65
73
  /**
66
74
  * Everything {@link applyCallsSfu} needs and this package never invents on
67
- * its own: the SFU's own address (calls#15), the LiveKit API key/secret pair
68
- * every client and the service sign tokens with, and the token for the
69
- * named tunnel `scripts/calls-sfu-ingress.mjs` creates. All four come from
70
- * the secrets store, at the caller's own script -- #122's and #127's "Human
71
- * steps".
75
+ * its own: the SFU's own address (calls#15), one LiveKit API key/secret pair
76
+ * per org with calls enabled (#152 -- a shared instance, kept apart by
77
+ * per-company keys, not a port range or a deployment each), and the token
78
+ * for the named tunnel `scripts/calls-sfu-ingress.mjs` creates. All of it
79
+ * comes from the secrets store, at the caller's own script -- #122's and
80
+ * #127's "Human steps" -- except the per-org pairs, which the platform `sfu`
81
+ * step mints itself (`plan/platform-sfu.ts`).
72
82
  */
73
83
  export interface CallsSfuConfig {
74
84
  address: string;
75
- livekitApiKey: string;
76
- livekitApiSecret: string;
85
+ /**
86
+ * API key id -> secret, one entry per org currently enabled for calls.
87
+ * {@link livekitConfigYaml} writes every entry under `keys:`, sorted by key
88
+ * id so the same set of orgs always serializes to the same bytes -- what
89
+ * `calls-sfu-ssh-adapter.ts`'s `applyCallsSfuOverSession` compares, byte
90
+ * for byte, to decide whether LiveKit needs a restart.
91
+ */
92
+ livekitKeys: Record<string, string>;
77
93
  cloudflaredTunnelToken: string;
78
94
  }
79
95
  /**
@@ -163,6 +179,13 @@ export declare function cloudflaredEnvFile(token: string): string;
163
179
  * has one public address of its own, bridged directly rather than sitting
164
180
  * behind NAT (calls#15's decision comment), so there is a known-good answer
165
181
  * and no reason to make LiveKit discover it via STUN.
182
+ *
183
+ * `keys:` carries one line per entry in {@link CallsSfuConfig.livekitKeys}
184
+ * (#152), sorted by key id -- never object insertion order, which
185
+ * JavaScript does not even guarantee is the order the platform step built
186
+ * the map in. An org dropped from the map (calls turned off) simply has no
187
+ * line here on the next apply; nothing here deletes a key, it only ever
188
+ * writes the set it was given.
166
189
  */
167
190
  export declare function livekitConfigYaml(config: CallsSfuConfig): string;
168
191
  /**
package/dist/calls-sfu.js CHANGED
@@ -21,13 +21,21 @@
21
21
  * platform's own `sfu:livekit` step, `plan/platform-sfu.ts`), never imported
22
22
  * here, and every test in `calls-sfu.test.ts` runs against a fake.
23
23
  *
24
- * The address, the LiveKit API key and its secret are never hardcoded here
24
+ * The address and every LiveKit API key/secret pair are never hardcoded here
25
25
  * ({@link CallsSfuConfig}): PR 344's review on app-template's
26
26
  * `calls-instance` step found its first cut created three Coolify apps
27
27
  * before it ever checked the address was known.
28
28
  * {@link requireCallsSfuAddress} is that check, run first, before either
29
29
  * file is written.
30
30
  *
31
+ * #152: one shared instance, kept apart by *per-company* keys rather than
32
+ * one shared pair -- {@link CallsSfuConfig.livekitKeys} carries every org's
33
+ * own key/secret pair, and {@link livekitConfigYaml} writes all of them into
34
+ * `keys:`. Minting and delivering each org's pair is the platform `sfu`
35
+ * step's job (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`), not
36
+ * this module's -- the same "this package never invents a credential" rule
37
+ * the single-pair design already followed.
38
+ *
31
39
  * #127 adds a second compose service, `cloudflared`, so the SFU has a public
32
40
  * `wss://` address through its own named tunnel -- browsers on an https page
33
41
  * cannot open a plain `ws://` connection, and 7880 was open to the whole
@@ -191,9 +199,20 @@ export function cloudflaredEnvFile(token) {
191
199
  * has one public address of its own, bridged directly rather than sitting
192
200
  * behind NAT (calls#15's decision comment), so there is a known-good answer
193
201
  * and no reason to make LiveKit discover it via STUN.
202
+ *
203
+ * `keys:` carries one line per entry in {@link CallsSfuConfig.livekitKeys}
204
+ * (#152), sorted by key id -- never object insertion order, which
205
+ * JavaScript does not even guarantee is the order the platform step built
206
+ * the map in. An org dropped from the map (calls turned off) simply has no
207
+ * line here on the next apply; nothing here deletes a key, it only ever
208
+ * writes the set it was given.
194
209
  */
195
210
  export function livekitConfigYaml(config) {
196
211
  const address = requireCallsSfuAddress(config.address);
212
+ const keys = Object.entries(config.livekitKeys).sort(([a], [b]) => a.localeCompare(b));
213
+ const keysYaml = keys.length === 0
214
+ ? 'keys: {}\n'
215
+ : `keys:\n${keys.map(([apiKey, apiSecret]) => ` ${apiKey}: ${apiSecret}`).join('\n')}\n`;
197
216
  return `port: ${LIVEKIT_SIGNALLING_PORT}
198
217
  rtc:
199
218
  tcp_port: ${LIVEKIT_MEDIA_TCP_PORT}
@@ -201,9 +220,7 @@ rtc:
201
220
  port_range_end: 60000
202
221
  use_external_ip: false
203
222
  node_ip: ${address}
204
- keys:
205
- ${config.livekitApiKey}: ${config.livekitApiSecret}
206
- `;
223
+ ${keysYaml}`;
207
224
  }
208
225
  async function run(host, command) {
209
226
  const result = await host.exec(command);
@@ -211,6 +228,20 @@ async function run(host, command) {
211
228
  throw new Error(`"${command}" exited ${result.exitCode}: ${result.stderr || result.stdout}`);
212
229
  }
213
230
  }
231
+ /**
232
+ * Writes `contents` to `${path}.tmp` and renames it onto `path` with `mv`,
233
+ * rather than writing `path` directly -- `mv` within the same directory is a
234
+ * single rename syscall, so a reader of `path` (this function's own
235
+ * `cat`-and-compare in `calls-sfu-ssh-adapter.ts`, or LiveKit's own read at
236
+ * container start) only ever sees the old complete file or the new complete
237
+ * one, never a partial write from one apply interleaved with another's
238
+ * (#152 round 2 review, finding 3).
239
+ */
240
+ async function writeFileAtomically(host, path, contents) {
241
+ const tmpPath = `${path}.tmp`;
242
+ await host.writeFile(tmpPath, contents);
243
+ await run(host, `mv -f ${tmpPath} ${path}`);
244
+ }
214
245
  /**
215
246
  * Writes all three files, brings LiveKit and `cloudflared` up, then narrows
216
247
  * the VM firewall to exactly what #122's and #127's "Decided" sections name
@@ -240,7 +271,7 @@ export async function applyCallsSfu(host, config) {
240
271
  requireCallsSfuAddress(config.address);
241
272
  requireCloudflaredTunnelToken(config.cloudflaredTunnelToken);
242
273
  await host.writeFile(LIVEKIT_COMPOSE_PATH, dockerComposeYaml());
243
- await host.writeFile(LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
274
+ await writeFileAtomically(host, LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
244
275
  await host.writeFile(CLOUDFLARED_ENV_PATH, cloudflaredEnvFile(config.cloudflaredTunnelToken));
245
276
  await run(host, `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`);
246
277
  await run(host, 'ufw allow 22/tcp');
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export type { OrgId, MailMode, MailConfig, ChatConfig, CallsConfig, IssuerConfig, SiteConfig, IssuerAppKind, IssuerApp, HostingConfig, SsoConfig, GuestsConfig, SoloConfig, StorageConfig, AiConfig, BuyerAddress, BuyerIdentity, AdoptedAppForm, AdoptedApp, OwnerIdentity, DeclaredOrg, } from './org.js';
2
- export { validateBuyerIdentity, validateHosting, validateOwner, validateSite } from './org.js';
2
+ export { DEFAULT_MAIL_APPS, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
3
3
  export type { DeclaredPlatform, PlatformService, PlatformServiceKey } from './platform.js';
4
4
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
5
5
  export type { RequestableField, CallsChange, StorageLimitChange, AiLimitChange, DomainChange, MailTierChange, RequestedChangeValue, ChangeClassification, RequestedChangeState, RequestedChange, } from './requested-changes.js';
@@ -23,11 +23,15 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
23
23
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, } from './plan/index.js';
24
24
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
25
25
  export { HttpJobsSchedulerClient } from './plan/index.js';
26
+ export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
27
+ export { checkMailIdentityAudience } from './plan/index.js';
28
+ export type { MailAllowlistApp } from './plan/index.js';
29
+ export { checkMailAppsAllowlist } from './plan/index.js';
26
30
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
27
31
  export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './plan/index.js';
28
32
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
29
33
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './plan/index.js';
30
- export { PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, PLATFORM_AI_MINTER_MISSING, } from './plan/index.js';
34
+ export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
31
35
  export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './plan/index.js';
32
36
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './plan/index.js';
33
37
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
@@ -37,6 +41,8 @@ export type { CallsSfuSshConnectOptions } from './calls-sfu-ssh-client.js';
37
41
  export { UnknownCallsSfuHostKeyError, toCallsSfuHostPort, applyCallsSfuOverSession, } from './calls-sfu-ssh-adapter.js';
38
42
  export type { LivekitCredentials } from './calls-sfu-keys.js';
39
43
  export { mintLivekitCredentials } from './calls-sfu-keys.js';
44
+ export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './plan/index.js';
45
+ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './plan/index.js';
40
46
  export type { MigrateSql, MigrationResult } from './migrate.js';
41
47
  export { MIGRATIONS_DIR, migrate } from './migrate.js';
42
48
  export type { BootstrapCompany, BootstrapExecutorDeps, BootstrapOwner, BootstrapPlatformDeps, BootstrapPlatformInput, BootstrapProgress, BootstrapResult, RootCredentials, } from './bootstrap/index.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { validateBuyerIdentity, validateHosting, validateOwner, validateSite } from './org.js';
1
+ export { DEFAULT_MAIL_APPS, validateBuyerIdentity, validateHosting, validateMail, validateOwner, validateSite, } from './org.js';
2
2
  export { PLATFORM_SERVICE_KEYS, defaultPlatformHostname, validatePlatform, } from './platform.js';
3
3
  export { REQUESTABLE_FIELDS, classifyChange, RequestedChangeError } from './requested-changes.js';
4
4
  export { validateSlug, slugFromName, RESERVED_SLUGS } from './slug.js';
@@ -10,13 +10,16 @@ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesF
10
10
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
11
11
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
12
12
  export { HttpJobsSchedulerClient } from './plan/index.js';
13
+ export { checkMailIdentityAudience } from './plan/index.js';
14
+ export { checkMailAppsAllowlist } from './plan/index.js';
13
15
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
14
16
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './plan/index.js';
15
- export { PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, PLATFORM_AI_MINTER_MISSING, } from './plan/index.js';
17
+ export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
16
18
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './plan/index.js';
17
19
  export { connectCallsSfuSsh } from './calls-sfu-ssh-client.js';
18
20
  export { UnknownCallsSfuHostKeyError, toCallsSfuHostPort, applyCallsSfuOverSession, } from './calls-sfu-ssh-adapter.js';
19
21
  export { mintLivekitCredentials } from './calls-sfu-keys.js';
22
+ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './plan/index.js';
20
23
  export { MIGRATIONS_DIR, migrate } from './migrate.js';
21
24
  export { bootstrapPlatform } from './bootstrap/index.js';
22
25
  export { AlreadyRunningError, RunNotFoundError, StepFailedError, StepMismatchError, } from './runner/errors.js';
@@ -0,0 +1,15 @@
1
+ -- @wtfalch/foundry: #162 (decided 2026-09-29) -- the owner's mailbox
2
+ -- address is now declared, not guessed (replacing the hardcoded
3
+ -- `owner@<domain>` PR 163 shipped first), and the org's send-only no-reply
4
+ -- address gets its own configurable local part. Mirrors
5
+ -- `src/store/schema.ts`'s `declaredOrgs` table additions.
6
+ --
7
+ -- owner_mailbox_address is nullable: an org can have mail on with no
8
+ -- address declared yet, and `plan/diff.ts`'s `planMail` reports the
9
+ -- mailbox alone as blocked in that case rather than this column refusing
10
+ -- the row. no_reply_local_part defaults to 'no-reply' so every existing
11
+ -- row keeps today's behaviour unchanged.
12
+
13
+ alter table foundry_declared_orgs
14
+ add column if not exists owner_mailbox_address text,
15
+ add column if not exists no_reply_local_part text not null default 'no-reply';
@@ -0,0 +1,7 @@
1
+ -- @wtfalch/foundry: which ZITADEL apps in the org's audience project may
2
+ -- read a person's mail (#34). Mirrors `src/store/schema.ts`'s `mailApps`.
3
+ -- Defaults to 'agora' and 'webmail' so every existing org keeps its current
4
+ -- behaviour.
5
+
6
+ alter table foundry_declared_orgs
7
+ add column if not exists mail_apps jsonb not null default '["agora","webmail"]'::jsonb;
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: the role mailboxes an org wants pre-created on its mail
2
+ -- domain (#230). Mirrors `src/store/schema.ts`'s `roleMailboxes`. Defaults to
3
+ -- an empty list so every existing org keeps its current behaviour.
4
+
5
+ alter table foundry_declared_orgs
6
+ add column if not exists role_mailboxes jsonb not null default '[]'::jsonb;
package/dist/org.d.ts CHANGED
@@ -32,11 +32,61 @@ export type MailMode = 'off' | 'received' | 'hosted';
32
32
  * Decided in #7 amendment 1: setting `domain` alone never starts a mail
33
33
  * change. `usesDomain` is mail's own field for that, independent of
34
34
  * `mode` — a domain can be recorded before mail is ever pointed at it.
35
+ *
36
+ * `mailApps`: which ZITADEL apps in the org's audience project may read a
37
+ * person's mail. Decided by William (2026-09-30, #34): only listed apps
38
+ * may read a person's mail, and the org's own declared config holds the
39
+ * list -- ZITADEL puts a project's own id into the default audience of
40
+ * every app signed in to that project, so a matching `requireAudience`
41
+ * value alone says nothing about *who* carries it (`manage` had exactly
42
+ * this hole until it moved to its own project, wtfalch/agora#100;
43
+ * `operator` has it today, wtfalch/operator#11). `undefined` means the org
44
+ * declares none of its own and {@link DEFAULT_MAIL_APPS} applies --
45
+ * optional so every existing declared org, and every test fixture that
46
+ * builds a `MailConfig` literal, keeps working unchanged; a caller that
47
+ * cares reads `mail.mailApps ?? DEFAULT_MAIL_APPS`, the same fallback
48
+ * `plan/mail-identity-allowlist.ts`'s own doc comment names.
35
49
  */
36
50
  export interface MailConfig {
37
51
  mode: MailMode;
38
52
  usesDomain: boolean;
53
+ mailApps?: string[];
54
+ /**
55
+ * The owner's mailbox address on this org's mail domain. Decided
56
+ * 2026-09-29 on #162: nothing is guessed any more, replacing the
57
+ * hardcoded `owner@<domain>` PR 163 shipped first. `null` (the default)
58
+ * until an operator names one -- `plan/diff.ts`'s `planMail` then reports
59
+ * the owner's mailbox alone as blocked, naming this field, while every
60
+ * other part of the org's mail still applies. Optional so a caller that
61
+ * builds a `MailConfig` for anything other than the owner's mailbox (most
62
+ * existing call sites, before this field existed) is unaffected; absent
63
+ * is treated the same as `null` everywhere this is read.
64
+ */
65
+ ownerMailboxAddress?: string | null;
66
+ /**
67
+ * The local part of the org's send-only no-reply address (no inbox),
68
+ * read by `plan/executor.ts`'s `ensureAppLettermintMail` for `MAIL_FROM`.
69
+ * Defaults to `'no-reply'` when absent -- #162: a localised default via
70
+ * `@wtfalch/i18n` (a Norwegian company's own word for it, say) is
71
+ * deferred, since that package has none yet. An operator's own value
72
+ * always wins once set.
73
+ */
74
+ noReplyLocalPart?: string;
75
+ /**
76
+ * Local parts of the role mailboxes this org wants pre-created on its mail
77
+ * domain (#230) -- `postmaster`, `abuse`, `security` and the like. Role
78
+ * mailboxes have no person behind them, so unlike {@link ownerMailboxAddress}
79
+ * they are not tied to `owner` and are never invited through the broker.
80
+ * Foundry holds no default set: every org declares its own. `undefined` or
81
+ * empty means none. Each name is a local part, not an address, so the
82
+ * address is always on the org's own resolved mail domain.
83
+ */
84
+ roleMailboxes?: string[];
39
85
  }
86
+ /** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
87
+ export declare const DEFAULT_MAIL_APPS: readonly string[];
88
+ /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
89
+ export declare const DEFAULT_NO_REPLY_LOCAL_PART = "no-reply";
40
90
  /**
41
91
  * Chat's per-service opt-in into the org's `domain` fact.
42
92
  *
@@ -329,6 +379,37 @@ export interface OwnerIdentity {
329
379
  * null` (no owner named yet) is always valid.
330
380
  */
331
381
  export declare function validateOwner(owner: OwnerIdentity | null): string | null;
382
+ /**
383
+ * `null` when `mail` is valid; otherwise the reason it is not (#162,
384
+ * decided 2026-09-29).
385
+ *
386
+ * `ownerMailboxAddress`, when set, must be a valid email address; when
387
+ * `usesDomain` is also true and `domain` is set, it must be on that exact
388
+ * domain ("validate it is on the org's own domain", #162's "How"). A
389
+ * level-1 org's mail domain is `<slug>.<platform apex>` -- the platform's
390
+ * own fact, not this file's -- so that case is left for `plan/diff.ts`'s
391
+ * `planMail` to resolve and block on, the same way it already resolves
392
+ * `mailHost` itself.
393
+ *
394
+ * Deliberately NOT required just because `mode` is not `'off'`: an org can
395
+ * have mail on with no address declared yet, same as it can have an owner
396
+ * named with no ZITADEL invite sent yet -- `planMail` reports the mailbox
397
+ * alone as blocked in that case rather than this function refusing to let
398
+ * the org be saved at all (#162 "Done when": "mail on with no owner address
399
+ * plans a blocked item and still applies the other steps").
400
+ *
401
+ * `noReplyLocalPart`, when set, must be non-empty and contain neither `@`
402
+ * nor whitespace.
403
+ *
404
+ * `roleMailboxes`, when set (#230), has only plain local parts (no `@`, `,` or
405
+ * whitespace) and no duplicate, ignoring case.
406
+ *
407
+ * `mailApps`, when set (#34), has no empty entry and no duplicate -- same
408
+ * two rules `validateHosting` already applies to `issuerApps`' names, for
409
+ * the same reason: a duplicate or blank entry can never mean anything
410
+ * different from leaving it out.
411
+ */
412
+ export declare function validateMail(org: Pick<DeclaredOrg, 'mail' | 'domain'>): string | null;
332
413
  /**
333
414
  * The form an app foundry does not own takes. Decided in #7's `#15`
334
415
  * refinement comment:
package/dist/org.js CHANGED
@@ -1,3 +1,7 @@
1
+ /** {@link MailConfig.mailApps} when the org declares none of its own (#34, decided 2026-09-30). */
2
+ export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
3
+ /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
4
+ export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
1
5
  const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
2
6
  const HOSTING_BUILD_PACKS = new Set([
3
7
  'dockerfile',
@@ -185,3 +189,87 @@ export function validateOwner(owner) {
185
189
  return 'owner.familyName must not be empty';
186
190
  return null;
187
191
  }
192
+ /** A local part has no `@` and no whitespace -- loose on purpose, the same restraint {@link OWNER_EMAIL_PATTERN}'s own doc comment states. */
193
+ const LOCAL_PART_PATTERN = /^[^\s@]+$/;
194
+ /**
195
+ * `null` when `mail` is valid; otherwise the reason it is not (#162,
196
+ * decided 2026-09-29).
197
+ *
198
+ * `ownerMailboxAddress`, when set, must be a valid email address; when
199
+ * `usesDomain` is also true and `domain` is set, it must be on that exact
200
+ * domain ("validate it is on the org's own domain", #162's "How"). A
201
+ * level-1 org's mail domain is `<slug>.<platform apex>` -- the platform's
202
+ * own fact, not this file's -- so that case is left for `plan/diff.ts`'s
203
+ * `planMail` to resolve and block on, the same way it already resolves
204
+ * `mailHost` itself.
205
+ *
206
+ * Deliberately NOT required just because `mode` is not `'off'`: an org can
207
+ * have mail on with no address declared yet, same as it can have an owner
208
+ * named with no ZITADEL invite sent yet -- `planMail` reports the mailbox
209
+ * alone as blocked in that case rather than this function refusing to let
210
+ * the org be saved at all (#162 "Done when": "mail on with no owner address
211
+ * plans a blocked item and still applies the other steps").
212
+ *
213
+ * `noReplyLocalPart`, when set, must be non-empty and contain neither `@`
214
+ * nor whitespace.
215
+ *
216
+ * `roleMailboxes`, when set (#230), has only plain local parts (no `@`, `,` or
217
+ * whitespace) and no duplicate, ignoring case.
218
+ *
219
+ * `mailApps`, when set (#34), has no empty entry and no duplicate -- same
220
+ * two rules `validateHosting` already applies to `issuerApps`' names, for
221
+ * the same reason: a duplicate or blank entry can never mean anything
222
+ * different from leaving it out.
223
+ */
224
+ export function validateMail(org) {
225
+ const { mail } = org;
226
+ const address = mail.ownerMailboxAddress ?? null;
227
+ if (address !== null) {
228
+ if (!OWNER_EMAIL_PATTERN.test(address)) {
229
+ return `mail.ownerMailboxAddress '${address}' is not a valid email address`;
230
+ }
231
+ if (mail.usesDomain && org.domain) {
232
+ const addressDomain = address.slice(address.indexOf('@') + 1).toLowerCase();
233
+ if (addressDomain !== org.domain.toLowerCase()) {
234
+ return `mail.ownerMailboxAddress '${address}' must be on ${org.domain}`;
235
+ }
236
+ }
237
+ }
238
+ if (mail.noReplyLocalPart !== undefined && !LOCAL_PART_PATTERN.test(mail.noReplyLocalPart)) {
239
+ return `mail.noReplyLocalPart '${mail.noReplyLocalPart}' must not be empty or contain '@' or whitespace`;
240
+ }
241
+ if (mail.roleMailboxes) {
242
+ const seen = new Set();
243
+ const reserved = new Map([
244
+ [(mail.noReplyLocalPart ?? DEFAULT_NO_REPLY_LOCAL_PART).toLowerCase(), 'no-reply'],
245
+ ]);
246
+ if (address !== null)
247
+ reserved.set(address.slice(0, address.indexOf('@')).toLowerCase(), 'owner');
248
+ for (const name of mail.roleMailboxes) {
249
+ if (!LOCAL_PART_PATTERN.test(name) || name.includes(',')) {
250
+ return `mail.roleMailboxes entry '${name}' must be a non-empty local part without '@', ',' or whitespace`;
251
+ }
252
+ if (seen.has(name.toLowerCase())) {
253
+ return `mail.roleMailboxes name '${name}' is not unique`;
254
+ }
255
+ const clash = reserved.get(name.toLowerCase());
256
+ if (clash) {
257
+ return `mail.roleMailboxes name '${name}' clashes with the ${clash} mailbox`;
258
+ }
259
+ seen.add(name.toLowerCase());
260
+ }
261
+ }
262
+ if (mail.mailApps) {
263
+ const seen = new Set();
264
+ for (const name of mail.mailApps) {
265
+ if (!name) {
266
+ return 'mail.mailApps entry needs a non-empty name';
267
+ }
268
+ if (seen.has(name)) {
269
+ return `mail.mailApps name '${name}' is not unique`;
270
+ }
271
+ seen.add(name);
272
+ }
273
+ }
274
+ return null;
275
+ }