@wtfalch/foundry 0.3.0 → 0.5.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 (71) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -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 +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  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 +22 -4
  18. package/dist/calls-sfu.js +22 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0014_calls_enabled.sql +7 -0
  22. package/dist/migrations/0015_domain_unique.sql +39 -0
  23. package/dist/migrations/0016_platform.sql +61 -0
  24. package/dist/migrations/0017_requested_changes.sql +42 -0
  25. package/dist/migrations/0018_platform_apply.sql +19 -0
  26. package/dist/org.d.ts +18 -2
  27. package/dist/org.js +56 -0
  28. package/dist/plan/agora.d.ts +77 -0
  29. package/dist/plan/agora.js +127 -0
  30. package/dist/plan/calls-matrix.d.ts +135 -0
  31. package/dist/plan/calls-matrix.js +275 -0
  32. package/dist/plan/cloudflared-connector.d.ts +70 -0
  33. package/dist/plan/cloudflared-connector.js +106 -0
  34. package/dist/plan/diff.d.ts +26 -5
  35. package/dist/plan/diff.js +720 -106
  36. package/dist/plan/executor.d.ts +731 -36
  37. package/dist/plan/executor.js +1180 -41
  38. package/dist/plan/index.d.ts +22 -5
  39. package/dist/plan/index.js +12 -2
  40. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  41. package/dist/plan/jobs-scheduler-http.js +50 -0
  42. package/dist/plan/live.d.ts +51 -2
  43. package/dist/plan/live.js +8 -0
  44. package/dist/plan/plan-apply.d.ts +192 -3
  45. package/dist/plan/plan-apply.js +290 -14
  46. package/dist/plan/platform-diff.d.ts +312 -0
  47. package/dist/plan/platform-diff.js +867 -0
  48. package/dist/plan/platform-org-ports.d.ts +50 -0
  49. package/dist/plan/platform-org-ports.js +70 -0
  50. package/dist/plan/platform-org.d.ts +120 -0
  51. package/dist/plan/platform-org.js +115 -0
  52. package/dist/plan/platform-providers.d.ts +501 -0
  53. package/dist/plan/platform-providers.js +3029 -0
  54. package/dist/plan/platform-sfu.d.ts +93 -0
  55. package/dist/plan/platform-sfu.js +100 -0
  56. package/dist/plan/providers-readers.js +22 -1
  57. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  58. package/dist/plan/zitadel-machine-user.js +95 -0
  59. package/dist/plan.d.ts +6 -0
  60. package/dist/platform.d.ts +55 -0
  61. package/dist/platform.js +46 -0
  62. package/dist/requested-changes.d.ts +108 -0
  63. package/dist/requested-changes.js +95 -0
  64. package/dist/slug.js +21 -2
  65. package/dist/store/audit.js +4 -2
  66. package/dist/store/postgres.d.ts +40 -7
  67. package/dist/store/postgres.js +257 -29
  68. package/dist/store/schema.d.ts +700 -0
  69. package/dist/store/schema.js +58 -0
  70. package/dist/store.d.ts +56 -0
  71. package/package.json +2 -2
@@ -1,10 +1,27 @@
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 { PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, PLATFORM_AI_MINTER_MISSING, } 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';
7
22
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
8
23
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
9
- export type { CoolifyPort, CloudflarePort, ZitadelPort, FilesOrgLimits, AiOrgLimits, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, } from './executor.js';
24
+ export type { AgoraHostPort, AgoraHostValues } from './agora.js';
25
+ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
10
26
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
27
+ export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
@@ -1,5 +1,15 @@
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 { PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, PLATFORM_AI_MINTER_MISSING, } from './platform-providers.js';
11
+ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
12
+ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
4
13
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
5
14
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
15
+ 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
+ }
@@ -1,10 +1,14 @@
1
1
  import type { PlanApply } from '../apply.js';
2
2
  import type { DeclaredOrg, OrgId } from '../org.js';
3
- import type { Plan } from '../plan.js';
3
+ import type { Plan, PlanStep } from '../plan.js';
4
+ import type { DeclaredPlatform } from '../platform.js';
4
5
  import type { AppliedState } from '../state.js';
5
- import type { DeclaredOrgStore } from '../store.js';
6
+ import type { DeclaredOrgStore, PlatformStore } from '../store.js';
7
+ import { type AgoraHostPort } from './agora.js';
6
8
  import { type StepAction } from './diff.js';
