@wtfalch/foundry 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.js +6 -1
  4. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  5. package/dist/calls-sfu-deploy-support.js +10 -1
  6. package/dist/calls-sfu.d.ts +31 -8
  7. package/dist/calls-sfu.js +36 -5
  8. package/dist/index.d.ts +8 -2
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  11. package/dist/migrations/0020_mail_apps.sql +7 -0
  12. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  13. package/dist/org.d.ts +81 -0
  14. package/dist/org.js +88 -0
  15. package/dist/plan/diff.js +93 -10
  16. package/dist/plan/executor.d.ts +236 -35
  17. package/dist/plan/executor.js +274 -76
  18. package/dist/plan/index.d.ts +7 -1
  19. package/dist/plan/index.js +4 -1
  20. package/dist/plan/mail-copy.d.ts +125 -0
  21. package/dist/plan/mail-copy.js +166 -0
  22. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  23. package/dist/plan/mail-identity-allowlist.js +11 -0
  24. package/dist/plan/mail-identity.d.ts +68 -0
  25. package/dist/plan/mail-identity.js +17 -0
  26. package/dist/plan/plan-apply.d.ts +5 -0
  27. package/dist/plan/platform-diff.d.ts +184 -9
  28. package/dist/plan/platform-diff.js +368 -15
  29. package/dist/plan/platform-org.d.ts +3 -2
  30. package/dist/plan/platform-providers.d.ts +138 -24
  31. package/dist/plan/platform-providers.js +1460 -79
  32. package/dist/plan/platform-sfu.d.ts +54 -19
  33. package/dist/plan/platform-sfu.js +64 -22
  34. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  35. package/dist/plan/zitadel-machine-user.js +182 -39
  36. package/dist/requested-changes.d.ts +17 -7
  37. package/dist/requested-changes.js +37 -7
  38. package/dist/store/postgres.js +31 -2
  39. package/dist/store/schema.d.ts +72 -0
  40. package/dist/store/schema.js +13 -0
  41. package/dist/store.d.ts +7 -0
  42. package/package.json +1 -1
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Copies an org's old mail into Stalwart before its `received` -> `hosted`
3
+ * MX cutover (#113), using `@wtfalch/email/postmaster`'s `mailCopyPlan` to
4
+ * build the exact Vandelay `import`/`export` commands and nothing more --
5
+ * this module decides when to run them and what to do with the result, the
6
+ * same split `calls-sfu.ts` draws between a pure plan builder and the apply
7
+ * logic around it.
8
+ *
9
+ * Three things this step needs and never invents itself, mirroring
10
+ * `plan/platform-sfu.ts`'s own "a host supplies real clients" rule:
11
+ *
12
+ * - {@link MailCopyPort.resolveAccounts}: which accounts to copy, and where
13
+ * each one's mail lands in Stalwart. Foundry tracks only an org's owner
14
+ * mailbox (`org.mail.ownerMailboxAddress`) -- it has no roster of every
15
+ * mailbox a Google Workspace or Microsoft 365 tenant holds, so the full
16
+ * list is a fact a human supplies, read back here exactly the way a held
17
+ * SSH key or API credential already is elsewhere in this package.
18
+ * - {@link MailCopyPort.resolveSourceToken}: the org's own admin bearer
19
+ * token for its old provider, a per-org held key (#113 "Decided",
20
+ * 2026-09-30) -- never an environment variable this package sets itself.
21
+ * - {@link MailCopyPort.resolveStalwartToken}: the platform's own Stalwart
22
+ * export credential (the admin key `impersonate` already covers,
23
+ * `plan/platform-providers.ts`'s `provisionPlatformMail`), read back by
24
+ * the host rather than minted again here.
25
+ *
26
+ * {@link MailCopyPort.run} is the fourth port: running one Vandelay command
27
+ * to completion in a one-off container (#113 "Decided": Coolify) and
28
+ * reporting how it went. Never throws on a non-zero exit -- the caller
29
+ * checks `exitCode`, the same "write a file, run a command, the caller
30
+ * checks it" shape `CallsSfuHostPort.exec` already uses, for the same
31
+ * reason: every value a real implementation might echo into stdout/stderr
32
+ * is the host's to redact, never this function's problem.
33
+ *
34
+ * **No new step type.** #113's first decision (2026-09-30) asked for a
35
+ * resumable step type of its own; the second (2026-10-01) replaced it with
36
+ * "keep foundry's one step contract": this step runs once per `apply`, the
37
+ * same as every other, and resumes only because `runMailCopyStep` is
38
+ * idempotent -- skip an account already recorded done, stop at the first
39
+ * one that fails this round, and let the *planner* (`plan/diff.ts`'s
40
+ * `planMailCopy`) decide whether to show the step again next time. "Rely on
41
+ * Vandelay's own rate limit for the Gmail daily cap" (#113 "Decided") means
42
+ * this function never inspects *why* an account failed -- a provider's
43
+ * daily transfer cap and a real failure look the same from here, so it
44
+ * always just stops and lets the next apply retry, rather than guessing.
45
+ */
46
+ import type { MailCopyAccount, MailCopyProvider } from '@wtfalch/email/postmaster';
47
+ import type { DeclaredOrg } from '../org.js';
48
+ /** Scope id: a JSON array of account `source` addresses already fully copied for this org. Read by `plan/diff.ts`'s `planMailCopy` to report progress, and by `executor.ts`'s `mailCopy` to skip a finished account on the next apply. */
49
+ export declare const MAIL_COPY_COMPLETED_SCOPE_KEY = "mail_copy_completed_accounts";
50
+ /** Scope id: set to `'1'` once every account in the resolved plan has copied cleanly. `plan/diff.ts`'s `planMail` gates the `received` -> `hosted` mode flip on this being set. */
51
+ export declare const MAIL_COPY_DONE_SCOPE_KEY = "mail_copy_done";
52
+ /**
53
+ * Parses {@link MAIL_COPY_COMPLETED_SCOPE_KEY}'s recorded value back into
54
+ * the set of account addresses already copied. `[]` for anything that is
55
+ * not a JSON array of strings -- absent, corrupt, or a shape from before
56
+ * this existed -- the same "a malformed record reads as unapplied, never
57
+ * throws" rule `platform-diff.ts`'s `parseBrokerDeployRecord` already
58
+ * follows, and for the same reason: this is a planning read, not a
59
+ * validation boundary.
60
+ */
61
+ export declare function parseMailCopyCompleted(raw: string | undefined): string[];
62
+ /**
63
+ * Everything {@link mailCopyPlan} needs beyond the provider: the resolved
64
+ * account list and their Vandelay target mapping, the Stalwart JMAP base
65
+ * URL, and the archive directory the one-off container keeps its Vandelay
66
+ * SQLite archives in between runs (the host's own choice -- it also
67
+ * implements {@link MailCopyPort.run}, so it alone knows where that
68
+ * container's persistent storage is mounted).
69
+ */
70
+ export interface MailCopyJob {
71
+ readonly provider: MailCopyProvider;
72
+ readonly accounts: readonly MailCopyAccount[];
73
+ readonly stalwartUrl: string;
74
+ readonly archiveDir: string;
75
+ }
76
+ export interface MailCopyPort {
77
+ /** `null` until a human has declared this org's accounts (#113's own "Decide" is silent on how; this package never guesses a roster). */
78
+ resolveAccounts(org: DeclaredOrg): Promise<MailCopyJob | null>;
79
+ /** `null` until a human has stored this org's admin token as a held key. */
80
+ resolveSourceToken(org: DeclaredOrg): Promise<string | null>;
81
+ /** `null` until the platform's own Stalwart export credential is held. */
82
+ resolveStalwartToken(): Promise<string | null>;
83
+ /** Runs one Vandelay command (`accountPlan.importArgv`/`exportArgv`) to completion, `VANDELAY_TOKEN` set to `token` for that one launch. */
84
+ run(argv: readonly string[], token: string): Promise<{
85
+ exitCode: number;
86
+ stdout: string;
87
+ stderr: string;
88
+ }>;
89
+ }
90
+ export declare class MissingMailCopyAccountsError extends Error {
91
+ constructor(orgSlug: string);
92
+ }
93
+ export declare class MissingMailCopySourceTokenError extends Error {
94
+ constructor(orgSlug: string);
95
+ }
96
+ export declare class MissingMailCopyStalwartTokenError extends Error {
97
+ constructor(orgSlug: string);
98
+ }
99
+ export interface MailCopyStepResult {
100
+ /** Every account copied so far, this apply's newly-finished ones included -- what the executor records under {@link MAIL_COPY_COMPLETED_SCOPE_KEY}. */
101
+ readonly completed: readonly string[];
102
+ /** `true` once the job names at least one account and every one of them has copied cleanly. */
103
+ readonly done: boolean;
104
+ /** Why this apply stopped early, absent when nothing failed. */
105
+ readonly failure?: MailCopyFailure;
106
+ }
107
+ /** The account and phase a copy stopped at, and what Vandelay said -- never a token. */
108
+ export interface MailCopyFailure {
109
+ readonly account: string;
110
+ readonly phase: 'import' | 'export';
111
+ /** Absent when `port.run` threw instead of returning an exit code. */
112
+ readonly exitCode?: number;
113
+ /** The last {@link STDERR_TAIL_LENGTH} characters of stderr (or the thrown error's message), with both tokens replaced. */
114
+ readonly detail: string;
115
+ }
116
+ /**
117
+ * Runs this apply's share of one org's mail copy: resolve the job and its
118
+ * credentials, skip whatever {@link alreadyCompleted} already names, then
119
+ * import and export each remaining account in turn, stopping at the first
120
+ * one that fails (import or export) rather than guessing whether that is a
121
+ * provider's daily cap or a real problem. Never throws for a Vandelay
122
+ * failure -- only for a missing port answer, which is a configuration gap,
123
+ * not something a retry fixes on its own.
124
+ */
125
+ export declare function runMailCopyStep(org: DeclaredOrg, port: MailCopyPort, alreadyCompleted: ReadonlySet<string>): Promise<MailCopyStepResult>;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Copies an org's old mail into Stalwart before its `received` -> `hosted`
3
+ * MX cutover (#113), using `@wtfalch/email/postmaster`'s `mailCopyPlan` to
4
+ * build the exact Vandelay `import`/`export` commands and nothing more --
5
+ * this module decides when to run them and what to do with the result, the
6
+ * same split `calls-sfu.ts` draws between a pure plan builder and the apply
7
+ * logic around it.
8
+ *
9
+ * Three things this step needs and never invents itself, mirroring
10
+ * `plan/platform-sfu.ts`'s own "a host supplies real clients" rule:
11
+ *
12
+ * - {@link MailCopyPort.resolveAccounts}: which accounts to copy, and where
13
+ * each one's mail lands in Stalwart. Foundry tracks only an org's owner
14
+ * mailbox (`org.mail.ownerMailboxAddress`) -- it has no roster of every
15
+ * mailbox a Google Workspace or Microsoft 365 tenant holds, so the full
16
+ * list is a fact a human supplies, read back here exactly the way a held
17
+ * SSH key or API credential already is elsewhere in this package.
18
+ * - {@link MailCopyPort.resolveSourceToken}: the org's own admin bearer
19
+ * token for its old provider, a per-org held key (#113 "Decided",
20
+ * 2026-09-30) -- never an environment variable this package sets itself.
21
+ * - {@link MailCopyPort.resolveStalwartToken}: the platform's own Stalwart
22
+ * export credential (the admin key `impersonate` already covers,
23
+ * `plan/platform-providers.ts`'s `provisionPlatformMail`), read back by
24
+ * the host rather than minted again here.
25
+ *
26
+ * {@link MailCopyPort.run} is the fourth port: running one Vandelay command
27
+ * to completion in a one-off container (#113 "Decided": Coolify) and
28
+ * reporting how it went. Never throws on a non-zero exit -- the caller
29
+ * checks `exitCode`, the same "write a file, run a command, the caller
30
+ * checks it" shape `CallsSfuHostPort.exec` already uses, for the same
31
+ * reason: every value a real implementation might echo into stdout/stderr
32
+ * is the host's to redact, never this function's problem.
33
+ *
34
+ * **No new step type.** #113's first decision (2026-09-30) asked for a
35
+ * resumable step type of its own; the second (2026-10-01) replaced it with
36
+ * "keep foundry's one step contract": this step runs once per `apply`, the
37
+ * same as every other, and resumes only because `runMailCopyStep` is
38
+ * idempotent -- skip an account already recorded done, stop at the first
39
+ * one that fails this round, and let the *planner* (`plan/diff.ts`'s
40
+ * `planMailCopy`) decide whether to show the step again next time. "Rely on
41
+ * Vandelay's own rate limit for the Gmail daily cap" (#113 "Decided") means
42
+ * this function never inspects *why* an account failed -- a provider's
43
+ * daily transfer cap and a real failure look the same from here, so it
44
+ * always just stops and lets the next apply retry, rather than guessing.
45
+ */
46
+ import { mailCopyPlan } from '@wtfalch/email/postmaster';
47
+ /** Scope id: a JSON array of account `source` addresses already fully copied for this org. Read by `plan/diff.ts`'s `planMailCopy` to report progress, and by `executor.ts`'s `mailCopy` to skip a finished account on the next apply. */
48
+ export const MAIL_COPY_COMPLETED_SCOPE_KEY = 'mail_copy_completed_accounts';
49
+ /** Scope id: set to `'1'` once every account in the resolved plan has copied cleanly. `plan/diff.ts`'s `planMail` gates the `received` -> `hosted` mode flip on this being set. */
50
+ export const MAIL_COPY_DONE_SCOPE_KEY = 'mail_copy_done';
51
+ /**
52
+ * Parses {@link MAIL_COPY_COMPLETED_SCOPE_KEY}'s recorded value back into
53
+ * the set of account addresses already copied. `[]` for anything that is
54
+ * not a JSON array of strings -- absent, corrupt, or a shape from before
55
+ * this existed -- the same "a malformed record reads as unapplied, never
56
+ * throws" rule `platform-diff.ts`'s `parseBrokerDeployRecord` already
57
+ * follows, and for the same reason: this is a planning read, not a
58
+ * validation boundary.
59
+ */
60
+ export function parseMailCopyCompleted(raw) {
61
+ if (!raw)
62
+ return [];
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(raw);
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ if (!Array.isArray(parsed) || !parsed.every((v) => typeof v === 'string'))
71
+ return [];
72
+ return parsed;
73
+ }
74
+ export class MissingMailCopyAccountsError extends Error {
75
+ constructor(orgSlug) {
76
+ super(`coolify:mail-copy for ${orgSlug}: no accounts are declared for this org's mail copy yet (#113)`);
77
+ this.name = 'MissingMailCopyAccountsError';
78
+ }
79
+ }
80
+ export class MissingMailCopySourceTokenError extends Error {
81
+ constructor(orgSlug) {
82
+ super(`coolify:mail-copy for ${orgSlug}: no admin token is held for this org's old mail provider yet (#113 "Decided", 2026-09-30)`);
83
+ this.name = 'MissingMailCopySourceTokenError';
84
+ }
85
+ }
86
+ export class MissingMailCopyStalwartTokenError extends Error {
87
+ constructor(orgSlug) {
88
+ super(`coolify:mail-copy for ${orgSlug}: no Stalwart export credential is held yet`);
89
+ this.name = 'MissingMailCopyStalwartTokenError';
90
+ }
91
+ }
92
+ const STDERR_TAIL_LENGTH = 500;
93
+ /** Replaces every occurrence of each token, then keeps the tail: a real implementation may echo `VANDELAY_TOKEN` into stderr, and this must not carry it into a message. */
94
+ function redactTail(text, tokens) {
95
+ let out = text;
96
+ for (const token of tokens)
97
+ out = out.split(token).join('[redacted]');
98
+ return out.slice(-STDERR_TAIL_LENGTH);
99
+ }
100
+ /**
101
+ * Runs this apply's share of one org's mail copy: resolve the job and its
102
+ * credentials, skip whatever {@link alreadyCompleted} already names, then
103
+ * import and export each remaining account in turn, stopping at the first
104
+ * one that fails (import or export) rather than guessing whether that is a
105
+ * provider's daily cap or a real problem. Never throws for a Vandelay
106
+ * failure -- only for a missing port answer, which is a configuration gap,
107
+ * not something a retry fixes on its own.
108
+ */
109
+ export async function runMailCopyStep(org, port, alreadyCompleted) {
110
+ const job = await port.resolveAccounts(org);
111
+ if (!job)
112
+ throw new MissingMailCopyAccountsError(org.slug);
113
+ const sourceToken = await port.resolveSourceToken(org);
114
+ if (!sourceToken)
115
+ throw new MissingMailCopySourceTokenError(org.slug);
116
+ const stalwartToken = await port.resolveStalwartToken();
117
+ if (!stalwartToken)
118
+ throw new MissingMailCopyStalwartTokenError(org.slug);
119
+ const plan = mailCopyPlan({
120
+ provider: job.provider,
121
+ accounts: job.accounts,
122
+ stalwartUrl: job.stalwartUrl,
123
+ archiveDir: job.archiveDir,
124
+ });
125
+ const tokens = [sourceToken, stalwartToken];
126
+ const completed = new Set(alreadyCompleted);
127
+ let failure;
128
+ for (const accountPlan of plan.accounts) {
129
+ if (completed.has(accountPlan.source))
130
+ continue;
131
+ const phases = [
132
+ { phase: 'import', argv: accountPlan.importArgv, token: sourceToken },
133
+ { phase: 'export', argv: accountPlan.exportArgv, token: stalwartToken },
134
+ ];
135
+ for (const { phase, argv, token } of phases) {
136
+ try {
137
+ const ran = await port.run(argv, token);
138
+ if (ran.exitCode !== 0) {
139
+ failure = {
140
+ account: accountPlan.source,
141
+ phase,
142
+ exitCode: ran.exitCode,
143
+ detail: redactTail(ran.stderr, tokens),
144
+ };
145
+ }
146
+ }
147
+ catch (error) {
148
+ // A thrown run is a failed run: stop here, but keep what this apply finished.
149
+ failure = {
150
+ account: accountPlan.source,
151
+ phase,
152
+ detail: redactTail(error instanceof Error ? error.message : String(error), tokens),
153
+ };
154
+ }
155
+ if (failure)
156
+ break;
157
+ }
158
+ if (failure)
159
+ break;
160
+ completed.add(accountPlan.source);
161
+ }
162
+ // `completed` may still hold a stale address no longer in the plan, so
163
+ // done is checked against the plan's own accounts, never `completed.size`.
164
+ const done = plan.accounts.length > 0 && plan.accounts.every((account) => completed.has(account.source));
165
+ return { completed: [...completed], done, ...(failure ? { failure } : {}) };
166
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * #34: a matching `requireAudience` value says nothing about *who* carries
3
+ * it.
4
+ *
5
+ * ZITADEL puts a project's own id into the default audience of any sign-in
6
+ * to that project, so every JWT app in the org's audience project mints a
7
+ * token Stalwart's OIDC directory accepts, whether or not that app has
8
+ * anything to do with mail. Two real instances were found by hand rather
9
+ * than by any check: `manage` shared the audience project and its sign-in
10
+ * tokens opened the signed-in person's mailbox until it moved to its own
11
+ * project (wtfalch/agora#100); `operator` was added to the same project
12
+ * later and has the same hole today (wtfalch/operator#11).
13
+ *
14
+ * {@link checkMailAppsAllowlist} faults on every JWT app in the audience
15
+ * project that is not on the org's own declared allow-list, naming the app
16
+ * and its client id -- the same way `org.ts`'s validators name the value
17
+ * they refuse. Decided by William (2026-09-30): only listed apps may read
18
+ * a person's mail; each org's declared config holds its own list
19
+ * ({@link MailConfig.mailApps} in `../org.js`, defaulting to
20
+ * {@link DEFAULT_MAIL_APPS} when the org declares none of its own).
21
+ *
22
+ * An exported pure function, the same "host calls it" shape #33's
23
+ * `checkMailIdentityAudience` (`./mail-identity.js`) takes. This module
24
+ * declares its own narrow app shape rather than importing that one's, so
25
+ * the two land independently of each other -- a host composing both checks
26
+ * adapts the same live app list to each. Deliberately silent on
27
+ * opaque-token apps: #33's check already names those on its own, and an
28
+ * app this function cannot classify is reported separately rather than
29
+ * folded into either list.
30
+ */
31
+ export interface MailAllowlistApp {
32
+ /** ZITADEL's own app id. */
33
+ id: string;
34
+ /** The OIDC client id ZITADEL issued -- what a fault names, never a secret. */
35
+ clientId: string;
36
+ name: string;
37
+ /**
38
+ * How ZITADEL mints this app's access token. `undefined` means unknown
39
+ * (an `api` app, or a caller that cannot yet tell) and is never treated
40
+ * as `'jwt'` -- an app this function cannot classify as a JWT app is
41
+ * never faulted as an unlisted one.
42
+ */
43
+ accessTokenType?: 'jwt' | 'opaque';
44
+ }
45
+ /**
46
+ * Faults on every JWT app in `apps` that is not on `declaredMailApps`,
47
+ * naming the app and its client id. Empty means every JWT app the audience
48
+ * project holds is on the list (or there are no JWT apps at all).
49
+ */
50
+ export declare function checkMailAppsAllowlist(apps: readonly MailAllowlistApp[], declaredMailApps: readonly string[]): string[];
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Faults on every JWT app in `apps` that is not on `declaredMailApps`,
3
+ * naming the app and its client id. Empty means every JWT app the audience
4
+ * project holds is on the list (or there are no JWT apps at all).
5
+ */
6
+ export function checkMailAppsAllowlist(apps, declaredMailApps) {
7
+ const allowed = new Set(declaredMailApps);
8
+ return apps
9
+ .filter((app) => app.accessTokenType === 'jwt' && !allowed.has(app.name))
10
+ .map((app) => `${app.name} (${app.clientId}) is not on the declared mail-apps list but mints a JWT Stalwart's audience rule accepts`);
11
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * #33: whether Stalwart's OIDC directory audience rule actually binds the
3
+ * app that is carrying a token, not just whether the configured value looks
4
+ * right.
5
+ *
6
+ * The retired `checkMailIdentity` (app-template's `bootstrap/mail-
7
+ * identity.ts`) confirmed the directory's `requireAudience` matched what
8
+ * bootstrap configured, and that read as "the audience rule is enforced."
9
+ * It is not, for every app: Stalwart's OIDC directory lookup only enforces
10
+ * `requireAudience` on the JWT path (`authenticate_jwt`). A bearer token
11
+ * that is opaque instead goes through `authenticate_opaque`, which calls
12
+ * userinfo and builds the account -- checking neither audience nor scope at
13
+ * all (measured against `stalwartlabs/stalwart` `d9dee0a`,
14
+ * `crates/directory/src/backend/oidc/lookup.rs`). An app whose ZITADEL
15
+ * `accessTokenType` is opaque mints tokens that skip the rule entirely, no
16
+ * matter what the value check says.
17
+ *
18
+ * So {@link checkMailIdentityAudience} checks the audience *value* only --
19
+ * its name says so -- and separately names every opaque-minting app, so a
20
+ * clean value match stops reading as "and therefore enforced" for apps it
21
+ * never binds. Which apps are *allowed* to hold a JWT the rule does accept
22
+ * is a declared allow-list, out of scope here (#34).
23
+ *
24
+ * An exported pure function, decided in #33: the host supplies the live
25
+ * `requireAudience` value, the audience this org expects, and the apps in
26
+ * the audience project. This module reads no live state and calls nothing
27
+ * -- `apps` takes its own narrow shape rather than importing
28
+ * `@wtfalch/providers/zitadel`'s `ZitadelApp`, the same way `executor.ts`'s
29
+ * `ZitadelPort.listApps` already declares its own shape for a real
30
+ * `ZitadelClient` to satisfy structurally.
31
+ */
32
+ export interface MailIdentityApp {
33
+ /** ZITADEL's own app id. */
34
+ id: string;
35
+ /** The OIDC client id ZITADEL issued -- what a fault names, never a secret. */
36
+ clientId: string;
37
+ name: string;
38
+ /**
39
+ * How ZITADEL mints this app's access token. `undefined` means unknown
40
+ * (an `api` app, which has no `oidcConfig` at all, or a caller that
41
+ * cannot yet tell) and is never treated as either value -- an app this
42
+ * function cannot classify is reported on its own, not folded into
43
+ * "safe".
44
+ */
45
+ accessTokenType?: 'jwt' | 'opaque';
46
+ }
47
+ export interface MailIdentityAudienceCheck {
48
+ /** `null` when the directory's `requireAudience` matches `expectedAudience`; otherwise the reason it does not. */
49
+ audienceMismatch: string | null;
50
+ /**
51
+ * One line per app in the audience project that mints an opaque access
52
+ * token, naming the app and its client id -- Stalwart's audience rule
53
+ * never binds it, whatever `audienceMismatch` says. Empty means every app
54
+ * this function was given mints a JWT (or none were given).
55
+ */
56
+ opaqueApps: string[];
57
+ /**
58
+ * One line per app this function could not classify at all (an
59
+ * `accessTokenType` of neither `'jwt'` nor `'opaque'`) -- worth a human
60
+ * look, since it is reported as neither enforced nor refused.
61
+ */
62
+ unknownAccessTokenApps: string[];
63
+ }
64
+ export declare function checkMailIdentityAudience(input: {
65
+ requireAudience: string;
66
+ expectedAudience: string;
67
+ apps: readonly MailIdentityApp[];
68
+ }): MailIdentityAudienceCheck;
@@ -0,0 +1,17 @@
1
+ export function checkMailIdentityAudience(input) {
2
+ const { requireAudience, expectedAudience, apps } = input;
3
+ const audienceMismatch = requireAudience === expectedAudience
4
+ ? null
5
+ : `requireAudience is '${requireAudience}', expected '${expectedAudience}'`;
6
+ const opaqueApps = [];
7
+ const unknownAccessTokenApps = [];
8
+ for (const app of apps) {
9
+ if (app.accessTokenType === 'opaque') {
10
+ opaqueApps.push(`${app.name} (${app.clientId}) mints an opaque access token -- Stalwart's requireAudience does not bind it`);
11
+ }
12
+ else if (app.accessTokenType !== 'jwt') {
13
+ unknownAccessTokenApps.push(`${app.name} (${app.clientId}) has an unknown access token type`);
14
+ }
15
+ }
16
+ return { audienceMismatch, opaqueApps, unknownAccessTokenApps };
17
+ }
@@ -178,6 +178,10 @@ export interface PlatformStepExecutor {
178
178
  */
179
179
  blockedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
180
180
  auth?: string;
181
+ /** #211 round 6: see `PlanPlatformOptions.blocked.authExecutorMissing`. */
182
+ authExecutorMissing?: boolean;
183
+ /** #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`. */
184
+ authOtherInputsMissing?: string[];
181
185
  files?: string;
182
186
  integrations?: string;
183
187
  ai?: string;
@@ -190,6 +194,7 @@ export interface PlatformStepExecutor {
190
194
  */
191
195
  driftedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
192
196
  archon?: boolean;
197
+ auth?: boolean;
193
198
  }>;
194
199
  execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
195
200
  resourceId?: string;