@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
@@ -0,0 +1,111 @@
1
+ -- Copied verbatim from @wtfalch/audit 0.6.0's own migrations/ (#232), the
2
+ -- same way that package's own audit-migrations CLI copies them into a host's
3
+ -- drizzle/ directory -- foundry has no drizzle/ journal, so this file is
4
+ -- that copy, hand-placed at the next free number instead. Never edit the
5
+ -- copied SQL below; a fix ships as a new numbered file, same as upstream.
6
+ --
7
+ -- @wtfalch/audit: row-level security on audit_events.
8
+ --
9
+ -- Mirrors src/ledger.ts (scopeAuditTenant). Every statement is idempotent.
10
+ -- Applied by the host's own migrate script after the host copies this file
11
+ -- into its drizzle/ directory as the next number (audit-migrations); never
12
+ -- edited there.
13
+ --
14
+ -- Until this file, tenant isolation was only the `tenantId` a caller
15
+ -- remembered to pass to ledger.page(). One missed predicate showed a
16
+ -- customer another company's trail. With RLS on, the database holds the
17
+ -- line for every role that is not the table's owner -- the runtime role
18
+ -- <database>_rt, and any read-only role a host adds:
19
+ --
20
+ -- audit.tenant_id -- set per transaction by the host
21
+ -- (scopeAuditTenant, or
22
+ -- set_config('audit.tenant_id', $1, true)).
23
+ -- While set, a SELECT sees that tenant's rows and
24
+ -- nothing else -- not another tenant's, and not
25
+ -- the estate's own tenant_id-null rows.
26
+ -- audit.require_tenant -- 'on' makes an unscoped SELECT see nothing
27
+ -- instead of everything. Set it on a role, once:
28
+ -- alter role <database>_rt set audit.require_tenant = 'on';
29
+ -- That is the setting that survives a forgotten
30
+ -- scope. It is not set here because an operator
31
+ -- surface reads across tenants on purpose, and
32
+ -- which role does that is the host's decision.
33
+ --
34
+ -- Unset and not required, a SELECT sees every row, exactly as before this
35
+ -- file: applying it changes nothing for a host until the host scopes.
36
+ --
37
+ -- INSERT is not restricted: a request scoped to one tenant may still record
38
+ -- an estate-level (tenant_id null) event, and the ledger's own vocabulary
39
+ -- checks already decide what may be written. UPDATE and DELETE have no
40
+ -- policy, so they stay refused for the runtime role -- which 0001's revoke
41
+ -- already did.
42
+ --
43
+ -- Not FORCE: the owner, and the security definer functions it owns
44
+ -- (audit_erase_person, audit_seal_erasure, and the two below), bypass RLS.
45
+ -- Erasure is by subject, across tenants, by design.
46
+
47
+ alter table audit_events enable row level security;
48
+
49
+ drop policy if exists audit_events_read on audit_events;
50
+ create policy audit_events_read on audit_events
51
+ for select
52
+ using (
53
+ case
54
+ when coalesce(current_setting('audit.tenant_id', true), '') <> ''
55
+ then tenant_id is not null
56
+ and tenant_id = current_setting('audit.tenant_id', true)::uuid
57
+ when coalesce(current_setting('audit.require_tenant', true), '') = 'on'
58
+ then false
59
+ else true
60
+ end
61
+ );
62
+
63
+ drop policy if exists audit_events_append on audit_events;
64
+ create policy audit_events_append on audit_events
65
+ for insert
66
+ with check (true);
67
+
68
+ -- The hash chain is one chain across every tenant. Under a tenant scope the
69
+ -- runtime role can no longer see the true tail, so ledger.sign() with
70
+ -- hashChain on would chain onto that tenant's last row and fork the chain.
71
+ -- This reads the tail as the owner. It returns a hash, never content.
72
+ create or replace function audit_chain_tail()
73
+ returns text
74
+ language sql
75
+ stable
76
+ security definer
77
+ set search_path = pg_catalog, public
78
+ as $$
79
+ select row_hash from audit_events order by id desc limit 1
80
+ $$;
81
+ revoke all on function audit_chain_tail() from public;
82
+
83
+ -- The same for ledger.erase()'s pending-erasure sweep, which must find
84
+ -- every tenant's pending rows, scoped or not. Ids, hashes and erasure
85
+ -- instants only.
86
+ create or replace function audit_pending_erasures()
87
+ returns table (id bigint, row_hash text, erased_at timestamptz)
88
+ language sql
89
+ stable
90
+ security definer
91
+ set search_path = pg_catalog, public
92
+ as $$
93
+ select e.id, e.row_hash, e.erased_at
94
+ from audit_events e
95
+ where e.row_hash is not null
96
+ and e.erased_at is not null
97
+ and e.erasure_hash is null
98
+ order by e.id
99
+ $$;
100
+ revoke all on function audit_pending_erasures() from public;
101
+
102
+ do $$
103
+ declare
104
+ rt text := current_database() || '_rt';
105
+ begin
106
+ if exists (select 1 from pg_roles where rolname = rt) then
107
+ execute format('grant execute on function audit_chain_tail() to %I', rt);
108
+ execute format('grant execute on function audit_pending_erasures() to %I', rt);
109
+ end if;
110
+ end
111
+ $$;
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
  *
