@wtfalch/foundry 0.5.0 → 0.7.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.
Files changed (66) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +12 -3
  5. package/dist/bootstrap/versions.d.ts +17 -0
  6. package/dist/bootstrap/versions.js +12 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  8. package/dist/calls-sfu-deploy-support.js +10 -1
  9. package/dist/calls-sfu.d.ts +36 -12
  10. package/dist/calls-sfu.js +42 -9
  11. package/dist/index.d.ts +10 -4
  12. package/dist/index.js +6 -3
  13. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  14. package/dist/migrations/0020_mail_apps.sql +7 -0
  15. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  16. package/dist/migrations/0022_chat_enabled.sql +6 -0
  17. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  18. package/dist/migrations/0024_audit_events.sql +211 -0
  19. package/dist/migrations/0025_audit_display.sql +147 -0
  20. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  21. package/dist/migrations/0027_audit_chain.sql +170 -0
  22. package/dist/migrations/0028_audit_rls.sql +111 -0
  23. package/dist/org.d.ts +105 -0
  24. package/dist/org.js +101 -0
  25. package/dist/plan/agora.d.ts +21 -0
  26. package/dist/plan/agora.js +23 -0
  27. package/dist/plan/calls-matrix.d.ts +2 -2
  28. package/dist/plan/calls-matrix.js +3 -2
  29. package/dist/plan/cloudflared-connector.d.ts +3 -2
  30. package/dist/plan/cloudflared-connector.js +5 -3
  31. package/dist/plan/diff.d.ts +15 -2
  32. package/dist/plan/diff.js +399 -51
  33. package/dist/plan/executor.d.ts +307 -61
  34. package/dist/plan/executor.js +489 -129
  35. package/dist/plan/index.d.ts +9 -1
  36. package/dist/plan/index.js +5 -1
  37. package/dist/plan/login-host.d.ts +64 -0
  38. package/dist/plan/login-host.js +52 -0
  39. package/dist/plan/mail-copy.d.ts +125 -0
  40. package/dist/plan/mail-copy.js +166 -0
  41. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  42. package/dist/plan/mail-identity-allowlist.js +11 -0
  43. package/dist/plan/mail-identity.d.ts +68 -0
  44. package/dist/plan/mail-identity.js +17 -0
  45. package/dist/plan/plan-apply.d.ts +26 -8
  46. package/dist/plan/plan-apply.js +10 -9
  47. package/dist/plan/platform-diff.d.ts +207 -10
  48. package/dist/plan/platform-diff.js +403 -15
  49. package/dist/plan/platform-org.d.ts +3 -2
  50. package/dist/plan/platform-providers.d.ts +139 -25
  51. package/dist/plan/platform-providers.js +1531 -95
  52. package/dist/plan/platform-sfu.d.ts +54 -19
  53. package/dist/plan/platform-sfu.js +64 -22
  54. package/dist/plan/providers-readers.js +44 -1
  55. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  56. package/dist/plan/zitadel-machine-user.js +182 -39
  57. package/dist/requested-changes.d.ts +17 -7
  58. package/dist/requested-changes.js +37 -7
  59. package/dist/store/audit.d.ts +40 -4
  60. package/dist/store/audit.js +114 -29
  61. package/dist/store/postgres.js +35 -3
  62. package/dist/store/schema.d.ts +109 -1
  63. package/dist/store/schema.js +20 -0
  64. package/dist/store.d.ts +7 -0
  65. package/package.json +5 -3
  66. package/versions.json +8 -0
@@ -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
  }
@@ -7,8 +7,10 @@ export declare const DEFAULT_GIT_REF = "main";
7
7
  * The Stalwart image tag the platform's mail step runs. `platform.mail.gitRef`
8
8
  * is an image tag, not a git ref, and the planner blocks a floating one
9
9
  * (`platform-providers.ts`, `mailImageTagBlocked`), so this is a pinned release.
