@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
|
@@ -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;
|