@wtfalch/foundry 0.16.8 → 0.16.9

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/plan/diff.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
- import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
3
+ import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
4
  import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
6
  import { planEntryAiKeys } from './entry-ai-key.js';
@@ -1003,6 +1003,15 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
1003
1003
  * #177: blocked, not planned, while `filesReady` is false -- the platform's
1004
1004
  * own files host (#171) is off or not yet applied, so there is nothing here
1005
1005
  * for `FilesOrganisationPort.provisionOrganisation` to reach.
1006
+ *
1007
+ * #273: `desired.agoraOrigin` is every org's own Agora origin (#164's
1008
+ * `agoraHostnameFor`, always on, never blocked on anything this step
1009
+ * doesn't already wait for) -- `executor.ts`'s `filesOrganisation` adds it
1010
+ * to the files host's CORS allowlist alongside provisioning. Known gap: a
1011
+ * later domain change that moves Agora's hostname is not replanned here,
1012
+ * since this step (like `planIssuer`) has nothing to diff once the org's
1013
+ * Files organisation already exists -- the old origin is left allowed
1014
+ * rather than silently dropped.
1006
1015
  */
1007
1016
  function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
1008
1017
  const key = resourceKey('files', 'organisation', org.id);
@@ -1023,7 +1032,12 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
1023
1032
  system: 'files',
1024
1033
  kind: 'organisation',
1025
1034
  key: org.id,
1026
- desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
1035
+ desired: {
1036
+ provision: true,
1037
+ ceilingBytes: null,
1038
+ ceilingMicros: null,
1039
+ agoraOrigin: `https://${agoraHostnameFor(org)}`,
1040
+ },
1027
1041
  },
1028
1042
  step: {
1029
1043
  description: `provision ${org.slug}'s own organisation on Files`,
@@ -1114,6 +1114,20 @@ export interface FilesOrganisationPort {
1114
1114
  name: string;
1115
1115
  created: boolean;
1116
1116
  }>;
1117
+ /**
1118
+ * #273: adds `origin` to the files host's `FILES_ALLOWED_ORIGINS`
1119
+ * allowlist (`wtfalch/files#109`) -- additively, never removing another
1120
+ * organisation's entry already there. That list is a Coolify env var on
1121
+ * the files runner app, not a database row the files management API
1122
+ * reports back (files#109's own reasoning: a database round trip on the
1123
+ * unauthenticated CORS preflight is both a hot-path cost and a new way to
1124
+ * fail closed), so there is nothing here to diff against a live value --
1125
+ * the host decides how `origin` actually reaches that env var, the same
1126
+ * way every other method on this port decides how to speak the service's
1127
+ * protocol. Safely repeatable: calling it again with an origin already on
1128
+ * the list is a no-op.
1129
+ */
1130
+ ensureAllowedOrigin(organisationId: string, origin: string): Promise<void>;
1117
1131
  }
1118
1132
  /**
1119
1133
  * Provisions one org's organisation on AI and switches its AI use on (#80),
@@ -2345,6 +2359,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2345
2359
  * safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
2346
2360
  * this is called every time regardless of whether it already ran --
2347
2361
  * idempotent by find-or-create, not by checking `recordedIds` first.
2362
+ *
2363
+ * #273: also ensures the org's Agora origin is on the files host's CORS
2364
+ * allowlist (`FilesOrganisationPort.ensureAllowedOrigin`), the same
2365
+ * repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
2366
+ * gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
2367
+ * `desired.agoraOrigin`.
2348
2368
  */
2349
2369
  private filesOrganisation;
2350
2370
  /**
@@ -3970,6 +3970,12 @@ export class FoundryStepExecutor {
3970
3970
  * safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
3971
3971
  * this is called every time regardless of whether it already ran --
3972
3972
  * idempotent by find-or-create, not by checking `recordedIds` first.
3973
+ *
3974
+ * #273: also ensures the org's Agora origin is on the files host's CORS
3975
+ * allowlist (`FilesOrganisationPort.ensureAllowedOrigin`), the same
3976
+ * repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
3977
+ * gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
3978
+ * `desired.agoraOrigin`.
3973
3979
  */
3974
3980
  async filesOrganisation(action, org, context) {
3975
3981
  const files = await this.filesPort();
@@ -3985,6 +3991,9 @@ export class FoundryStepExecutor {
3985
3991
  ceilingMicros: action.desired.ceilingMicros ?? null,
3986
3992
  ...(owner ? { name: org.displayName, owner } : {}),
3987
3993
  });
3994
+ const agoraOrigin = action.desired.agoraOrigin;
3995
+ if (agoraOrigin)
3996
+ await files.ensureAllowedOrigin(org.id, agoraOrigin);
3988
3997
  // Recorded now, so the org key is minted below the member files made.
3989
3998
  if (owner && provisioned.created)
3990
3999
  context.recordScopeId(FILES_ORG_OWNER_SCOPE_KEY, owner);
@@ -250,7 +250,8 @@ export declare const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = "foundry:platform:adm
250
250
  * ({@link PlatformMailDeps.stalwartForInstance}), the same way
251
251
  * `zitadelForInstance` is built from a freshly issued instance's issuer URL.
252
252
  * {@link defaultStalwartForInstance} is the real implementation, wrapping
253
- * `@wtfalch/email/postmaster`'s `openInstance`, `applyObjects` with
253
+ * `@wtfalch/email/postmaster`'s `openInstance`, `Bootstrap/set` while the
254
+ * server is still in bootstrap mode, `applyObjects` with
254
255
  * `platformMailSettingsObjects` (see `provisionPlatformMail`'s own doc
255
256
  * comment for why not `mailObjects`) and `StalwartClient.createApiKey`.