7
- import { type LiveReaders, type LiveResource } from './live.js';
9
+ import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
10
+ import { type HeldKeyResolver } from './platform-org.js';
11
+ import type { CallsSfuConnectionPort } from './platform-sfu.js';
8
12
  /** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
9
13
  export interface AuditActor {
10
14
  readonly id: string;
@@ -27,6 +31,8 @@ export interface AuditLog {
27
31
  actor: AuditActor;
28
32
  orgId: OrgId;
29
33
  plan: Plan;
34
+ /** `'platform'` for the shared platform's plan (#167), whose `orgId` is the owner org's; absent for an org's own. */
35
+ target?: 'platform';
30
36
  at: Date;
31
37
  }): Promise<void>;
32
38
  recordApply(entry: {
@@ -35,6 +41,8 @@ export interface AuditLog {
35
41
  plan: Plan;
36
42
  outcome: 'applied' | 'refused' | 'failed';
37
43
  reason?: string;
44
+ /** `'platform'` for the shared platform's apply (#167), whose `orgId` is the owner org's; absent for an org's own. */
45
+ target?: 'platform';
38
46
  at: Date;
39
47
  }): Promise<void>;
40
48
  /** `FoundryPlanApply.destroy`'s own record -- no `Plan` involved, so this is its own entry shape rather than a reuse of `recordApply`'s. */
@@ -61,6 +69,13 @@ export interface AuthzGate {
61
69
  * own permission rather than reusing `canApply`.
62
70
  */
63
71
  canDestroy(actor: AuditActor, orgId: OrgId): Promise<boolean>;
72
+ /**
73
+ * Gates `applyPlatform` (#167): changing the shared platform every org
74
+ * runs on is a stronger action than applying one org, so it is its own
75
+ * permission. Optional so an existing host that only gates orgs keeps
76
+ * compiling; a gate without it refuses every platform apply.
77
+ */
78
+ canApplyPlatform?(actor: AuditActor): Promise<boolean>;
64
79
  }
65
80
  /**
66
81
  * What {@link StepExecutor.execute} needs beyond the {@link StepAction}
@@ -108,6 +123,13 @@ export interface StepExecutionContext {
108
123
  * scope key can never collide with the `system:kind:key` form.
109
124
  */
110
125
  recordScopeId(key: string, id: string): void;
126
+ /**
127
+ * Writes every id recorded so far to the store now, not only at the end of
128
+ * the run. For an id that must survive a crash mid-step: the ai key's id
129
+ * between its mint and its delivery. The end-of-run write is a superset of
130
+ * this one, since both write the same map.
131
+ */
132
+ persistScopeIds(): Promise<void>;
111
133
  }
