@wtfalch/foundry 0.4.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 (76) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +111 -0
  2. package/dist/bootstrap/bootstrap.js +323 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +99 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +17 -6
  10. package/dist/calls-sfu-deploy-support.js +19 -7
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +36 -12
  18. package/dist/calls-sfu.js +41 -9
  19. package/dist/index.d.ts +38 -7
  20. package/dist/index.js +20 -4
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  25. package/dist/migrations/0020_mail_apps.sql +7 -0
  26. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  27. package/dist/org.d.ts +81 -0
  28. package/dist/org.js +96 -0
  29. package/dist/plan/agora.d.ts +77 -0
  30. package/dist/plan/agora.js +127 -0
  31. package/dist/plan/cloudflared-connector.d.ts +70 -0
  32. package/dist/plan/cloudflared-connector.js +106 -0
  33. package/dist/plan/diff.d.ts +20 -3
  34. package/dist/plan/diff.js +713 -115
  35. package/dist/plan/executor.d.ts +680 -172
  36. package/dist/plan/executor.js +755 -189
  37. package/dist/plan/index.d.ts +28 -5
  38. package/dist/plan/index.js +15 -2
  39. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  40. package/dist/plan/jobs-scheduler-http.js +50 -0
  41. package/dist/plan/live.d.ts +51 -2
  42. package/dist/plan/live.js +8 -0
  43. package/dist/plan/mail-copy.d.ts +125 -0
  44. package/dist/plan/mail-copy.js +166 -0
  45. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  46. package/dist/plan/mail-identity-allowlist.js +11 -0
  47. package/dist/plan/mail-identity.d.ts +68 -0
  48. package/dist/plan/mail-identity.js +17 -0
  49. package/dist/plan/plan-apply.d.ts +197 -3
  50. package/dist/plan/plan-apply.js +278 -9
  51. package/dist/plan/platform-diff.d.ts +487 -0
  52. package/dist/plan/platform-diff.js +1220 -0
  53. package/dist/plan/platform-org-ports.d.ts +50 -0
  54. package/dist/plan/platform-org-ports.js +70 -0
  55. package/dist/plan/platform-org.d.ts +121 -0
  56. package/dist/plan/platform-org.js +115 -0
  57. package/dist/plan/platform-providers.d.ts +615 -0
  58. package/dist/plan/platform-providers.js +4410 -0
  59. package/dist/plan/platform-sfu.d.ts +128 -0
  60. package/dist/plan/platform-sfu.js +142 -0
  61. package/dist/plan/providers-readers.js +22 -1
  62. package/dist/plan/zitadel-machine-user.d.ts +83 -0
  63. package/dist/plan/zitadel-machine-user.js +238 -0
  64. package/dist/plan.d.ts +6 -0
  65. package/dist/platform.d.ts +55 -0
  66. package/dist/platform.js +46 -0
  67. package/dist/requested-changes.d.ts +118 -0
  68. package/dist/requested-changes.js +125 -0
  69. package/dist/slug.js +15 -2
  70. package/dist/store/audit.js +4 -2
  71. package/dist/store/postgres.d.ts +29 -1
  72. package/dist/store/postgres.js +203 -4
  73. package/dist/store/schema.d.ts +755 -0
  74. package/dist/store/schema.js +70 -0
  75. package/dist/store.d.ts +53 -0
  76. package/package.json +1 -1
@@ -1,10 +1,33 @@
1
- export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, } from './live.js';
2
- export { readLiveResources } from './live.js';
1
+ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, } from './live.js';
2
+ export { readLiveResources, readLivePlatformResources } from './live.js';
3
3
  export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
4
4
  export { computePlanDetail, toPlan } from './diff.js';