256
257
  */
@@ -258,7 +259,8 @@ export interface PlatformMailAdminPort {
258
259
  /**
259
260
  * Sets the instance's default hostname and its OIDC directory
260
261
  * (`x:SystemSettings`/`x:Directory`/`x:Domain`/`x:Authentication`) --
261
- * idempotent, safe to call on every apply once the instance is up.
262
+ * idempotent, safe to call on every apply once the instance is up. A fresh
263
+ * instance is first taken out of bootstrap mode.
262
264
  */
263
265
  applySettings(input: {
264
266
  mailHost: string;
@@ -1413,7 +1413,20 @@ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join
1413
1413
  * ZITADEL/login apps.
1414
1414
  */
1415
1415
  const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
1416
- const STALWART_DATA_MOUNT = '/opt/stalwart';
1416
+ /**
1417
+ * The v0.16 image's two VOLUMEs (its Dockerfile): the data store, and the
1418
+ * `config.json` Stalwart writes when bootstrap completes. The pre-0.16 image
1419
+ * used `/opt/stalwart`, which v0.16 does not use. Each is a Coolify
1420
+ * persistent storage (a Docker named volume, not a host path), so Docker
1421
+ * seeds it from the image on first use and keeps the image's UID 2000
1422
+ * ownership, which the container needs to write it.
1423
+ */
1424
+ // Suffixes differ from the pre-0.16 storage's `-data` (still attached at
1425
+ // /opt/stalwart on apps created before), so a new storage never shares its name.
1426
+ const STALWART_STORAGES = [
1427
+ { suffix: 'var-lib', mount_path: '/var/lib/stalwart' },
1428
+ { suffix: 'etc', mount_path: '/etc/stalwart' },
1429
+ ];
1417
1430
  /** `STALWART_RECOVERY_ADMIN=<user>:<password>` -- the fixed recovery account name; only the password half is generated. */
1418
1431
  const STALWART_RECOVERY_USER = 'admin';
1419
1432
  /**
@@ -1474,6 +1487,20 @@ export function defaultStalwartForInstance(input) {
1474
1487
  });
1475
1488
  return {
1476
1489
  async applySettings({ mailHost, apex, oidc }) {
1490
+ // A fresh Stalwart (0.16) is in bootstrap mode and refuses every write
1491
+ // but the `Bootstrap` object, so complete that first. A server already
1492
+ // past it skips this. `update` drops the response, which carries the
1493
+ // generated `admin@<apex>` secret: this step signs in with the
1494
+ // recovery admin and never needs it.
1495
+ if ((await instance.client.probe()) === 'bootstrap') {
1496
+ await instance.client.update('Bootstrap', 'singleton', {
1497
+ serverHostname: mailHost,
1498
+ defaultDomain: apex,
1499
+ // The server sits behind a tunnel; no ACME challenge can reach it.
1500
+ requestTlsCertificate: false,
1501
+ });
1502
+ await instance.client.waitFor('ready');
1503
+ }
1477
1504
  await applyObjects(instance.client, platformMailSettingsObjects({ apex, mailHost, oidc }), () => { });
1478
1505
  },
1479
1506
  createApiKey: (description, permissions) => instance.client.createApiKey(description, permissions),
@@ -1608,12 +1635,19 @@ async function provisionPlatformMail(context, deps) {
1608
1635
  await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`);
1609
1636
  // ---- storage ---------------------------------------------------------------
1610
1637
  const storages = await deps.coolify.listStorages(appId);
1611
- const hasDataVolume = (storages.persistent_storages ?? []).some((s) => s.mount_path === STALWART_DATA_MOUNT);
1612
- if (!hasDataVolume) {
1638
+ // An app made before this release only has the old `/opt/stalwart` volume.
1639
+ // That one is left in place (empty and unused; detaching it needs a call this
1640
+ // step has no port for) and the two real ones are added.
1641
+ let storagesAdded = false;
1642
+ for (const wanted of STALWART_STORAGES) {
1643
+ if ((storages.persistent_storages ?? []).some((st) => st.mount_path === wanted.mount_path)) {
1644
+ continue;
1645
+ }
1613
1646
  await deps.coolify.createPersistentStorage(appId, {
1614
- name: `${PLATFORM_MAIL_APP_NAME}-data`,
1615
- mount_path: STALWART_DATA_MOUNT,
1647
+ name: `${PLATFORM_MAIL_APP_NAME}-${wanted.suffix}`,
1648
+ mount_path: wanted.mount_path,
1616
1649
  });
1650
+ storagesAdded = true;
1617
1651
  }
1618
1652
  // ---- env ---------------------------------------------------------------------
1619
1653
  // Generated once; unlike ZITADEL's masterkey this is never re-derived from
@@ -1630,7 +1664,8 @@ async function provisionPlatformMail(context, deps) {
1630
1664
  await deps.coolify.bulkUpsertEnvs(appId, envs);
1631
1665
  const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
1632
1666
  recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
1633
- if (app.justCreated || !app.running || envsChanged || declaredChanged) {
1667
+ // A new storage binds into the container only at its next start.
1668
+ if (app.justCreated || !app.running || envsChanged || declaredChanged || storagesAdded) {
1634
1669
  // A container still up from before would answer the readiness probe with
1635
1670
  // the old deployment's health. Stop it and see it stopped first, so the
1636
1671
  // wait below only ever reads the new deployment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.16.8",
3
+ "version": "0.16.9",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",