112
134
  /**
113
135
  * Makes one {@link StepAction} true against a real system. No implementation
@@ -121,6 +143,58 @@ export interface StepExecutor {
121
143
  resourceId?: string;
122
144
  } | undefined>;
123
145
  }
146
+ /**
147
+ * #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
148
+ * {@link PlatformStepExecutor.execute} needs beyond the {@link StepAction}
149
+ * itself, for the shared platform (`platform.ts`'s `DeclaredPlatform`) rather
150
+ * than one `DeclaredOrg`. A separate type rather than a reuse of
151
+ * `StepExecutionContext`: that type's `org` field is read directly by every
152
+ * real executor method in `executor.ts`, so widening it to also carry a
153
+ * platform would touch that whole file, which is out of scope here -- #167
154
+ * ships the plan/apply frame only, proven against fakes, the same way #19
155
+ * shipped org plan/apply before #20 wired `FoundryStepExecutor` against it.
156
+ */
157
+ export interface PlatformStepExecutionContext {
158
+ readonly platform: DeclaredPlatform;
159
+ /** {@link StepExecutionContext.recordedIds}, for the platform's own run. */
160
+ recordedIds(): Readonly<Record<string, string>>;
161
+ /** {@link StepExecutionContext.recordScopeId}, for the platform's own run. */
162
+ recordScopeId(key: string, id: string): void;
163
+ /**
164
+ * Writes every id recorded so far to the platform's applied state now,
165
+ * rather than when the run ends or throws, so a crash between two calls
166
+ * cannot lose an id a provider already created. Optional: a caller that
167
+ * builds its own context (tests) may omit it.
168
+ */
169
+ persist?(): Promise<void>;
170
+ }
171
+ /** {@link StepExecutor}, for the platform (#167) rather than an org. */
172
+ export interface PlatformStepExecutor {
173
+ /**
174
+ * Steps this executor cannot run, as fixed text (part of the stale-plan
175
+ * comparison, so it must not vary per call). The planner reports each as an
176
+ * unmanaged item and plans no step for it, the way a blocked Agora is
177
+ * reported, instead of planning a step that throws on every apply.
178
+ */
179
+ blockedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
180
+ auth?: string;
181
+ files?: string;
182
+ integrations?: string;
183
+ ai?: string;
184
+ archon?: string;
185
+ mail?: string;
186
+ }>;
187
+ /**
188
+ * Steps whose live state differs from the declared one in a way the recorded
189
+ * ids cannot show, read from the live system on each plan.
190
+ */
191
+ driftedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
192
+ archon?: boolean;
193
+ }>;
194
+ execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
195
+ resourceId?: string;
196
+ } | undefined>;
197
+ }
124
198
  /**
125
199
  * One resource `FoundryPlanApply.destroy` has decided to tear down: an entry
126
200
  * from `AppliedState.serviceIds` (`resourceKey`'s `system:kind:key` form, or
@@ -168,6 +242,22 @@ export declare class NotAuthorizedError extends Error {
168
242
  export declare class NotATestOrgError extends Error {
169
243
  constructor(orgId: OrgId);
170
244
  }
245
+ /** #167: {@link DeclaredOrgNotFoundError}, for the platform's own singleton record (#166). */
246
+ export declare class DeclaredPlatformNotFoundError extends Error {
247
+ constructor();
248
+ }
249
+ /** #167: {@link StalePlanError}, for `planPlatform`/`applyPlatform`. */
250
+ export declare class PlatformStalePlanError extends Error {
251
+ constructor();
252
+ }
253
+ /**
254
+ * #167: {@link ConfirmationRequiredError}, for `applyPlatform` -- typed as
255
+ * the literal word `platform` rather than a declared name, since
256
+ * {@link DeclaredPlatform} has no `displayName` of its own to type (#166).
257
+ */
258
+ export declare class PlatformConfirmationRequiredError extends Error {
259
+ constructor();
260
+ }
171
261
  export interface FoundryPlanApplyOptions {
172
262
  store: DeclaredOrgStore;
173
263
  readers: LiveReaders;
@@ -177,8 +267,51 @@ export interface FoundryPlanApplyOptions {
177
267
  authz: AuthzGate;
178
268
  /** The operator running this plan/apply — every audit row's "who" (#9, #19). */
179
269
  actor: AuditActor;
270
+ /** Host-supplied Agora values (#164). Optional: without it, or if it throws, the Agora is reported blocked and every other step still plans. */
271
+ agora?: AgoraHostPort;
272
+ /**
273
+ * #173: true when the host's executor was built with a `jobsScheduler`
274
+ * port. Optional, and false without it: each hosted org's registration with
275
+ * the jobs scheduler is then reported blocked instead of planned, so the
276
+ * step never runs (and fails) where no scheduler is deployed.
277
+ */
278
+ jobsScheduler?: boolean;
279
+ /**
280
+ * #177: true when the host's executor was built with its own `filesOrganisation`
281
+ * / `aiOrganisation` port. Without one, the org step needs the platform's
282
+ * key, so it is blocked while `platform.heldKeys` is absent or does not hold it.
283
+ */
284
+ filesOrganisation?: boolean;
285
+ aiOrganisation?: boolean;
180
286
  /** Overridable for tests; defaults to `() => new Date()`. */
181
287
  now?: () => Date;
288
+ /**
289
+ * #167: dependencies for planning/applying the shared platform (#166)
290
+ * alongside an org. Optional so an org-only caller — every #19-era caller,
291
+ * including every existing org test in this file — is unaffected;
292
+ * `planPlatform`/`applyPlatform` throw a plain `Error` if called without
293
+ * this set.
294
+ */
295
+ platform?: {
296
+ store: PlatformStore;
297
+ readers: PlatformLiveReaders;
298
+ executor: PlatformStepExecutor;
299
+ /**
300
+ * #170: read-only look at the held SFU connection. When the platform
301
+ * declares the SFU but this is absent, or resolves `null`, the plan
302
+ * reports the SFU as blocked (in `unmanaged`) instead of planning a step
303
+ * that would throw and halt every step after it.
304
+ */
305
+ sfuConnection?: CallsSfuConnectionPort;
306
+ /**
307
+ * #177: Archon's held keys. An org plan resolves the platform's files,
308
+ * AI and jobs keys through it and blocks the org step (naming the ref,
309
+ * never a value) while one is not held. Absent: every service without a
310
+ * host-supplied port is blocked (`filesOrganisation`, `aiOrganisation`,
311
+ * `jobsScheduler` on the options), never planned to fail at apply.
312
+ */
313
+ heldKeys?: HeldKeyResolver;
314
+ };
182
315
  }
183
316
  /**
184
317
  * The #19 implementation of {@link PlanApply}: computes a plan against live
@@ -194,10 +327,30 @@ export declare class FoundryPlanApply implements PlanApply {
194
327
  private readonly audit;
195
328
  private readonly authz;
196
329
  private readonly actor;
330
+ private readonly agora;
331
+ private readonly jobsScheduler;
332
+ private readonly hostFiles;
333
+ private readonly hostAi;
197
334
  private readonly now;
335
+ private readonly platformDeps;
198
336
  constructor(options: FoundryPlanApplyOptions);
199
337
  plan(id: OrgId): Promise<Plan>;
200
338
  apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
339
+ /**
340
+ * #167: {@link plan}, for the shared platform (#166) rather than an org --
341
+ * there is no id to pass, since `DeclaredPlatform` is one singleton record.
342
+ */
343
+ planPlatform(): Promise<Plan>;
344
+ /**
345
+ * #167: {@link apply}, for the platform. `plan.orgId` is the platform's own
346
+ * `ownerOrgId` (`toPlatformPlan`'s own doc comment), so the same
347
+ * `AuditLog`/`AuthzGate` seams `apply` uses need no platform-specific
348
+ * shape of their own. `confirmedName` must be the literal word `platform`
349
+ * rather than a declared name -- `DeclaredPlatform` has none.
350
+ */
351
+ applyPlatform(plan: Plan, confirmedName: string): Promise<{
352
+ serviceIds: Record<string, string>;
353
+ }>;
201
354
  /**
202
355
  * Deletes every resource foundry recorded for `id`, in reverse creation
203
356
  * order, then the declared org and its applied state themselves (goal
@@ -211,7 +364,43 @@ export declare class FoundryPlanApply implements PlanApply {
211
364
  */
212
365
  destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
213
366
  private refuse;
367
+ private refusePlatform;
214
368
  private refuseDestroy;
215
369
  private runSteps;
216
370
  private computeFreshPlan;
371
+ /**
372
+ * #177: an org's plan reads the shared platform's own addresses and
373
+ * readiness through the very `platform` deps `planPlatform`/`applyPlatform`
374
+ * already use. With no platform wired or declared, {@link
375
+ * DEFAULT_ORG_PLATFORM_READINESS} reports every service not ready, so every
376
+ * org step that needs one is blocked.
377
+ */
378
+ private orgPlatformReadiness;
379
+ /**
380
+ * #177: `AgoraHostValues` from the applied platform, the same
381
+ * `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
382
+ * host supplies no `agora` port of its own. An explicit `agora` (#164)
383
+ * always wins, unchanged, so a host that already has its own values
384
+ * (perhaps not yet wired to this platform) keeps them.
385
+ */
386
+ private orgAgoraHost;
387
+ /** #167: `FoundryPlanApplyOptions.platform`, or a plain `Error` naming what's missing. */
388
+ private requirePlatformDeps;
389
+ private computeFreshPlatformPlan;
390
+ private sfuBlockedReason;
391
+ /**
392
+ * {@link runSteps}, for the platform. The ids the steps return are recorded
393
+ * in `PlatformStore`'s applied state (`system:kind:key`, like an org's
394
+ * `serviceIds`), so a later plan finds a resource by its recorded id even
395
+ * when a provider's list has not caught up with a create.
396
+ */
397
+ private runPlatformSteps;
217
398
  }
399
+ /**
400
+ * #9 (resolved): whether any step in a plan needs its name typed before
401
+ * `apply` runs it. Shared by `apply` (the org's `displayName`) and
402
+ * `applyPlatform` (the literal word `platform`, #167) so the platform gets
403
+ * the identical gate an org already has, proven end-to-end above, rather
404
+ * than a second copy that could drift from it.
405
+ */
406
+ export declare function planNeedsConfirmation(steps: readonly PlanStep[]): boolean;