5
- export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
6
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
5
+ export type { PlatformPlanDetail } from './platform-diff.js';
6
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
7
+ export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
8
+ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
9
+ export type { PlatformOrgClientFactories } from './platform-org-ports.js';
10
+ export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
11
+ export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
12
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
13
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
14
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './platform-providers.js';
15
+ export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
16
+ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
17
+ export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
18
+ export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './platform-providers.js';
19
+ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
20
+ export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
21
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
22
+ export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
23
+ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
7
24
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
8
25
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
9
- export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './executor.js';
26
+ export type { AgoraHostPort, AgoraHostValues } from './agora.js';
27
+ export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
28
+ export { checkMailIdentityAudience } from './mail-identity.js';
29
+ export type { MailAllowlistApp } from './mail-identity-allowlist.js';
30
+ export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
31
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
10
32
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
33
+ export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
@@ -1,5 +1,18 @@
1
- export { readLiveResources } from './live.js';
1
+ export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
- export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, } from './plan-apply.js';
3
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
4
+ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
5
+ export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
6
+ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
7
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
8
+ // #171: how a host signs files management requests with the delivered key.
9
+ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
10
+ export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
11
+ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
12
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
13
+ export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
4
14
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
15
+ export { checkMailIdentityAudience } from './mail-identity.js';
16
+ export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
5
17
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
18
+ export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
@@ -0,0 +1,29 @@
1
+ import type { JobsSchedulerPort } from './executor.js';
2
+ /**
3
+ * #173: the real {@link JobsSchedulerPort}, a thin client over the jobs
4
+ * scheduler's admin HTTP API (https://github.com/wtfalch/jobs README, "The
5
+ * admin API", `POST /admin/projects`). The scheduler ships no SDK for this,
6
+ * so it is plain `fetch`.
7
+ *
8
+ * `adminToken` is the scheduler's `SCHEDULER_ADMIN_TOKEN` (`coolifyJobs`
9
+ * generates it and hands it to the host through `deliverSecret`); the host
10
+ * resolves it and builds this client. This package holds no credential of
11
+ * its own. The token and the credentials in a response never appear in a
12
+ * thrown error -- only the status and the scheduler's own `error` text,
13
+ * which the API builds from the name and URLs alone.
14
+ */
15
+ export declare class HttpJobsSchedulerClient implements JobsSchedulerPort {
16
+ private readonly adminToken;
17
+ private readonly fetchImpl;
18
+ private readonly baseUrl;
19
+ constructor(baseUrl: string, adminToken: string, fetchImpl?: typeof fetch);
20
+ registerProject(input: {
21
+ name: string;
22
+ baseUrl: string;
23
+ alertUrl?: string;
24
+ }): Promise<{
25
+ id: string;
26
+ secret?: string;
27
+ syncKey?: string;
28
+ }>;
29
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * #173: the real {@link JobsSchedulerPort}, a thin client over the jobs
3
+ * scheduler's admin HTTP API (https://github.com/wtfalch/jobs README, "The
4
+ * admin API", `POST /admin/projects`). The scheduler ships no SDK for this,
5
+ * so it is plain `fetch`.
6
+ *
7
+ * `adminToken` is the scheduler's `SCHEDULER_ADMIN_TOKEN` (`coolifyJobs`
8
+ * generates it and hands it to the host through `deliverSecret`); the host
9
+ * resolves it and builds this client. This package holds no credential of
10
+ * its own. The token and the credentials in a response never appear in a
11
+ * thrown error -- only the status and the scheduler's own `error` text,
12
+ * which the API builds from the name and URLs alone.
13
+ */
14
+ export class HttpJobsSchedulerClient {
15
+ adminToken;
16
+ fetchImpl;
17
+ baseUrl;
18
+ constructor(baseUrl, adminToken, fetchImpl = fetch) {
19
+ this.adminToken = adminToken;
20
+ this.fetchImpl = fetchImpl;
21
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
22
+ }
23
+ async registerProject(input) {
24
+ const response = await this.fetchImpl(`${this.baseUrl}/admin/projects`, {
25
+ method: 'POST',
26
+ headers: {
27
+ authorization: `Bearer ${this.adminToken}`,
28
+ 'content-type': 'application/json',
29
+ },
30
+ body: JSON.stringify(input),
31
+ });
32
+ const body = await response.json().catch(() => undefined);
33
+ const fields = typeof body === 'object' && body !== null ? body : {};
34
+ if (response.status !== 200 && response.status !== 201) {
35
+ const detail = typeof fields.error === 'string' ? `: ${fields.error}` : '';
36
+ throw new Error(`jobs scheduler: POST /admin/projects answered ${response.status}${detail}`);
37
+ }
38
+ if (typeof fields.id !== 'string' || fields.id.length === 0) {
39
+ throw new Error(`jobs scheduler: POST /admin/projects answered ${response.status} with no project id`);
40
+ }
41
+ // 200 is a repeat call: the scheduler returns just the id, never a secret.
42
+ if (response.status === 200)
43
+ return { id: fields.id };
44
+ return {
45
+ id: fields.id,
46
+ secret: typeof fields.secret === 'string' ? fields.secret : undefined,
47
+ syncKey: typeof fields.syncKey === 'string' ? fields.syncKey : undefined,
48
+ };
49
+ }
50
+ }
@@ -1,6 +1,6 @@
1
1
  import type { OrgId } from '../org.js';
