@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.
- package/dist/bootstrap/bootstrap.d.ts +16 -1
- package/dist/bootstrap/bootstrap.js +40 -2
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +12 -3
- package/dist/bootstrap/versions.d.ts +17 -0
- package/dist/bootstrap/versions.js +12 -0
- 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 +36 -12
- package/dist/calls-sfu.js +42 -9
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -3
- 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/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/org.d.ts +105 -0
- package/dist/org.js +101 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +2 -2
- package/dist/plan/calls-matrix.js +3 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +15 -2
- package/dist/plan/diff.js +399 -51
- package/dist/plan/executor.d.ts +307 -61
- package/dist/plan/executor.js +489 -129
- package/dist/plan/index.d.ts +9 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/login-host.d.ts +64 -0
- package/dist/plan/login-host.js +52 -0
- 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 +26 -8
- package/dist/plan/plan-apply.js +10 -9
- package/dist/plan/platform-diff.d.ts +207 -10
- package/dist/plan/platform-diff.js +403 -15
- package/dist/plan/platform-org.d.ts +3 -2
- package/dist/plan/platform-providers.d.ts +139 -25
- package/dist/plan/platform-providers.js +1531 -95
- package/dist/plan/platform-sfu.d.ts +54 -19
- package/dist/plan/platform-sfu.js +64 -22
- package/dist/plan/providers-readers.js +44 -1
- 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/audit.d.ts +40 -4
- package/dist/store/audit.js +114 -29
- package/dist/store/postgres.js +35 -3
- package/dist/store/schema.d.ts +109 -1
- package/dist/store/schema.js +20 -0
- package/dist/store.d.ts +7 -0
- package/package.json +5 -3
- 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
|
+
}
|
package/dist/plan/agora.d.ts
CHANGED
|
@@ -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
|
package/dist/plan/agora.js
CHANGED
|
@@ -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
|
|
49
|
-
export declare const MAS_IMAGE
|
|
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 =
|
|
50
|
-
export const MAS_IMAGE =
|
|
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
|
|
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 =
|
|
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
|
|
77
|
+
description: 'cloudflared connector for a Cloudflare tunnel',
|
|
76
78
|
instant_deploy: false,
|
|
77
79
|
});
|
|
78
80
|
appId = created.uuid;
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -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).
|
|
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. */
|