10
+ * Pinned in `versions.json` as `stalwart` (#247) -- this is the pattern that
11
+ * file's loader follows.
10
12
  */
11
- export declare const DEFAULT_MAIL_IMAGE_TAG = "v0.16.24";
13
+ export declare const DEFAULT_MAIL_IMAGE_TAG: string;
12
14
  export interface BootstrapCompany {
13
15
  name: string;
14
16
  slug: string;
@@ -1,5 +1,6 @@
1
1
  import { validateOwner } from '../org.js';
2
2
  import { PLATFORM_SERVICE_KEYS, defaultPlatformHostname } from '../platform.js';
3
+ import { VERSIONS } from './versions.js';
3
4
  /** Company Included's own org id, the same literal `test/fakes.ts`'s `basePlatform()` uses for `ownerOrgId`. */
4
5
  export const COMPANY_INCLUDED_ORG_ID = 'org_company_included';
5
6
  export const DEFAULT_GIT_REF = 'main';
@@ -7,8 +8,10 @@ export const DEFAULT_GIT_REF = 'main';
7
8
  * The Stalwart image tag the platform's mail step runs. `platform.mail.gitRef`
8
9
  * is an image tag, not a git ref, and the planner blocks a floating one
9
10
  * (`platform-providers.ts`, `mailImageTagBlocked`), so this is a pinned release.
11
+ * Pinned in `versions.json` as `stalwart` (#247) -- this is the pattern that
12
+ * file's loader follows.
10
13
  */
11
- export const DEFAULT_MAIL_IMAGE_TAG = 'v0.16.24';
14
+ export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
12
15
  /**
13
16
  * Every platform service except `sfu` is enabled, like the other steps: a
14
17
  * service whose dependencies the host did not supply is reported blocked, not
@@ -49,12 +52,18 @@ export function declaredCompanyOrg(company, owner) {
49
52
  slug: company.slug,
50
53
  displayName: company.name,
51
54
  domain: null,
52
- mail: { mode: 'off', usesDomain: false },
53
- chat: { usesDomain: false },
55
+ mail: {
56
+ mode: 'off',
57
+ usesDomain: false,
58
+ ownerMailboxAddress: null,
59
+ noReplyLocalPart: 'no-reply',
60
+ },
61
+ chat: { enabled: false, usesDomain: false },
54
62
  calls: { enabled: false, usesDomain: false },
55
63
  issuer: { usesDomain: false },
56
64
  site: { enabled: false, usesDomain: false },
57
65
  hosting: {
66
+ appEnv: {},
58
67
  enabled: false,
59
68
  usesDomain: false,
60
69
  database: false,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The estate's pinned third-party image/release refs for the platform
3
+ * services foundry provisions (#247). One JSON file, so Renovate can read
4
+ * and later propose bumps against it, instead of four separate string
5
+ * constants it cannot see. `postgres` has no entry here: no planner step
6
+ * pins a postgres image tag today, so there is no ref to record yet.
7
+ */
8
+ export interface PinnedVersions {
9
+ zitadel: string;
10
+ synapse: string;
11
+ mas: string;
12
+ livekit: string;
13
+ cloudflared: string;
14
+ stalwart: string;
15
+ }
16
+ export declare function loadVersions(): PinnedVersions;
17
+ export declare const VERSIONS: PinnedVersions;
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /**
3
+ * Read at module load from `versions.json`, which sits beside this
4
+ * package's `package.json` (not under `src`, so plain JSON import would
5
+ * fight `tsconfig.build.json`'s `rootDir`) and ships in the published
6
+ * tarball via `package.json`'s `files`.
7
+ */
8
+ const VERSIONS_PATH = new URL('../../versions.json', import.meta.url);
9
+ export function loadVersions() {
10
+ return JSON.parse(readFileSync(VERSIONS_PATH, 'utf8'));
11
+ }
12
+ export const VERSIONS = loadVersions();
@@ -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
  /**
@@ -100,11 +116,12 @@ export declare const CLOUDFLARED_ENV_PATH = "/opt/livekit/cloudflared.env";
100
116
  /**
101
117
  * `:latest`, matching this codebase's one other pinned-by-tag-only image
102
118
  * (`scripts/shared-ingress.mjs`'s `CLOUDFLARED_IMAGE_REF`) -- neither runs
103
- * anywhere a supply-chain review has asked for a digest pin yet.
119
+ * anywhere a supply-chain review has asked for a digest pin yet. Pinned in
120
+ * `versions.json` (#247).
104
121
  */
105
- export declare const LIVEKIT_IMAGE_REF = "livekit/livekit-server:latest";
106
- /** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). */
107
- export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:2026.9.3";
122
+ export declare const LIVEKIT_IMAGE_REF: string;
123
+ /** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). Pinned in `versions.json` (#247). */
124
+ export declare const CLOUDFLARED_IMAGE_REF: string;
108
125
  /** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
109
126
  export declare const LIVEKIT_SIGNALLING_PORT = 7880;
110
127
  /** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
@@ -163,6 +180,13 @@ export declare function cloudflaredEnvFile(token: string): string;
163
180
  * has one public address of its own, bridged directly rather than sitting
164
181
  * behind NAT (calls#15's decision comment), so there is a known-good answer
165
182
  * and no reason to make LiveKit discover it via STUN.
183
+ *
184
+ * `keys:` carries one line per entry in {@link CallsSfuConfig.livekitKeys}
185
+ * (#152), sorted by key id -- never object insertion order, which
186
+ * JavaScript does not even guarantee is the order the platform step built
187
+ * the map in. An org dropped from the map (calls turned off) simply has no
188
+ * line here on the next apply; nothing here deletes a key, it only ever
189
+ * writes the set it was given.
166
190
  */
167
191
  export declare function livekitConfigYaml(config: CallsSfuConfig): string;
168
192
  /**
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
@@ -44,6 +52,7 @@
44
52
  * `calls-sfu-deploy-support.ts`'s `fileWriteMode`), never logged, scrubbed
45
53
  * from any error `calls-sfu-deploy.mjs` prints.
46
54
  */
55
+ import { VERSIONS } from './bootstrap/versions.js';
47
56
  // ---------------------------------------------------------------------------
48
57
  // The guard
49
58
  // ---------------------------------------------------------------------------
@@ -97,11 +106,12 @@ export const CLOUDFLARED_ENV_PATH = '/opt/livekit/cloudflared.env';
97
106
  /**
98
107
  * `:latest`, matching this codebase's one other pinned-by-tag-only image
99
108
  * (`scripts/shared-ingress.mjs`'s `CLOUDFLARED_IMAGE_REF`) -- neither runs
100
- * anywhere a supply-chain review has asked for a digest pin yet.
109
+ * anywhere a supply-chain review has asked for a digest pin yet. Pinned in
110
+ * `versions.json` (#247).
101
111
  */
102
- export const LIVEKIT_IMAGE_REF = 'livekit/livekit-server:latest';
103
- /** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). */
104
- export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:2026.9.3';
112
+ export const LIVEKIT_IMAGE_REF = `livekit/livekit-server:${VERSIONS.livekit}`;
113
+ /** Pinned to a release tag; equals `scripts/shared-ingress.mjs`'s own `CLOUDFLARED_IMAGE_REF` constant (duplicated, not imported -- this package and that top-level script sit in separate places in the tree). Pinned in `versions.json` (#247). */
114
+ export const CLOUDFLARED_IMAGE_REF = `cloudflare/cloudflared:${VERSIONS.cloudflared}`;
105
115
  /** LiveKit signalling -- reachable only through the tunnel as of #127 (see {@link CALLS_SFU_PRIVATE_CIDR}). */
106
116
  export const LIVEKIT_SIGNALLING_PORT = 7880;
107
117
  /** Media fallback for networks that block UDP -- reaches the SFU's own address directly. */
@@ -191,9 +201,20 @@ export function cloudflaredEnvFile(token) {
191
201
  * has one public address of its own, bridged directly rather than sitting
192
202
  * behind NAT (calls#15's decision comment), so there is a known-good answer
193
203
  * and no reason to make LiveKit discover it via STUN.
204
+ *
205
+ * `keys:` carries one line per entry in {@link CallsSfuConfig.livekitKeys}
206
+ * (#152), sorted by key id -- never object insertion order, which
207
+ * JavaScript does not even guarantee is the order the platform step built
208
+ * the map in. An org dropped from the map (calls turned off) simply has no
209
+ * line here on the next apply; nothing here deletes a key, it only ever
210
+ * writes the set it was given.
194
211
  */
195
212
  export function livekitConfigYaml(config) {
196
213
  const address = requireCallsSfuAddress(config.address);
214
+ const keys = Object.entries(config.livekitKeys).sort(([a], [b]) => a.localeCompare(b));
215
+ const keysYaml = keys.length === 0
216
+ ? 'keys: {}\n'
217
+ : `keys:\n${keys.map(([apiKey, apiSecret]) => ` ${apiKey}: ${apiSecret}`).join('\n')}\n`;
197
218
  return `port: ${LIVEKIT_SIGNALLING_PORT}
198
219
  rtc:
199
220
  tcp_port: ${LIVEKIT_MEDIA_TCP_PORT}
@@ -201,9 +222,7 @@ rtc:
201
222
  port_range_end: 60000
202
223
  use_external_ip: false
203
224
  node_ip: ${address}
204
- keys:
205
- ${config.livekitApiKey}: ${config.livekitApiSecret}
206
- `;
225
+ ${keysYaml}`;
207
226
  }
208
227
  async function run(host, command) {
209
228
  const result = await host.exec(command);
@@ -211,6 +230,20 @@ async function run(host, command) {
211
230
  throw new Error(`"${command}" exited ${result.exitCode}: ${result.stderr || result.stdout}`);
212
231
  }
213
232
  }
233
+ /**
234
+ * Writes `contents` to `${path}.tmp` and renames it onto `path` with `mv`,
235
+ * rather than writing `path` directly -- `mv` within the same directory is a
236
+ * single rename syscall, so a reader of `path` (this function's own
237
+ * `cat`-and-compare in `calls-sfu-ssh-adapter.ts`, or LiveKit's own read at
238
+ * container start) only ever sees the old complete file or the new complete
239
+ * one, never a partial write from one apply interleaved with another's
240
+ * (#152 round 2 review, finding 3).
241
+ */
242
+ async function writeFileAtomically(host, path, contents) {
243
+ const tmpPath = `${path}.tmp`;
244
+ await host.writeFile(tmpPath, contents);
245
+ await run(host, `mv -f ${tmpPath} ${path}`);
246
+ }
214
247
  /**
215
248
  * Writes all three files, brings LiveKit and `cloudflared` up, then narrows
216
249
  * the VM firewall to exactly what #122's and #127's "Decided" sections name
@@ -240,7 +273,7 @@ export async function applyCallsSfu(host, config) {
240
273
  requireCallsSfuAddress(config.address);
241
274
  requireCloudflaredTunnelToken(config.cloudflaredTunnelToken);
242
275
  await host.writeFile(LIVEKIT_COMPOSE_PATH, dockerComposeYaml());
243
- await host.writeFile(LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
276
+ await writeFileAtomically(host, LIVEKIT_CONFIG_PATH, livekitConfigYaml(config));
244
277
  await host.writeFile(CLOUDFLARED_ENV_PATH, cloudflaredEnvFile(config.cloudflaredTunnelToken));
245
278
  await run(host, `docker compose -f ${LIVEKIT_COMPOSE_PATH} up -d`);
246
279
  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';
@@ -20,14 +20,18 @@ export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
20
20
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
21
21
  export type { PlatformOrgClientFactories } from './plan/index.js';
22
22
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
23
- export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, } from './plan/index.js';
24
- export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
23
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, } from './plan/index.js';
24
+ export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } 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';
@@ -8,15 +8,18 @@ export { declaredOrgs, foundryAppliedState, foundryAuditEvents, foundryDeclaredP
8
8
  export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
9
9
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
10
10
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
11
- export { FoundryStepExecutor, NotBuiltError, healthUrls } from './plan/index.js';
11
+ export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } 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;
@@ -0,0 +1,6 @@
1
+ -- @wtfalch/foundry: chat is not in v1 (decided 2026-10-01). Mirrors
2
+ -- `src/store/schema.ts`'s `chatEnabled`. Defaults false, so every existing org
3
+ -- reads as chat off and `apply` leaves any live chat resource alone.
4
+
5
+ alter table foundry_declared_orgs
6
+ add column if not exists chat_enabled boolean not null default false;
@@ -0,0 +1,8 @@
1
+ -- @wtfalch/foundry: the bespoke app's own env var names for values the plan
2
+ -- already mints (Files, AI, mail), each a key `pushAppEnv` resolves (#243).
3
+ -- Mirrors `src/store/schema.ts`'s `hostingAppEnv`. Defaults to an empty
4
+ -- object so every existing org keeps its current behaviour: no app env
5
+ -- pushed from this map.
6
+
7
+ alter table foundry_declared_orgs
8
+ add column if not exists hosting_app_env jsonb not null default '{}'::jsonb;