@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
@@ -0,0 +1,68 @@
1
+ /**
2
+ * #33: whether Stalwart's OIDC directory audience rule actually binds the
3
+ * app that is carrying a token, not just whether the configured value looks
4
+ * right.
5
+ *
6
+ * The retired `checkMailIdentity` (app-template's `bootstrap/mail-
7
+ * identity.ts`) confirmed the directory's `requireAudience` matched what
8
+ * bootstrap configured, and that read as "the audience rule is enforced."
9
+ * It is not, for every app: Stalwart's OIDC directory lookup only enforces
10
+ * `requireAudience` on the JWT path (`authenticate_jwt`). A bearer token
11
+ * that is opaque instead goes through `authenticate_opaque`, which calls
12
+ * userinfo and builds the account -- checking neither audience nor scope at
13
+ * all (measured against `stalwartlabs/stalwart` `d9dee0a`,
14
+ * `crates/directory/src/backend/oidc/lookup.rs`). An app whose ZITADEL
15
+ * `accessTokenType` is opaque mints tokens that skip the rule entirely, no
16
+ * matter what the value check says.
17
+ *
18
+ * So {@link checkMailIdentityAudience} checks the audience *value* only --
19
+ * its name says so -- and separately names every opaque-minting app, so a
20
+ * clean value match stops reading as "and therefore enforced" for apps it
21
+ * never binds. Which apps are *allowed* to hold a JWT the rule does accept
22
+ * is a declared allow-list, out of scope here (#34).
23
+ *
24
+ * An exported pure function, decided in #33: the host supplies the live
25
+ * `requireAudience` value, the audience this org expects, and the apps in
26
+ * the audience project. This module reads no live state and calls nothing
27
+ * -- `apps` takes its own narrow shape rather than importing
28
+ * `@wtfalch/providers/zitadel`'s `ZitadelApp`, the same way `executor.ts`'s
29
+ * `ZitadelPort.listApps` already declares its own shape for a real
30
+ * `ZitadelClient` to satisfy structurally.
31
+ */
32
+ export interface MailIdentityApp {
33
+ /** ZITADEL's own app id. */
34
+ id: string;
35
+ /** The OIDC client id ZITADEL issued -- what a fault names, never a secret. */
36
+ clientId: string;
37
+ name: string;
38
+ /**
39
+ * How ZITADEL mints this app's access token. `undefined` means unknown
40
+ * (an `api` app, which has no `oidcConfig` at all, or a caller that
41
+ * cannot yet tell) and is never treated as either value -- an app this
42
+ * function cannot classify is reported on its own, not folded into
43
+ * "safe".
44
+ */
45
+ accessTokenType?: 'jwt' | 'opaque';
46
+ }
47
+ export interface MailIdentityAudienceCheck {
48
+ /** `null` when the directory's `requireAudience` matches `expectedAudience`; otherwise the reason it does not. */
49
+ audienceMismatch: string | null;
50
+ /**
51
+ * One line per app in the audience project that mints an opaque access
52
+ * token, naming the app and its client id -- Stalwart's audience rule
53
+ * never binds it, whatever `audienceMismatch` says. Empty means every app
54
+ * this function was given mints a JWT (or none were given).
55
+ */
56
+ opaqueApps: string[];
57
+ /**
58
+ * One line per app this function could not classify at all (an
59
+ * `accessTokenType` of neither `'jwt'` nor `'opaque'`) -- worth a human
60
+ * look, since it is reported as neither enforced nor refused.
61
+ */
62
+ unknownAccessTokenApps: string[];
63
+ }
64
+ export declare function checkMailIdentityAudience(input: {
65
+ requireAudience: string;
66
+ expectedAudience: string;
67
+ apps: readonly MailIdentityApp[];
68
+ }): MailIdentityAudienceCheck;
@@ -0,0 +1,17 @@
1
+ export function checkMailIdentityAudience(input) {
2
+ const { requireAudience, expectedAudience, apps } = input;
3
+ const audienceMismatch = requireAudience === expectedAudience
4
+ ? null
5
+ : `requireAudience is '${requireAudience}', expected '${expectedAudience}'`;
6
+ const opaqueApps = [];
7
+ const unknownAccessTokenApps = [];
8
+ for (const app of apps) {
9
+ if (app.accessTokenType === 'opaque') {
10
+ opaqueApps.push(`${app.name} (${app.clientId}) mints an opaque access token -- Stalwart's requireAudience does not bind it`);
11
+ }
12
+ else if (app.accessTokenType !== 'jwt') {
13
+ unknownAccessTokenApps.push(`${app.name} (${app.clientId}) has an unknown access token type`);
14
+ }
15
+ }
16
+ return { audienceMismatch, opaqueApps, unknownAccessTokenApps };
17
+ }
@@ -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,63 @@ 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
+ /** #211 round 6: see `PlanPlatformOptions.blocked.authExecutorMissing`. */
182
+ authExecutorMissing?: boolean;
183
+ /** #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`. */
184
+ authOtherInputsMissing?: string[];
185
+ files?: string;
186
+ integrations?: string;
187
+ ai?: string;
188
+ archon?: string;
189
+ mail?: string;
190
+ }>;
191
+ /**
192
+ * Steps whose live state differs from the declared one in a way the recorded
193
+ * ids cannot show, read from the live system on each plan.
194
+ */
195
+ driftedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
196
+ archon?: boolean;
197
+ auth?: boolean;
198
+ }>;
199
+ execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
200
+ resourceId?: string;
201
+ } | undefined>;
202
+ }
124
203
  /**
125
204
  * One resource `FoundryPlanApply.destroy` has decided to tear down: an entry
126
205
  * from `AppliedState.serviceIds` (`resourceKey`'s `system:kind:key` form, or
@@ -168,6 +247,22 @@ export declare class NotAuthorizedError extends Error {
168
247
  export declare class NotATestOrgError extends Error {
169
248
  constructor(orgId: OrgId);
170
249
  }
250
+ /** #167: {@link DeclaredOrgNotFoundError}, for the platform's own singleton record (#166). */
251
+ export declare class DeclaredPlatformNotFoundError extends Error {
252
+ constructor();
253
+ }
254
+ /** #167: {@link StalePlanError}, for `planPlatform`/`applyPlatform`. */
255
+ export declare class PlatformStalePlanError extends Error {
256
+ constructor();
257
+ }
258
+ /**
259
+ * #167: {@link ConfirmationRequiredError}, for `applyPlatform` -- typed as
260
+ * the literal word `platform` rather than a declared name, since
261
+ * {@link DeclaredPlatform} has no `displayName` of its own to type (#166).
262
+ */
263
+ export declare class PlatformConfirmationRequiredError extends Error {
264
+ constructor();
265
+ }
171
266
  export interface FoundryPlanApplyOptions {
172
267
  store: DeclaredOrgStore;
173
268
  readers: LiveReaders;
@@ -177,8 +272,51 @@ export interface FoundryPlanApplyOptions {
177
272
  authz: AuthzGate;
178
273
  /** The operator running this plan/apply — every audit row's "who" (#9, #19). */
179
274
  actor: AuditActor;
275
+ /** Host-supplied Agora values (#164). Optional: without it, or if it throws, the Agora is reported blocked and every other step still plans. */
276
+ agora?: AgoraHostPort;
277
+ /**
278
+ * #173: true when the host's executor was built with a `jobsScheduler`
279
+ * port. Optional, and false without it: each hosted org's registration with
280
+ * the jobs scheduler is then reported blocked instead of planned, so the
281
+ * step never runs (and fails) where no scheduler is deployed.
282
+ */
283
+ jobsScheduler?: boolean;
284
+ /**
285
+ * #177: true when the host's executor was built with its own `filesOrganisation`
286
+ * / `aiOrganisation` port. Without one, the org step needs the platform's
287
+ * key, so it is blocked while `platform.heldKeys` is absent or does not hold it.
288
+ */
289
+ filesOrganisation?: boolean;
290
+ aiOrganisation?: boolean;
180
291
  /** Overridable for tests; defaults to `() => new Date()`. */
181
292
  now?: () => Date;
293
+ /**
294
+ * #167: dependencies for planning/applying the shared platform (#166)
295
+ * alongside an org. Optional so an org-only caller — every #19-era caller,
296
+ * including every existing org test in this file — is unaffected;
297
+ * `planPlatform`/`applyPlatform` throw a plain `Error` if called without
298
+ * this set.
299
+ */
300
+ platform?: {
301
+ store: PlatformStore;
302
+ readers: PlatformLiveReaders;
303
+ executor: PlatformStepExecutor;
304
+ /**
305
+ * #170: read-only look at the held SFU connection. When the platform
306
+ * declares the SFU but this is absent, or resolves `null`, the plan
307
+ * reports the SFU as blocked (in `unmanaged`) instead of planning a step
308
+ * that would throw and halt every step after it.
309
+ */
310
+ sfuConnection?: CallsSfuConnectionPort;
311
+ /**
312
+ * #177: Archon's held keys. An org plan resolves the platform's files,
313
+ * AI and jobs keys through it and blocks the org step (naming the ref,
314
+ * never a value) while one is not held. Absent: every service without a
315
+ * host-supplied port is blocked (`filesOrganisation`, `aiOrganisation`,
316
+ * `jobsScheduler` on the options), never planned to fail at apply.
317
+ */
318
+ heldKeys?: HeldKeyResolver;
319
+ };
182
320
  }
183
321
  /**
184
322
  * The #19 implementation of {@link PlanApply}: computes a plan against live
@@ -194,10 +332,30 @@ export declare class FoundryPlanApply implements PlanApply {
194
332
  private readonly audit;
195
333
  private readonly authz;
196
334
  private readonly actor;
335
+ private readonly agora;
336
+ private readonly jobsScheduler;
337
+ private readonly hostFiles;
338
+ private readonly hostAi;
197
339
  private readonly now;
340
+ private readonly platformDeps;
198
341
  constructor(options: FoundryPlanApplyOptions);
199
342
  plan(id: OrgId): Promise<Plan>;
200
343
  apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
344
+ /**
345
+ * #167: {@link plan}, for the shared platform (#166) rather than an org --
346
+ * there is no id to pass, since `DeclaredPlatform` is one singleton record.
347
+ */
348
+ planPlatform(): Promise<Plan>;
349
+ /**
350
+ * #167: {@link apply}, for the platform. `plan.orgId` is the platform's own
351
+ * `ownerOrgId` (`toPlatformPlan`'s own doc comment), so the same
352
+ * `AuditLog`/`AuthzGate` seams `apply` uses need no platform-specific
353
+ * shape of their own. `confirmedName` must be the literal word `platform`
354
+ * rather than a declared name -- `DeclaredPlatform` has none.
355
+ */
356
+ applyPlatform(plan: Plan, confirmedName: string): Promise<{
357
+ serviceIds: Record<string, string>;
358
+ }>;
201
359
  /**
202
360
  * Deletes every resource foundry recorded for `id`, in reverse creation
203
361
  * order, then the declared org and its applied state themselves (goal
@@ -211,7 +369,43 @@ export declare class FoundryPlanApply implements PlanApply {
211
369
  */
212
370
  destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
213
371
  private refuse;
372
+ private refusePlatform;
214
373
  private refuseDestroy;
215
374
  private runSteps;
216
375
  private computeFreshPlan;
376
+ /**
377
+ * #177: an org's plan reads the shared platform's own addresses and
378
+ * readiness through the very `platform` deps `planPlatform`/`applyPlatform`
379
+ * already use. With no platform wired or declared, {@link
380
+ * DEFAULT_ORG_PLATFORM_READINESS} reports every service not ready, so every
381
+ * org step that needs one is blocked.
382
+ */
383
+ private orgPlatformReadiness;
384
+ /**
385
+ * #177: `AgoraHostValues` from the applied platform, the same
386
+ * `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
387
+ * host supplies no `agora` port of its own. An explicit `agora` (#164)
388
+ * always wins, unchanged, so a host that already has its own values
389
+ * (perhaps not yet wired to this platform) keeps them.
390
+ */
391
+ private orgAgoraHost;
392
+ /** #167: `FoundryPlanApplyOptions.platform`, or a plain `Error` naming what's missing. */
393
+ private requirePlatformDeps;
394
+ private computeFreshPlatformPlan;
395
+ private sfuBlockedReason;
396
+ /**
397
+ * {@link runSteps}, for the platform. The ids the steps return are recorded
398
+ * in `PlatformStore`'s applied state (`system:kind:key`, like an org's
399
+ * `serviceIds`), so a later plan finds a resource by its recorded id even
400
+ * when a provider's list has not caught up with a create.
401
+ */
402
+ private runPlatformSteps;
217
403
  }
404
+ /**
405
+ * #9 (resolved): whether any step in a plan needs its name typed before
406
+ * `apply` runs it. Shared by `apply` (the org's `displayName`) and
407
+ * `applyPlatform` (the literal word `platform`, #167) so the platform gets
408
+ * the identical gate an org already has, proven end-to-end above, rather
409
+ * than a second copy that could drift from it.
410
+ */
411
+ export declare function planNeedsConfirmation(steps: readonly PlanStep[]): boolean;