@wtfalch/foundry 0.16.15 → 0.16.16

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.
@@ -294,10 +294,13 @@ export interface CoolifyPort {
294
294
  * fresh Synapse/MAS config file, since a file-storage write alone does not
295
295
  * reach the running container until its next deploy (issue #128's "a
296
296
  * running app is redeployed when its config changed"). Fire-and-forget,
297
- * like `restart` above: this executor does not poll a deployment to
298
- * completion anywhere else, and `coolifyCalls` only calls this when the
299
- * config it just wrote was actually freshly minted. Matches `CoolifyClient.
300
- * deploy` method-for-method.
297
+ * like `restart` above: `coolifyCalls` only calls this when the config it
298
+ * just wrote was actually freshly minted, with nothing recorded afterward
299
+ * that depends on the new version actually being live. Matches
300
+ * `CoolifyClient.deploy` method-for-method. A step that records an applied
301
+ * hostname/gitRef after a deploy uses {@link CoolifyPort.deployAndWait}
302
+ * instead (#252) — `deploy-health.ts`'s `waitForNewDeployment` is the one
303
+ * shared helper every such step calls, never this method directly.
301
304
  */
302
305
  deploy(uuid: string, force?: boolean): Promise<unknown>;
303
306
  /**
@@ -361,6 +364,27 @@ export interface CoolifyPort {
361
364
  }): Promise<{
362
365
  uuid: string;
363
366
  }>;
367
+ /**
368
+ * Deploys and blocks until Coolify's own deployment record reaches a
369
+ * terminal state (#252) — matches `CoolifyClient.deployAndWait`
370
+ * method-for-method. Unlike `deploy` above, or unlike polling
371
+ * `getApplication`'s live `status`, this is the one honest answer to
372
+ * "did the new version actually land": a failed build leaves the previous
373
+ * container running and healthy, so `status` and a served request read
374
+ * identically whether the deploy succeeded or not (`CoolifyDeploymentRecord`'s
375
+ * own doc comment in `@wtfalch/providers`). `force` defaults to `true` on
376
+ * the real client for the same reason its own doc comment gives — every
377
+ * caller here is provisioning or releasing, where a wrong "it's applied"
378
+ * costs more than a warm build cache.
379
+ */
380
+ deployAndWait(uuid: string, options?: {
381
+ force?: boolean;
382
+ attempts?: number;
383
+ intervalMs?: number;
384
+ }): Promise<{
385
+ deploymentUuid: string;
386
+ status: string;
387
+ }>;
364
388
  }
365
389
  /**
366
390
  * The Cloudflare calls this executor makes, shaped to match
@@ -812,7 +836,7 @@ export interface ZitadelPort {
812
836
  givenName: string;
813
837
  familyName: string;
814
838
  email: string;
815
- }): Promise<{
839
+ }, urlTemplate?: string): Promise<{
816
840
  userId: string;
817
841
  created: boolean;
818
842
  }>;
@@ -1341,6 +1365,16 @@ export interface FoundryStepExecutorPorts {
1341
1365
  * themselves.
1342
1366
  */
1343
1367
  deliverSecret(ref: string, secret: string): Promise<void>;
1368
+ /**
1369
+ * Archon's held keys, for an entry env from `sharedSecret` (read, else
1370
+ * generate and `put`) and `platformMailKey` (read only). Optional: without
1371
+ * it, or without `put`, such an env is omitted and the plan reports it
1372
+ * blocked. A value goes to the entry's env and nowhere else.
1373
+ */
1374
+ heldKeys?: {
1375
+ resolve(ref: string): Promise<string | null | undefined>;
1376
+ put?(ref: string, value: string): Promise<void>;
1377
+ };
1344
1378
  /**
1345
1379
  * Read access to the declared-org store, narrowed to the one query
1346
1380
  * `cloudflareZone` needs for its cross-org refusal check (#131) -- see that
@@ -1636,6 +1670,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1636
1670
  * provisioned, where this one is unconditional (#239).
1637
1671
  */
1638
1672
  private pushAppEnv;
1673
+ /**
1674
+ * The org's shared secret `name`: read from the held keys, else generated
1675
+ * and held first, so a crash after the `put` and before the env write
1676
+ * hands the same value out on the next apply. Only called when `put` exists.
1677
+ * An error here names the ref, never a value.
1678
+ */
1679
+ private ensureSharedSecret;
1639
1680
  /**
1640
1681
  * F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
1641
1682
  * of at least 32 characters (the broker refuses a shorter one). `null` once
@@ -2360,6 +2401,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2360
2401
  * this is called every time regardless of whether it already ran --
2361
2402
  * idempotent by find-or-create, not by checking `recordedIds` first.
2362
2403
  *
2404
+ * F6b: `action.desired.shareHost` (`diff.ts`'s `planFilesOrganisation`) is
2405
+ * passed through every time, even though `entry-files-key.ts` reports the
2406
+ * route itself as blocked until the platform's files host can serve
2407
+ * `share.<domain>` -- this is the one place the name reaches files at all,
2408
+ * so files can act on it the moment its own route exists.
2409
+ *
2363
2410
  * #273: also ensures the org's Agora origin is on the files host's CORS
2364
2411
  * allowlist (`FilesOrganisationPort.ensureAllowedOrigin`), the same
2365
2412
  * repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
- import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
4
+ import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
5
  import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
6
  import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
@@ -14,6 +14,7 @@ import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnel
14
14
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
15
15
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
16
16
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
17
+ import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
17
18
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
18
19
  /**
19
20
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
@@ -861,11 +862,16 @@ export class FoundryStepExecutor {
861
862
  if (!zitadelOrgId) {
862
863
  throw new Error(`zitadel:owner for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
863
864
  }
865
+ // ZITADEL's default invite link (`/ui/v2/login/verify`) 404s; the platform's login app, which
866
+ // every org's users sign in through on this instance, serves `/verify` at the root.
867
+ const authIssuer = action.desired.authIssuer;
864
868
  const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
865
869
  givenName: action.desired.givenName,
866
870
  familyName: action.desired.familyName,
867
871
  email: action.desired.email,
868
- });
872
+ }, authIssuer
873
+ ? `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`
874
+ : undefined);
869
875
  // An owner invited after chat was built never reaches `coolifyChat`
870
876
  // again, so the role is granted here once the messaging project exists.
871
877
  const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
@@ -1424,7 +1430,13 @@ export class FoundryStepExecutor {
1424
1430
  // The app receives whatever it needs to sign in (gap P2). Unconditional,
1425
1431
  // unlike the database push above: every app has a hostname, and an
1426
1432
  // upsert of the same value on a later apply is a no-op, not a change.
1427
- const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null);
1433
+ const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null,
1434
+ // What the plan decided is available (diff.ts `planApps`), so the marker
1435
+ // recorded below is the one the plan compares against. Absent: none.
1436
+ action.desired.secretSources ?? {
1437
+ platformMailKey: false,
1438
+ sharedSecret: false,
1439
+ });
1428
1440
  // Rule 4 (#239, #243): a container reads its env only at start, so an app
1429
1441
  // that was already there is restarted, once, when its database URLs or its
1430
1442
  // declared appEnv changed.
@@ -1633,7 +1645,7 @@ export class FoundryStepExecutor {
1633
1645
  * that call runs only once the `app` entry's `database` is declared and
1634
1646
  * provisioned, where this one is unconditional (#239).
1635
1647
  */
1636
- async pushAppEnv(appId, org, entry, context, authIssuer) {
1648
+ async pushAppEnv(appId, org, entry, context, authIssuer, secretSources) {
1637
1649
  const recorded = context.recordedIds();
1638
1650
  const envs = [
1639
1651
  // Only the `app` entry has a public name; the others are internal.
@@ -1691,14 +1703,44 @@ export class FoundryStepExecutor {
1691
1703
  // minted here; a source with no value yet (its own step has not run,
1692
1704
  // or has not run for this apply) is simply omitted, the same way every
1693
1705
  // other optional entry above is.
1694
- const appEnv = resolveAppEnv(org, entry, recorded).values;
1706
+ // The secret sources (`sharedSecret`, `platformMailKey`) are filled here
1707
+ // only, straight into `envs`: never recorded, returned or logged.
1708
+ const heldKeys = this.ports.heldKeys;
1709
+ // Availability is the plan's (`secretSources`). If the plan said a key is
1710
+ // held and this port cannot read it, the host wired the two differently:
1711
+ // fail by ref, never push a marker for an env that was not written.
1712
+ let mailKey = null;
1713
+ if (secretSources.platformMailKey) {
1714
+ try {
1715
+ mailKey = (await heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) || null;
1716
+ }
1717
+ catch {
1718
+ // reported below, by ref
1719
+ }
1720
+ if (mailKey === null) {
1721
+ throw new Error(`coolify:app for ${org.slug}: the plan found ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF} held but the executor's heldKeys cannot read it`);
1722
+ }
1723
+ }
1724
+ if (secretSources.sharedSecret && typeof heldKeys?.put !== 'function') {
1725
+ throw new Error(`coolify:app for ${org.slug}: the plan found held keys that can put but the executor's heldKeys cannot`);
1726
+ }
1727
+ const resolved = resolveAppEnv(org, entry, recorded, secretSources);
1728
+ const appEnv = resolved.values;
1695
1729
  for (const [key, value] of Object.entries(appEnv))
1696
1730
  envs.push({ key, value });
1731
+ for (const { name, source } of resolved.secrets) {
1732
+ envs.push({
1733
+ key: name,
1734
+ value: source === 'platformMailKey'
1735
+ ? mailKey
1736
+ : await this.ensureSharedSecret(org, source.name, recorded),
1737
+ });
1738
+ }
1697
1739
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
1698
1740
  // `coolifyApp` records `applied` once the restart succeeded, so `planApp`
1699
1741
  // replans this step when the declared map changes (rule 3). Ids and a
1700
1742
  // domain, never a secret.
1701
- const applied = JSON.stringify(appEnv);
1743
+ const applied = JSON.stringify(resolved.marker);
1702
1744
  // The sign-in values, recorded by `coolifyApp` the same way; the key is
1703
1745
  // never part of them.
1704
1746
  const authApplied = JSON.stringify(authEnv);
@@ -1710,6 +1752,28 @@ export class FoundryStepExecutor {
1710
1752
  authApplied,
1711
1753
  };
1712
1754
  }
1755
+ /**
1756
+ * The org's shared secret `name`: read from the held keys, else generated
1757
+ * and held first, so a crash after the `put` and before the env write
1758
+ * hands the same value out on the next apply. Only called when `put` exists.
1759
+ * An error here names the ref, never a value.
1760
+ */
1761
+ async ensureSharedSecret(org, name, recorded) {
1762
+ const heldKeys = this.ports.heldKeys;
1763
+ const ref = sharedSecretRef(org.id, name);
1764
+ const held = await heldKeys?.resolve(ref);
1765
+ if (held)
1766
+ return held;
1767
+ // Another entry (or this one) already got a value. Generating again would
1768
+ // split them, so a lost value needs a person, not a new mint.
1769
+ const label = `"secret:sharedSecret:${name}"`;
1770
+ if (Object.entries(recorded).some(([key, value]) => key.startsWith('app_env_applied.') && value.includes(label))) {
1771
+ throw new Error(`coolify:app for ${org.slug}: shared secret ${name} was delivered but is no longer held (ref ${ref}); restore the held value, or clear it from every entry's env and applied marker to mint a new one`);
1772
+ }
1773
+ const value = randomBytes(32).toString('base64url');
1774
+ await heldKeys?.put?.(ref, value);
1775
+ return value;
1776
+ }
1713
1777
  /**
1714
1778
  * F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
1715
1779
  * of at least 32 characters (the broker refuses a shorter one). `null` once
@@ -3971,6 +4035,12 @@ export class FoundryStepExecutor {
3971
4035
  * this is called every time regardless of whether it already ran --
3972
4036
  * idempotent by find-or-create, not by checking `recordedIds` first.
3973
4037
  *
4038
+ * F6b: `action.desired.shareHost` (`diff.ts`'s `planFilesOrganisation`) is
4039
+ * passed through every time, even though `entry-files-key.ts` reports the
4040
+ * route itself as blocked until the platform's files host can serve
4041
+ * `share.<domain>` -- this is the one place the name reaches files at all,
4042
+ * so files can act on it the moment its own route exists.
4043
+ *
3974
4044
  * #273: also ensures the org's Agora origin is on the files host's CORS
3975
4045
  * allowlist (`FilesOrganisationPort.ensureAllowedOrigin`), the same
3976
4046
  * repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
@@ -3989,6 +4059,7 @@ export class FoundryStepExecutor {
3989
4059
  organisationId: org.id,
3990
4060
  ceilingBytes: action.desired.ceilingBytes ?? null,
3991
4061
  ceilingMicros: action.desired.ceilingMicros ?? null,
4062
+ shareHost: action.desired.shareHost ?? null,
3992
4063
  ...(owner ? { name: org.displayName, owner } : {}),
3993
4064
  });
3994
4065
  const agoraOrigin = action.desired.agoraOrigin;
@@ -4429,6 +4500,7 @@ export class FoundryStepExecutor {
4429
4500
  const envs = [
4430
4501
  { key: 'FILES_URL', value: d.filesUrl },
4431
4502
  { key: 'FILES_ORGANISATION_ID', value: org.id },
4503
+ ...(d.shareOrigin ? [{ key: 'FILES_SHARE_ORIGIN', value: d.shareOrigin }] : []),
4432
4504
  ];
4433
4505
  for (const name of d.collections) {
4434
4506
  let collection;
@@ -4495,6 +4567,7 @@ export class FoundryStepExecutor {
4495
4567
  permissions: d.permissions,
4496
4568
  collections: d.collections,
4497
4569
  filesUrl: d.filesUrl,
4570
+ ...(d.shareOrigin ? { shareOrigin: d.shareOrigin } : {}),
4498
4571
  }));
4499
4572
  if (expiresAt && previousKeyId && previousKeyId !== resourceId) {
4500
4573
  await this.revokeFilesEntryKey(files, org, entryKey, previousKeyId, context);
@@ -26,6 +26,12 @@ export declare function appHostname(org: DeclaredOrg): string;
26
26
  export declare function coolifyDomains(names: readonly string[]): string;
27
27
  /** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
28
28
  export declare function legacyAppHostname(org: DeclaredOrg): string;
29
+ /**
30
+ * `share.<domain>` when hosting is on and an entry declares
31
+ * `uses.files.shareHost`, else `null`. The files service answers public
32
+ * shares there; foundry never puts it behind Access.
33
+ */
34
+ export declare function filesShareHostname(org: DeclaredOrg): string | null;
29
35
  /**
30
36
  * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
31
37
  * #164): a site's two names, every entry's names, an own-domain calls name,
@@ -47,6 +47,16 @@ export function coolifyDomains(names) {
47
47
  export function legacyAppHostname(org) {
48
48
  return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
49
49
  }
50
+ /**
51
+ * `share.<domain>` when hosting is on and an entry declares
52
+ * `uses.files.shareHost`, else `null`. The files service answers public
53
+ * shares there; foundry never puts it behind Access.
54
+ */
55
+ export function filesShareHostname(org) {
56
+ if (!org.hosting.enabled || !org.domain)
57
+ return null;
58
+ return org.hosting.entries.some((e) => e.uses?.files?.shareHost) ? `share.${org.domain}` : null;
59
+ }
50
60
  /**
51
61
  * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
52
62
  * #164): a site's two names, every entry's names, an own-domain calls name,
@@ -71,6 +81,9 @@ export function tunnelIngressHostnames(org) {
71
81
  // #161: and Boule moves onto `boule.<domain>` the same way.
72
82
  hostnames.push(bouleHostnameFor(org));
73
83
  }
84
+ const share = filesShareHostname(org);
85
+ if (share)
86
+ hostnames.push(share);
74
87
  // The wildcard goes last: Cloudflare takes the first ingress rule that
75
88
  // matches, so every exact name above must come before `*.<domain>`.
76
89
  const wildcard = wildcardHostname(org);
@@ -10,7 +10,7 @@ export type { PlatformOrgClientFactories } from './platform-org-ports.js';
10
10
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
11
11
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
12
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';
13
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
14
14
  export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './platform-providers.js';
15
15
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
16
16
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
@@ -4,7 +4,7 @@ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLAT
4
4
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
5
5
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
6
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';
7
+ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
8
8
  // #171: how a host signs files management requests with the delivered key.
9
9
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
10
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';
@@ -210,6 +210,12 @@ export interface PlatformStepExecutor {
210
210
  /** #257 fix round 4: see `PlanPlatformOptions.drifted.backupStorageBlocked` (`platform-diff.ts`). */
211
211
  backupStorageBlocked?: string;
212
212
  }>;
213
+ /**
214
+ * Gives the platform files runner a changed share-domain set and touches
215
+ * nothing else. Returns the scope ids to record (only the share-domains
216
+ * one), or `null` when nothing changed.
217
+ */
218
+ refreshFilesShare?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<Record<string, string> | null>;
213
219
  execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
214
220
  resourceId?: string;
215
221
  } | undefined>;
@@ -378,6 +384,17 @@ export declare class FoundryPlanApply implements PlanApply {
378
384
  constructor(options: FoundryPlanApplyOptions);
379
385
  plan(id: OrgId): Promise<Plan>;
380
386
  apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
387
+ /**
388
+ * After an org apply: gives the platform files runner a changed share-domain
389
+ * set (its Coolify `domains` and `FILES_PUBLIC_HOSTS`, derived from the
390
+ * declared orgs) without a platform pass. Touches only the share hosts and
391
+ * redeploys the runner; the platform files step itself never runs here.
392
+ * Skips quietly for an actor who may not apply the platform, and when the
393
+ * platform files step was never applied. There is no platform lock, so the
394
+ * service ids are re-read just before the write and only the share-domains
395
+ * key is recorded. A failure is rethrown naming that the org part succeeded.
396
+ */
397
+ private refreshPlatformFilesShare;
381
398
  /**
382
399
  * #167: {@link plan}, for the shared platform (#166) rather than an org --
383
400
  * there is no id to pass, since `DeclaredPlatform` is one singleton record.
@@ -8,8 +8,9 @@ import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
8
8
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
9
9
  import { readLivePlatformResources, readLiveResources, } from './live.js';
10
10
  import { lockedHostnames } from './locked-hostnames.js';
11
- import { PLATFORM_ARCHON_KEY, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
11
+ import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
12
12
  import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
13
+ import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
13
14
  export class DeclaredOrgNotFoundError extends Error {
14
15
  constructor(orgId) {
15
16
  super(`no declared org ${orgId}`);
@@ -139,7 +140,42 @@ export class FoundryPlanApply {
139
140
  await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
140
141
  throw new ConfirmationRequiredError(orgId);
141
142
  }
142
- return this.runSteps(org, plan, detail);
143
+ const applied = await this.runSteps(org, plan, detail);
144
+ await this.refreshPlatformFilesShare();
145
+ return applied;
146
+ }
147
+ /**
148
+ * After an org apply: gives the platform files runner a changed share-domain
149
+ * set (its Coolify `domains` and `FILES_PUBLIC_HOSTS`, derived from the
150
+ * declared orgs) without a platform pass. Touches only the share hosts and
151
+ * redeploys the runner; the platform files step itself never runs here.
152
+ * Skips quietly for an actor who may not apply the platform, and when the
153
+ * platform files step was never applied. There is no platform lock, so the
154
+ * service ids are re-read just before the write and only the share-domains
155
+ * key is recorded. A failure is rethrown naming that the org part succeeded.
156
+ */
157
+ async refreshPlatformFilesShare() {
158
+ const deps = this.platformDeps;
159
+ if (!deps?.executor.refreshFilesShare)
160
+ return;
161
+ if ((await this.authz.canApplyPlatform?.(this.actor)) !== true)
162
+ return;
163
+ try {
164
+ const platform = await deps.store.get();
165
+ if (!platform?.files.enabled)
166
+ return;
167
+ const recorded = await deps.store.getServiceIds();
168
+ if (!recorded[`coolify:app:${PLATFORM_FILES_KEY}`] ||
169
+ !recorded[PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY])
170
+ return;
171
+ const scopeIds = await deps.executor.refreshFilesShare(platform, recorded);
172
+ if (scopeIds === null)
173
+ return;
174
+ await deps.store.putServiceIds({ ...(await deps.store.getServiceIds()), ...scopeIds });
175
+ }
176
+ catch (e) {
177
+ throw new Error(`org applied; platform files share refresh failed: ${e instanceof Error ? e.message : String(e)}`);
178
+ }
143
179
  }
144
180
  /**
145
181
  * #167: {@link plan}, for the shared platform (#166) rather than an org --
@@ -443,7 +479,16 @@ export class FoundryPlanApply {
443
479
  refs.add(ORG_PLATFORM_KEY_REFS.ai);
444
480
  if (this.jobsScheduler)
445
481
  refs.add(ORG_PLATFORM_KEY_REFS.jobs);
446
- return orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys);
482
+ const readiness = orgPlatformReadinessFrom(platform, recorded, refs, !heldKeys);
483
+ // Entry env from the platform's mail key or a shared secret (#T8).
484
+ let mailKeyHeld = false;
485
+ try {
486
+ mailKeyHeld = Boolean(await heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF));
487
+ }
488
+ catch {
489
+ // not held
490
+ }
491
+ return { ...readiness, mailKeyHeld, canHoldSecrets: typeof heldKeys?.put === 'function' };
447
492
  }
448
493
  /**
449
494
  * #177: `AgoraHostValues` from the applied platform, the same
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, HostingEntry, OrgId } from '../org.js';
1
+ import type { DeclaredOrg, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
2
2
  import { type Plan, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import type { PlannedStep } from './diff.js';
@@ -107,6 +107,17 @@ export declare const PLATFORM_ARCHON_KEY = "platform:archon";
107
107
  */
108
108
  export declare const PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY = "archon_applied_hostname";
109
109
  export declare const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = "archon_applied_git_ref";
110
+ /**
111
+ * #398: the integrations management key id Archon runs with, recorded by
112
+ * `provisionPlatformArchon` only after its deployment finished and its health
113
+ * check passed with that id in its env. `provisionPlatformIntegrations`'s
114
+ * rotation step reads this before dropping a previous key id from the
115
+ * runner's `MANAGEMENT_PUBLIC_KEYS`: the old key stays pinned until this
116
+ * matches the new id, so a request signed by an Archon container still
117
+ * running the old key is never refused. If Archon's deploy fails this stays
118
+ * unrecorded and the old key stays pinned.
119
+ */
120
+ export declare const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = "archon_integrations_key_synced";
110
121
  /**
111
122
  * #211 round 4: the broker app id Archon last registered its entry with.
112
123
  * `provisionPlatformArchon` reads the broker to register with from
@@ -118,6 +129,19 @@ export declare const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = "archon_applied
118
129
  * changed hostname or `gitRef` does.
119
130
  */
120
131
  export declare const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = "archon_applied_broker_app";
132
+ /**
133
+ * The `AUTHZ_BOOTSTRAP_OPERATOR` the last Archon apply set (`none` when it set
134
+ * none). `planArchon` plans the step again when the owner's id recorded by
135
+ * `platform:auth` differs from it, so Archon picks the owner up once auth
136
+ * has recorded it.
137
+ */
138
+ export declare const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = "archon_applied_bootstrap_operator";
139
+ /**
140
+ * The owner's ZITADEL user id Archon takes as its first operator, from
141
+ * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY}. Undefined when none is
142
+ * recorded or the recorded value is `none` (no owner declared).
143
+ */
144
+ export declare function archonBootstrapOperator(recorded: Readonly<Record<string, string>>): string | undefined;
121
145
  /**
122
146
  * #175: set to '1' before the step points the tunnel at `http_status:404`
123
147
  * (Access app rebuild), cleared to '' once the real ingress is written back.
@@ -400,14 +424,31 @@ export declare function appDomainsAppliedScopeKey(entryKey: string): string;
400
424
  * entry), so an app that runs today plans no change.
401
425
  */
402
426
  export declare function appliedAppDomains(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, entryKey: string): string[];
427
+ /** Whether the secret sources can be filled right now: the mail key is held, and a new shared secret can be held. */
428
+ export interface AppSecretAvailability {
429
+ platformMailKey: boolean;
430
+ sharedSecret: boolean;
431
+ }
403
432
  /**
404
433
  * The entry's `env` values that have a value, in env-name order, and the env
405
434
  * names whose source has none yet. Shared by `planApp` and the executor's
406
435
  * `pushAppEnv` so both read the same recorded ids.
436
+ *
437
+ * A secret source is never a value here. `secrets` names each one that can be
438
+ * filled (`available`), `unresolvedSecrets` each that cannot, and `marker`
439
+ * is `values` plus a `secret:<source>` label per available secret: the JSON
440
+ * `coolifyApp` records as applied, so a declared change replans and no secret
441
+ * is written into it.
407
442
  */
408
- export declare function resolveAppEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>): {
443
+ export declare function resolveAppEnv(org: DeclaredOrg, entry: HostingEntry, recorded: Readonly<Record<string, string>>, available?: AppSecretAvailability): {
409
444
  values: Record<string, string>;
410
445
  unresolved: Array<[string, string]>;
446
+ secrets: Array<{
447
+ name: string;
448
+ source: 'platformMailKey' | EnvSharedSecret;
449
+ }>;
450
+ unresolvedSecrets: Array<[string, string]>;
451
+ marker: Record<string, string>;
411
452
  };
412
453
  /**
413
454
  * #172: the resource key `planAi` plans under and `FoundryPlatformStepExecutor`
@@ -478,13 +519,6 @@ export declare const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = "ai_platform_migrat
478
519
  * that throws) is repaired by the next one.
479
520
  */
480
521
  export declare const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = "ai_platform_deploy_pending";
481
- /**
482
- * The Coolify deployment queued for the host (its `deployment_uuid`, or
483
- * `untracked` when the deploy answered none), set when that deploy is queued
484
- * and cleared once it finished. The mint waits for it, so a retry after a
485
- * failed wait still waits instead of minting against the old container.
486
- */
487
- export declare const PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY = "ai_platform_runner_deployment";
488
522
  /**
489
523
  * #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
490
524
  * dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
@@ -684,3 +718,49 @@ export declare const PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY = "integrations_man
684
718
  /** The hostname and gitRef the last successful apply put live; a difference from the declared ones plans the step again. */
685
719
  export declare const PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY = "integrations_applied_hostname";
686
720
  export declare const PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY = "integrations_applied_gitref";
721
+ /**
722
+ * #398: the key id currently pinned on the runner as the active entry of
723
+ * `MANAGEMENT_PUBLIC_KEYS` -- `foundry-1` on the first mint, and whatever a
724
+ * rotation minted next after that. `collectArchonServiceInputs` reads this
725
+ * (falling back to the fixed `INTEGRATIONS_MANAGEMENT_KEY_ID` constant for a
726
+ * platform applied before this key existed) for Archon's own
727
+ * `INTEGRATIONS_MANAGEMENT_KEY_ID` env, so a rotated id reaches it without
728
+ * Archon's step needing to know anything happened on the integrations side.
729
+ */
730
+ export declare const PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY = "integrations_management_key_id";
731
+ /**
732
+ * #398: the key id the runner was last deployed with in its
733
+ * `MANAGEMENT_PUBLIC_KEYS`, recorded by `provisionPlatformIntegrations` only
734
+ * after the runner's deployment finished. Archon is not moved to a new key id
735
+ * until this equals the active id, so Archon never signs with a key the
736
+ * running runner does not yet hold.
737
+ */
738
+ export declare const PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY = "integrations_runner_keyring_deployed";
739
+ /**
740
+ * #398: the same two-key discipline as
741
+ * {@link PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY}, for a rotation's
742
+ * new pair: recorded the moment it is minted, before delivery, and cleared
743
+ * by nothing -- {@link PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY} moving past it
744
+ * is what later runs read as "delivered and pinned", the same way the
745
+ * once-only mint's guard is a second key rather than removing the first.
746
+ * An id here that {@link PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY} has not
747
+ * caught up to means the pair may be lost or half-applied; the step reports
748
+ * it and mints no further pair.
749
+ */
750
+ export declare const PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY = "integrations_management_key_rotation_undelivered";
751
+ /**
752
+ * #398: the previous key id, still pinned in `MANAGEMENT_PUBLIC_KEYS` beside
753
+ * the new one, pending removal -- set the moment the new pair is pinned
754
+ * beside it, and cleared (to `''`, the {@link PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY}
755
+ * convention for "was set, now isn't") once the previous id is actually
756
+ * removed from the keyring. Its presence is the overlap window: both keys
757
+ * verify a request while it holds.
758
+ */
759
+ export declare const PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY = "integrations_management_key_rotation_previous";
760
+ /**
761
+ * #398: how a rotation is requested. A person records any non-empty value
762
+ * under this scope key; `planIntegrations` then plans the step again and
763
+ * `provisionPlatformIntegrations` mints the new pair, clearing the key (to
764
+ * `''`) as soon as the mint is recorded, so one request is one rotation.
765
+ */
766
+ export declare const PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY = "integrations_management_key_rotation_requested";