2
2
  /**
3
- * One resource as seen live in one of foundry's six systems.
3
+ * One resource as seen live in one of foundry's seven systems.
4
4
  *
5
5
  * `key` is unique within `(system, kind)`. Per-org singleton resources
6
6
  * (issuer, mail, chat, storage's spend limit, AI's budget, the cms site, the
@@ -13,9 +13,22 @@ import type { OrgId } from '../org.js';
13
13
  * is keyed however that system names it, so it can still be matched, or left
14
14
  * {@link https://github.com/wtfalch/foundry/issues/10 unmanaged}, by
15
15
  * `computePlanDetail`.
16
+ *
17
+ * #173 adds the seventh system, `jobs`: not a resource Coolify, Cloudflare or
18
+ * any of the others reports, but the jobs scheduler's own admin API
19
+ * (https://github.com/wtfalch/jobs, jobs#62) -- an org's registration with it,
20
+ * keyed by the {@link OrgId} like every other per-org singleton.
16
21
  */
17
22
  export interface LiveResource {
18
- system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms';
23
+ /**
24
+ * `'sfu'` (#170) has no reader wired below -- there is no cheap way to
25
+ * probe the calls SFU VM's own state over SSH during planning, so
26
+ * `platform-diff.ts`'s `planSfu` always plans its step when
27
+ * `DeclaredPlatform.sfu.enabled` is true, rather than diffing against a
28
+ * live resource the way every other system here does. It is still part of
29
+ * this union so `StepAction.system` (`diff.ts`) can name it.
30
+ */
31
+ system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu';
19
32
  kind: string;
20
33
  key: string;
21
34
  config: Record<string, unknown>;
@@ -64,3 +77,39 @@ export interface LiveReaders {
64
77
  }
65
78
  /** Every live resource foundry can see for `orgId`, across all six systems. */
66
79
  export declare function readLiveResources(orgId: OrgId, readers: LiveReaders): Promise<LiveResource[]>;
80
+ /**
81
+ * #167: one narrow interface per system reading the shared platform's own
82
+ * resources (`platform.ts`'s `DeclaredPlatform`) -- the platform-scoped
83
+ * counterpart to {@link ZitadelReader}/{@link CoolifyReader}/
84
+ * {@link CloudflareReader} above. There is no {@link OrgId} to scope a read
85
+ * by: `DeclaredPlatform` is one record, not one per org, so `listResources`
86
+ * here takes no argument at all.
87
+ *
88
+ * `cloudflare` is wired for #167's own `cloudflare:zone` step
89
+ * (`platform-diff.ts`'s `planApexZone`); #173 adds `coolify`, for the jobs
90
+ * scheduler's own Coolify app (`platform-diff.ts`'s `planJobs`). The rest of
91
+ * the platform's eventual surface (`platform-diff.ts`'s own doc comment:
92
+ * "every other service ... is left for its own issue to plan") adds a reader
93
+ * here, and to {@link PlatformLiveReaders} and {@link readLivePlatformResources}
94
+ * below, as each service's own step lands -- the same way `LiveReaders` above
95
+ * grew from three readers to six across #19, #27 and #80.
96
+ */
97
+ export interface PlatformCloudflareReader {
98
+ listResources(): Promise<LiveResource[]>;
99
+ }
100
+ /** #173: the platform's own Coolify-hosted jobs scheduler (`platform-diff.ts`'s `planJobs`). */
101
+ export interface PlatformCoolifyReader {
102
+ listResources(): Promise<LiveResource[]>;
103
+ }
104
+ export interface PlatformLiveReaders {
105
+ cloudflare: PlatformCloudflareReader;
106
+ /**
107
+ * Optional so every #167-era caller -- everything that plans/applies only
108
+ * the apex zone -- is unaffected. Without one the scheduler could never be
109
+ * seen live, so `planJobs` plans no step and reports the scheduler as
110
+ * blocked (an `unmanaged` item) rather than re-planning it forever.
111
+ */
112
+ coolify?: PlatformCoolifyReader;
113
+ }
114
+ /** Every live resource foundry can see for the platform, across every wired reader (#167, #173). */
115
+ export declare function readLivePlatformResources(readers: PlatformLiveReaders): Promise<LiveResource[]>;
package/dist/plan/live.js CHANGED
@@ -10,3 +10,11 @@ export async function readLiveResources(orgId, readers) {
10
10
  ]);
11
11
  return [...zitadel, ...coolify, ...cloudflare, ...files, ...ai, ...cms];
12
12
  }
13
+ /** Every live resource foundry can see for the platform, across every wired reader (#167, #173). */
14
+ export async function readLivePlatformResources(readers) {
15
+ const [cloudflare, coolify] = await Promise.all([
16
+ readers.cloudflare.listResources(),
17
+ readers.coolify ? readers.coolify.listResources() : Promise.resolve([]),
18
+ ]);
19
+ return [...cloudflare, ...coolify];
20
+ }
@@ -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
+ }