@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.
- package/dist/bootstrap/bootstrap.d.ts +16 -1
- package/dist/bootstrap/bootstrap.js +40 -2
- package/dist/bootstrap/declare.js +6 -1
- package/dist/calls-sfu-deploy-support.d.ts +8 -0
- package/dist/calls-sfu-deploy-support.js +10 -1
- package/dist/calls-sfu.d.ts +31 -8
- package/dist/calls-sfu.js +36 -5
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -2
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/org.d.ts +81 -0
- package/dist/org.js +88 -0
- package/dist/plan/diff.js +93 -10
- package/dist/plan/executor.d.ts +236 -35
- package/dist/plan/executor.js +274 -76
- package/dist/plan/index.d.ts +7 -1
- package/dist/plan/index.js +4 -1
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +5 -0
- package/dist/plan/platform-diff.d.ts +184 -9
- package/dist/plan/platform-diff.js +368 -15
- package/dist/plan/platform-org.d.ts +3 -2
- package/dist/plan/platform-providers.d.ts +138 -24
- package/dist/plan/platform-providers.js +1460 -79
- package/dist/plan/platform-sfu.d.ts +54 -19
- package/dist/plan/platform-sfu.js +64 -22
- package/dist/plan/zitadel-machine-user.d.ts +60 -11
- package/dist/plan/zitadel-machine-user.js +182 -39
- package/dist/requested-changes.d.ts +17 -7
- package/dist/requested-changes.js +37 -7
- package/dist/store/postgres.js +31 -2
- package/dist/store/schema.d.ts +72 -0
- package/dist/store/schema.js +13 -0
- package/dist/store.d.ts +7 -0
- package/package.json +1 -1
|
@@ -7,7 +7,16 @@ export interface BootstrapPlatformInput {
|
|
|
7
7
|
company: BootstrapCompany;
|
|
8
8
|
owner: BootstrapOwner;
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
package/dist/calls-sfu.d.ts
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
|
|
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),
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
+
}
|