@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.
- package/dist/bootstrap/bootstrap.d.ts +111 -0
- package/dist/bootstrap/bootstrap.js +323 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +99 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +17 -6
- package/dist/calls-sfu-deploy-support.js +19 -7
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +41 -9
- package/dist/index.d.ts +38 -7
- package/dist/index.js +20 -4
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- 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 +96 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +20 -3
- package/dist/plan/diff.js +713 -115
- package/dist/plan/executor.d.ts +680 -172
- package/dist/plan/executor.js +755 -189
- package/dist/plan/index.d.ts +28 -5
- package/dist/plan/index.js +15 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +197 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +487 -0
- package/dist/plan/platform-diff.js +1220 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +121 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +615 -0
- package/dist/plan/platform-providers.js +4410 -0
- package/dist/plan/platform-sfu.d.ts +128 -0
- package/dist/plan/platform-sfu.js +142 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +83 -0
- package/dist/plan/zitadel-machine-user.js +238 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +118 -0
- package/dist/requested-changes.js +125 -0
- package/dist/slug.js +15 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +29 -1
- package/dist/store/postgres.js +203 -4
- package/dist/store/schema.d.ts +755 -0
- package/dist/store/schema.js +70 -0
- package/dist/store.d.ts +53 -0
- package/package.json +1 -1
package/dist/plan/index.d.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
export {
|
|
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 {
|
|
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';
|
package/dist/plan/index.js
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|
package/dist/plan/live.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|