@@ -49,6 +99,12 @@ export interface MailConfig {
49
99
  * Never fired by `domain` alone (#7 amendment 1).
50
100
  */
51
101
  export interface ChatConfig {
102
+ /**
103
+ * Chat is not in v1 (decided 2026-10-01): off by default for every org.
104
+ * While off, `apply` plans no chat step and leaves a live chat resource
105
+ * alone, listed in `unmanaged`; Agora hides all chat and call UI.
106
+ */
107
+ enabled: boolean;
52
108
  usesDomain: boolean;
53
109
  }
54
110
  /**
@@ -164,7 +220,25 @@ export interface HostingConfig {
164
220
  * means no Access gate. Requires `enabled`.
165
221
  */
166
222
  accessEmails: string[];
223
+ /**
224
+ * Maps the app's own env var name to one of `AppEnvSource`'s values --
225
+ * each a value the plan already mints for this org (Files, AI, mail) but
226
+ * never used to push an app's own env (#243). Each bespoke app names these
227
+ * differently (lokessmie uses `STORAGE_*`/`AI_*`); this is how an org
228
+ * declares its own app's names for them. `{}` means the app names none of
229
+ * them, so `pushAppEnv` pushes nothing from this map.
230
+ */
231
+ appEnv: Record<string, AppEnvSource>;
167
232
  }
233
+ /**
234
+ * The closed set of values the plan already mints that `HostingConfig.appEnv`
235
+ * may map an app's env var name to (#243): the org's own Files organisation
236
+ * id, its AI organisation id, and the mail domain its Lettermint project
237
+ * sends from. Each is read from `StepExecutionContext.recordedIds()` in
238
+ * `executor.ts`'s `pushAppEnv`, never minted again there.
239
+ */
240
+ export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain';
241
+ export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
168
242
  /**
169
243
  * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
170
244
  * shape as `validateBuyerIdentity` below.
@@ -329,6 +403,37 @@ export interface OwnerIdentity {
329
403
  * null` (no owner named yet) is always valid.
330
404
  */
331
405
  export declare function validateOwner(owner: OwnerIdentity | null): string | null;
406
+ /**
407
+ * `null` when `mail` is valid; otherwise the reason it is not (#162,
408
+ * decided 2026-09-29).
409
+ *
410
+ * `ownerMailboxAddress`, when set, must be a valid email address; when
411
+ * `usesDomain` is also true and `domain` is set, it must be on that exact
412
+ * domain ("validate it is on the org's own domain", #162's "How"). A
413
+ * level-1 org's mail domain is `<slug>.<platform apex>` -- the platform's
414
+ * own fact, not this file's -- so that case is left for `plan/diff.ts`'s
415
+ * `planMail` to resolve and block on, the same way it already resolves
416
+ * `mailHost` itself.
417
+ *
418
+ * Deliberately NOT required just because `mode` is not `'off'`: an org can
419
+ * have mail on with no address declared yet, same as it can have an owner
420
+ * named with no ZITADEL invite sent yet -- `planMail` reports the mailbox
421
+ * alone as blocked in that case rather than this function refusing to let
422
+ * the org be saved at all (#162 "Done when": "mail on with no owner address
423
+ * plans a blocked item and still applies the other steps").
424
+ *
425
+ * `noReplyLocalPart`, when set, must be non-empty and contain neither `@`
426
+ * nor whitespace.
427
+ *
428
+ * `roleMailboxes`, when set (#230), has only plain local parts (no `@`, `,` or
429
+ * whitespace) and no duplicate, ignoring case.
430
+ *
431
+ * `mailApps`, when set (#34), has no empty entry and no duplicate -- same
432
+ * two rules `validateHosting` already applies to `issuerApps`' names, for
433
+ * the same reason: a duplicate or blank entry can never mean anything
434
+ * different from leaving it out.
435
+ */
436
+ export declare function validateMail(org: Pick<DeclaredOrg, 'mail' | 'domain'>): string | null;
332
437
  /**
333
438
  * The form an app foundry does not own takes. Decided in #7's `#15`
334
439
  * refinement comment:
package/dist/org.js CHANGED
@@ -1,3 +1,12 @@
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';
5
+ export const APP_ENV_SOURCES = new Set([
6
+ 'filesOrganisationId',
7
+ 'aiOrganisationId',
8
+ 'mailDomain',
9
+ ]);
1
10
  const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
2
11
  const HOSTING_BUILD_PACKS = new Set([
3
12
  'dockerfile',
@@ -58,6 +67,14 @@ export function validateHosting(hosting) {
58
67
  return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
59
68
  }
60
69
  }
70
+ for (const [envName, source] of Object.entries(hosting.appEnv)) {
71
+ if (!envName) {
72
+ return 'every appEnv key needs a non-empty env var name';
73
+ }
74
+ if (!APP_ENV_SOURCES.has(source)) {
75
+ return `appEnv '${envName}' has an unknown source '${source}'`;
76
+ }
77
+ }
61
78
  return null;
62
79
  }
63
80
  /**
@@ -185,3 +202,87 @@ export function validateOwner(owner) {
185
202
  return 'owner.familyName must not be empty';
186
203
  return null;
187
204
  }
205
+ /** A local part has no `@` and no whitespace -- loose on purpose, the same restraint {@link OWNER_EMAIL_PATTERN}'s own doc comment states. */
206
+ const LOCAL_PART_PATTERN = /^[^\s@]+$/;
207
+ /**
208
+ * `null` when `mail` is valid; otherwise the reason it is not (#162,
209
+ * decided 2026-09-29).
210
+ *
211
+ * `ownerMailboxAddress`, when set, must be a valid email address; when
212
+ * `usesDomain` is also true and `domain` is set, it must be on that exact
213
+ * domain ("validate it is on the org's own domain", #162's "How"). A
214
+ * level-1 org's mail domain is `<slug>.<platform apex>` -- the platform's
215
+ * own fact, not this file's -- so that case is left for `plan/diff.ts`'s
216
+ * `planMail` to resolve and block on, the same way it already resolves
217
+ * `mailHost` itself.
218
+ *
219
+ * Deliberately NOT required just because `mode` is not `'off'`: an org can
220
+ * have mail on with no address declared yet, same as it can have an owner
221
+ * named with no ZITADEL invite sent yet -- `planMail` reports the mailbox
222
+ * alone as blocked in that case rather than this function refusing to let
223
+ * the org be saved at all (#162 "Done when": "mail on with no owner address
224
+ * plans a blocked item and still applies the other steps").
225
+ *
226
+ * `noReplyLocalPart`, when set, must be non-empty and contain neither `@`
227
+ * nor whitespace.
228
+ *
229
+ * `roleMailboxes`, when set (#230), has only plain local parts (no `@`, `,` or
230
+ * whitespace) and no duplicate, ignoring case.
231
+ *
232
+ * `mailApps`, when set (#34), has no empty entry and no duplicate -- same
233
+ * two rules `validateHosting` already applies to `issuerApps`' names, for
234
+ * the same reason: a duplicate or blank entry can never mean anything
235
+ * different from leaving it out.
236
+ */
237
+ export function validateMail(org) {
238
+ const { mail } = org;
239
+ const address = mail.ownerMailboxAddress ?? null;
240
+ if (address !== null) {
241
+ if (!OWNER_EMAIL_PATTERN.test(address)) {
242
+ return `mail.ownerMailboxAddress '${address}' is not a valid email address`;
243
+ }
244
+ if (mail.usesDomain && org.domain) {
245
+ const addressDomain = address.slice(address.indexOf('@') + 1).toLowerCase();
246
+ if (addressDomain !== org.domain.toLowerCase()) {
247
+ return `mail.ownerMailboxAddress '${address}' must be on ${org.domain}`;
248
+ }
249
+ }
250
+ }
251
+ if (mail.noReplyLocalPart !== undefined && !LOCAL_PART_PATTERN.test(mail.noReplyLocalPart)) {
252
+ return `mail.noReplyLocalPart '${mail.noReplyLocalPart}' must not be empty or contain '@' or whitespace`;
253
+ }
254
+ if (mail.roleMailboxes) {
255
+ const seen = new Set();
256
+ const reserved = new Map([
257
+ [(mail.noReplyLocalPart ?? DEFAULT_NO_REPLY_LOCAL_PART).toLowerCase(), 'no-reply'],
258
+ ]);
259
+ if (address !== null)
260
+ reserved.set(address.slice(0, address.indexOf('@')).toLowerCase(), 'owner');
261
+ for (const name of mail.roleMailboxes) {
262
+ if (!LOCAL_PART_PATTERN.test(name) || name.includes(',')) {
263
+ return `mail.roleMailboxes entry '${name}' must be a non-empty local part without '@', ',' or whitespace`;
264
+ }
265
+ if (seen.has(name.toLowerCase())) {
266
+ return `mail.roleMailboxes name '${name}' is not unique`;
267
+ }
268
+ const clash = reserved.get(name.toLowerCase());
269
+ if (clash) {
270
+ return `mail.roleMailboxes name '${name}' clashes with the ${clash} mailbox`;
271
+ }
272
+ seen.add(name.toLowerCase());
273
+ }
274
+ }
275
+ if (mail.mailApps) {
276
+ const seen = new Set();
277
+ for (const name of mail.mailApps) {
278
+ if (!name) {
279
+ return 'mail.mailApps entry needs a non-empty name';
280
+ }
281
+ if (seen.has(name)) {
282
+ return `mail.mailApps name '${name}' is not unique`;
283
+ }
284
+ seen.add(name);
285
+ }
286
+ }
287
+ return null;
288
+ }
@@ -13,6 +13,27 @@ import type { DeclaredOrg, OrgId } from '../org.js';
13
13
  */
14
14
  export declare const ZITADEL_ORG_SCOPE_KEY = "zitadel_org";
15
15
  export declare const AGORA_CLIENT_ID_SCOPE_KEY = "agora_client_id";
16
+ /**
17
+ * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
18
+ * not a `system:kind:key` resource id (that slot already holds the app's
19
+ * `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
20
+ * is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
21
+ * above. A level-1 org declares exactly one web `issuerApps` entry for its
22
+ * one standard app, so one flat key is enough; an org with more than one web
23
+ * app would need a richer key, not built here (Finding, this campaign's
24
+ * report).
25
+ */
26
+ export declare const APP_CLIENT_ID_SCOPE_KEY = "zitadel_app_client_id";
27
+ /**
28
+ * #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
29
+ * separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
30
+ * org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
31
+ * `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
32
+ * plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
33
+ * the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
34
+ * app.
35
+ */
36
+ export declare const CMS_CLIENT_ID_SCOPE_KEY = "cms_client_id";
16
37
  /**
17
38
  * The accent every org's Agora wears until orgs choose their own: the house
18
39
  * theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
@@ -12,6 +12,27 @@
12
12
  */
13
13
  export const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
14
14
  export const AGORA_CLIENT_ID_SCOPE_KEY = 'agora_client_id';
15
+ /**
16
+ * The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
17
+ * not a `system:kind:key` resource id (that slot already holds the app's
18
+ * `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
19
+ * is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
20
+ * above. A level-1 org declares exactly one web `issuerApps` entry for its
21
+ * one standard app, so one flat key is enough; an org with more than one web
22
+ * app would need a richer key, not built here (Finding, this campaign's
23
+ * report).
24
+ */
25
+ export const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
26
+ /**
27
+ * #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
28
+ * separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
29
+ * org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
30
+ * `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
31
+ * plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
32
+ * the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
33
+ * app.
34
+ */
35
+ export const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
15
36
  /**
16
37
  * The accent every org's Agora wears until orgs choose their own: the house
17
38
  * theme's `--accent` (`@wtfalch/house`, `HOUSE_THEMES['good-company']` in
@@ -85,6 +106,8 @@ export function desiredAgoraEnv(org, recorded, host) {
85
106
  organisationId: zitadelOrgId,
86
107
  mailProjectId: host.mailProjectId,
87
108
  messagingProjectId: host.messagingProjectId,
109
+ // Chat is not in v1: Agora hides all chat UI unless this is true.
110
+ chat: { enabled: org.chat.enabled },
88
111
  });
89
112
  }
90
113
  if (org.mail.mode !== 'off' && host.mailUpstream) {
@@ -45,8 +45,8 @@
45
45
  * (`executor.ts`'s `callsHostnameFor`).
46
46
  */
47
47
  export declare const SYNAPSE_IMAGE = "ghcr.io/element-hq/synapse";
48
- export declare const SYNAPSE_TAG = "v1.161.0";
49
- export declare const MAS_IMAGE = "ghcr.io/element-hq/matrix-authentication-service:1.24.0";
48
+ export declare const SYNAPSE_TAG: string;
49
+ export declare const MAS_IMAGE: string;
50
50
  /**
51
51
  * Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
52
52
  * `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
@@ -1,4 +1,5 @@
1
1
  import { generateKeyPairSync, randomBytes } from 'node:crypto';
2
+ import { VERSIONS } from '../bootstrap/versions.js';
2
3
  /**
3
4
  * The calls Matrix stack (#128, part 1 of 2 -- see that issue's "If this
4
5
  * passes about 800 changed lines, split it" note): pure config generation
@@ -46,8 +47,8 @@ import { generateKeyPairSync, randomBytes } from 'node:crypto';
46
47
  * (`executor.ts`'s `callsHostnameFor`).
47
48
  */
48
49
  export const SYNAPSE_IMAGE = 'ghcr.io/element-hq/synapse';
49
- export const SYNAPSE_TAG = 'v1.161.0';
50
- export const MAS_IMAGE = 'ghcr.io/element-hq/matrix-authentication-service:1.24.0';
50
+ export const SYNAPSE_TAG = VERSIONS.synapse;
51
+ export const MAS_IMAGE = `ghcr.io/element-hq/matrix-authentication-service:${VERSIONS.mas}`;
51
52
  /**
52
53
  * Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
53
54
  * `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
@@ -2,9 +2,10 @@ import type { CloudflarePort, CoolifyPort } from './executor.js';
2
2
  /**
3
3
  * #197: the pinned `cloudflared` image, the same release tag as
4
4
  * `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
5
- * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
5
+ * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal. Pinned in
6
+ * `versions.json` (#247).
6
7
  */
7
- export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:2026.9.3";
8
+ export declare const CLOUDFLARED_IMAGE_REF: string;
8
9
  /**
9
10
  * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
10
11
  * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
@@ -1,10 +1,12 @@
1
+ import { VERSIONS } from '../bootstrap/versions.js';
1
2
  import { isNotFoundError } from './executor.js';
2
3
  /**
3
4
  * #197: the pinned `cloudflared` image, the same release tag as
4
5
  * `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
5
- * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
6
+ * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal. Pinned in
7
+ * `versions.json` (#247).
6
8
  */
7
- export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:2026.9.3';
9
+ export const CLOUDFLARED_IMAGE_REF = `cloudflare/cloudflared:${VERSIONS.cloudflared}`;
8
10
  /**
9
11
  * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
10
12
  * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
@@ -72,7 +74,7 @@ export async function ensureCloudflaredConnector(input) {
72
74
  environment_uuid: input.environment.uuid,
73
75
  dockerfile: cloudflaredDockerfile(),
74
76
  name: input.appName,
75
- description: 'cloudflared connector for this platform tunnel (#197)',
77
+ description: 'cloudflared connector for a Cloudflare tunnel',
76
78
  instant_deploy: false,
77
79
  });
78
80
  appId = created.uuid;
@@ -26,6 +26,15 @@ export interface PlannedStep {
26
26
  step: PlanStep;
27
27
  action: StepAction;
28
28
  }
29
+ /**
30
+ * Facts about the host's executor ports that an org's plan needs, so a step
31
+ * whose port is not wired is reported blocked instead of planned (rule 2).
32
+ * New ones go here, not in new positional arguments (rule 7).
33
+ */
34
+ export interface PlanOrgOptions {
35
+ /** #267: the executor was built with a `loginHostBindings` port. */
36
+ loginHostBindings?: boolean;
37
+ }
29
38
  export interface PlanDetail {
30
39
  orgId: OrgId;
31
40
  plannedSteps: PlannedStep[];
@@ -73,7 +82,11 @@ export declare function toPlan(detail: PlanDetail): Plan;
73
82
  * app with the platform's jobs scheduler, idempotent on the *recorded*
74
83
  * resource id like {@link planOwner} rather than a live resource, since
75
84
  * nothing in `live` can report a scheduler registration (no reader exists for
76
- * it). Everything
85
+ * it). #267 adds one more, right after {@link planOwner} and before every
86
+ * app-creating step below: {@link planLoginHost}, the org's own company
87
+ * login host -- also idempotent on the *recorded* resource id, since
88
+ * `live` has no notion of a ZITADEL trusted domain or a login binding
89
+ * either. Everything
77
90
  * else in `live` that none of these eighteen singletons and however many
78
91
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
79
92
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
@@ -81,7 +94,7 @@ export declare function toPlan(detail: PlanDetail): Plan;
81
94
  * Company Factory end to end", which wires real readers and executors
82
95
  * against this seam.
83
96
  */
84
- export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness): PlanDetail;
97
+ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness, options?: PlanOrgOptions): PlanDetail;
85
98
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
86
99
  export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
87
100
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */