@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.
package/dist/org.d.ts CHANGED
@@ -333,8 +333,10 @@ export declare const AI_ENTRY_ENV_NAMES: readonly ["AI_URL", "AI_ORGANISATION_ID
333
333
  * env and one collection per name in `collections`. A name is uppercase
334
334
  * `[A-Z0-9_]`; its id is delivered as `FILES_COLLECTION_<NAME>_ID`.
335
335
  * `permissions` is a subset of {@link FILES_ENTRY_PERMISSIONS}, which is also
336
- * the default. `shareHost` asks for `share.<domain>`; it is reported blocked
337
- * until the platform files host serves that name.
336
+ * the default. `shareHost` asks for `share.<domain>`: foundry routes and
337
+ * publishes that name through the org's tunnel, delivers `FILES_SHARE_ORIGIN`
338
+ * and passes it to files. The platform files runner must still serve the
339
+ * host (a Coolify domain and `FILES_PUBLIC_HOSTS`); the plan warns of that.
338
340
  */
339
341
  export interface EntryFiles {
340
342
  collections: string[];
@@ -356,8 +358,18 @@ export interface HostingEntryUses {
356
358
  * looks like a secret. `{ from }` names a value the plan already mints.
357
359
  */
358
360
  export type EnvValue = string | {
359
- from: AppEnvSource;
360
- };
361
+ from: Exclude<AppEnvSource, 'sharedSecret'>;
362
+ } | EnvSharedSecret;
363
+ /**
364
+ * A secret foundry generates once and holds under an org-scoped ref. Every
365
+ * entry of the same org that names the same `name` gets the same value; an
366
+ * entry of another org never does. It is written to the entry's env only, and
367
+ * never reaches a plan, a log or an error.
368
+ */
369
+ export interface EnvSharedSecret {
370
+ from: 'sharedSecret';
371
+ name: string;
372
+ }
361
373
  /** The org's `app` entry: today's one app. */
362
374
  export declare function appEntry(hosting: HostingConfig): HostingEntry | undefined;
363
375
  /**
@@ -367,8 +379,19 @@ export declare function appEntry(hosting: HostingConfig): HostingEntry | undefin
367
379
  * sends from. Each is read from `StepExecutionContext.recordedIds()` in
368
380
  * `executor.ts`'s `pushAppEnv`, never minted again there.
369
381
  */
370
- export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain';
382
+ export type AppEnvSource = 'filesOrganisationId' | 'aiOrganisationId' | 'mailDomain' | 'sharedSecret' | 'platformMailKey';
383
+ /**
384
+ * Two sources are secrets. `sharedSecret` is {@link EnvSharedSecret}.
385
+ * `platformMailKey` is the platform's held Lettermint key, handed over as the
386
+ * value of the declared env name (the platform's own apps call it
387
+ * `LETTERMINT_API_TOKEN`). Any hosting entry of any customer org may name it
388
+ * (William, 2026-10-04): mail is Lettermint, and the key is the platform's.
389
+ */
371
390
  export declare const APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
391
+ /** The sources whose value is a secret: resolved at apply time, never recorded. */
392
+ export declare const SECRET_APP_ENV_SOURCES: ReadonlySet<AppEnvSource>;
393
+ /** The held-key ref of an org's shared secret. */
394
+ export declare const sharedSecretRef: (orgId: string, name: string) => string;
372
395
  /**
373
396
  * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
374
397
  * shape as `validateBuyerIdentity` below.
package/dist/org.js CHANGED
@@ -39,11 +39,27 @@ export const filesCollectionEnvName = (name) => `FILES_COLLECTION_${name}_ID`;
39
39
  export function appEntry(hosting) {
40
40
  return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
41
41
  }
42
+ /**
43
+ * Two sources are secrets. `sharedSecret` is {@link EnvSharedSecret}.
44
+ * `platformMailKey` is the platform's held Lettermint key, handed over as the
45
+ * value of the declared env name (the platform's own apps call it
46
+ * `LETTERMINT_API_TOKEN`). Any hosting entry of any customer org may name it
47
+ * (William, 2026-10-04): mail is Lettermint, and the key is the platform's.
48
+ */
42
49
  export const APP_ENV_SOURCES = new Set([
43
50
  'filesOrganisationId',
44
51
  'aiOrganisationId',
45
52
  'mailDomain',
53
+ 'sharedSecret',
54
+ 'platformMailKey',
46
55
  ]);
56
+ /** The sources whose value is a secret: resolved at apply time, never recorded. */
57
+ export const SECRET_APP_ENV_SOURCES = new Set([
58
+ 'sharedSecret',
59
+ 'platformMailKey',
60
+ ]);
61
+ /** The held-key ref of an org's shared secret. */
62
+ export const sharedSecretRef = (orgId, name) => `foundry:shared-secret:${orgId}:${name}`;
47
63
  const ISSUER_APP_KINDS = new Set(['web', 'native', 'api']);
48
64
  const HOSTING_BUILD_PACKS = new Set([
49
65
  'dockerfile',
@@ -267,6 +283,9 @@ function validateHostingEntry(entry, enabled) {
267
283
  if (!APP_ENV_SOURCES.has(value?.from)) {
268
284
  return `${at} env '${name}' has an unknown source '${value?.from}'`;
269
285
  }
286
+ if (value.from === 'sharedSecret' && !ENTRY_KEY.test(value.name ?? '')) {
287
+ return `${at} env '${name}' sharedSecret needs a name (lowercase letters, digits and hyphens)`;
288
+ }
270
289
  }
271
290
  else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
272
291
  return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * #252: the one shared helper every platform step calls between deploying a
3
+ * new version and recording it as the applied ref.
4
+ *
5
+ * Before this, a step that records `platform:*`'s applied hostname/gitRef
6
+ * called `CoolifyPort.deploy` -- fire-and-forget by design (see its own doc
7
+ * comment) -- and recorded the new ref right after, with nothing in between
8
+ * proving the new version had actually landed. A failed deploy leaves the
9
+ * previous container running and healthy (`@wtfalch/providers`'
10
+ * `CoolifyDeploymentRecord` doc comment: "`status` reports on the container
11
+ * that is *running*, and a failed build leaves the previous one running and
12
+ * healthy"), so the old container's healthy status was being read as the
13
+ * new deployment's -- exactly what `CLAUDE.md`'s platform-step rule 5 ("no
14
+ * waiting forever... an old healthy status is never read as the new one")
15
+ * exists to forbid.
16
+ *
17
+ * {@link waitForNewDeployment} closes that gap in two steps, always in this
18
+ * order:
19
+ *
20
+ * 1. `coolify.deployAndWait` -- blocks until *Coolify's own deployment
21
+ * record* (not `getApplication`'s live container status) reaches a
22
+ * terminal state. Anything but `'finished'` throws immediately: a build
23
+ * or deploy failure is a hard stop no health check below could ever
24
+ * paper over, and it is the step that actually catches "the old
25
+ * container stays healthy while the deployment fails" -- the container's
26
+ * own status never moves in that scenario, so only the deployment record
27
+ * itself tells the two apart.
28
+ * 2. The health check named by `health.kind`, decided per call site and
29
+ * stated there (#252's own decision):
30
+ * - `'version'` -- the service's health response carries a version
31
+ * string this service actually exposes; polled until it matches
32
+ * `health.expected` exactly. Unambiguous proof it is the *new*
33
+ * version specifically, but only usable where the service has one.
34
+ * - `'ready'` -- the health url answers 2xx. Works for every service
35
+ * with a url this helper's caller can actually reach, including ones
36
+ * with no version field (the fallback, not the preferred mode), and
37
+ * the old-container loophole this mode alone cannot close ("can be
38
+ * fooled by the old container staying up") is already closed by step
39
+ * 1 having confirmed Coolify's own deployment record finished first.
40
+ * - `'deployment-only'` -- no health check at all; step 1 is the whole
41
+ * answer. For a step whose app has nothing public wired (no Coolify
42
+ * `domains`, tunnel, DNS record -- `platform:ai`/`platform:files`'
43
+ * own doc comments say so explicitly) there is no url this helper's
44
+ * caller, running outside that app's own Docker network, could ever
45
+ * reach, so inventing one to poll would just be a slower, flakier way
46
+ * of saying "I called deployAndWait". A step that can only use this
47
+ * mode is not unverified the way `'ready'` is *within* `'ready'`'s own
48
+ * guarantee -- it is strictly weaker than the other two (a deployment
49
+ * that "finished" with a crash-looping container is read as success),
50
+ * but it is the one honest answer this helper can give it, and it is
51
+ * still what catches #252's named failure (the old container staying
52
+ * healthy while the deployment record itself fails), since that
53
+ * record is exactly what step 1 reads.
54
+ *
55
+ * Throws on any failure -- the deployment itself, or the health check
56
+ * timing out -- naming `appliedRefDescription` (what is still live, for
57
+ * whoever reads the error) so the caller's own "record applied ref" line is
58
+ * never reached. Rule 1 from `CLAUDE.md` applies here too: never call this
59
+ * from inside a retry loop that could mint a one-time secret again, since a
60
+ * failed wait is meant to be repaired by re-running the whole step, not
61
+ * silently retried from inside it.
62
+ */
63
+ export interface DeployAndWaitPort {
64
+ deployAndWait(uuid: string, options?: {
65
+ force?: boolean;
66
+ attempts?: number;
67
+ intervalMs?: number;
68
+ }): Promise<{
69
+ deploymentUuid: string;
70
+ status: string;
71
+ }>;
72
+ }
73
+ /**
74
+ * How a call site proves the new version is the one that answers (or that
75
+ * there is nothing for it to reach at all). See this module's own doc
76
+ * comment for why there are exactly three and no "pick one per call"
77
+ * default.
78
+ */
79
+ export type DeployHealthCheck = {
80
+ readonly kind: 'version';
81
+ readonly url: string;
82
+ /** Exact match against the health body's version field. */
83
+ readonly expected: string;
84
+ /** Field name in the parsed JSON body (default `'version'`). */
85
+ readonly field?: string;
86
+ } | {
87
+ readonly kind: 'ready';
88
+ readonly url: string;
89
+ } | {
90
+ /** No reachable url; `coolify.deployAndWait` alone is the check. See this module's doc comment. */
91
+ readonly kind: 'deployment-only';
92
+ };
93
+ export interface DeployHealthRuntime {
94
+ fetch?: typeof fetch;
95
+ sleep?: (ms: number) => Promise<void>;
96
+ now?: () => number;
97
+ /** Passed through to `deployAndWait` (default: its own, 90 * 10s = 15 minutes). */
98
+ deployAttempts?: number;
99
+ deployIntervalMs?: number;
100
+ /** `deployAndWait`'s own `force` (default `true` -- see this module's doc comment). */
101
+ deployForce?: boolean;
102
+ /** How long to wait for the health check to confirm the new version (default 5 minutes). */
103
+ readyTimeoutMs?: number;
104
+ /** Pause between health polls (default 5 seconds). */
105
+ readyIntervalMs?: number;
106
+ }
107
+ /**
108
+ * Phase 1 alone: deploys `appId` and blocks until Coolify's own deployment
109
+ * record reaches a terminal state, throwing unless it is `'finished'`. Split
110
+ * out of {@link waitForNewDeployment} for a step whose health url only
111
+ * resolves once *later* work in the same step (archon's/mail's own tunnel
112
+ * and DNS wiring) has run -- that work must happen between this call and
113
+ * {@link waitForHealthyVersion}, so the two cannot be one call there. A step
114
+ * with no such ordering constraint calls {@link waitForNewDeployment}
115
+ * instead, never this plus {@link waitForHealthyVersion} by hand.
116
+ */
117
+ export declare function waitForDeploymentFinished(coolify: DeployAndWaitPort, appId: string, runtime: DeployHealthRuntime, label: string, appliedRefDescription: string): Promise<void>;
118
+ /**
119
+ * Phase 2 alone: the health check named by `health.kind`, with no deploy and
120
+ * no Coolify polling -- see {@link waitForDeploymentFinished}'s own doc
121
+ * comment for when a step calls this separately instead of
122
+ * {@link waitForNewDeployment}. A `'deployment-only'` check is a no-op here
123
+ * by design: phase 1 was already the whole answer for it.
124
+ */
125
+ export declare function waitForHealthyVersion(appId: string, health: DeployHealthCheck, runtime: DeployHealthRuntime, sleep: (ms: number) => Promise<void>, label: string, appliedRefDescription: string): Promise<void>;
126
+ /**
127
+ * Deploys `appId` and does not return until Coolify's own deployment record
128
+ * says it finished *and* `health` confirms the new version is answering --
129
+ * {@link waitForDeploymentFinished} then {@link waitForHealthyVersion}, in
130
+ * that order, for a step with no reason to run anything between them. See
131
+ * this module's own doc comment for why each half exists; `label` names the
132
+ * step in every error (`platform:mail`, …) and `appliedRefDescription` names
133
+ * what is still live so a caller's error is actionable without the helper
134
+ * itself knowing any step's scope keys.
135
+ */
136
+ export declare function waitForNewDeployment(coolify: DeployAndWaitPort, appId: string, health: DeployHealthCheck, runtime: DeployHealthRuntime, sleep: (ms: number) => Promise<void>, label: string, appliedRefDescription: string): Promise<void>;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * #252: the one shared helper every platform step calls between deploying a
3
+ * new version and recording it as the applied ref.
4
+ *
5
+ * Before this, a step that records `platform:*`'s applied hostname/gitRef
6
+ * called `CoolifyPort.deploy` -- fire-and-forget by design (see its own doc
7
+ * comment) -- and recorded the new ref right after, with nothing in between
8
+ * proving the new version had actually landed. A failed deploy leaves the
9
+ * previous container running and healthy (`@wtfalch/providers`'
10
+ * `CoolifyDeploymentRecord` doc comment: "`status` reports on the container
11
+ * that is *running*, and a failed build leaves the previous one running and
12
+ * healthy"), so the old container's healthy status was being read as the
13
+ * new deployment's -- exactly what `CLAUDE.md`'s platform-step rule 5 ("no
14
+ * waiting forever... an old healthy status is never read as the new one")
15
+ * exists to forbid.
16
+ *
17
+ * {@link waitForNewDeployment} closes that gap in two steps, always in this
18
+ * order:
19
+ *
20
+ * 1. `coolify.deployAndWait` -- blocks until *Coolify's own deployment
21
+ * record* (not `getApplication`'s live container status) reaches a
22
+ * terminal state. Anything but `'finished'` throws immediately: a build
23
+ * or deploy failure is a hard stop no health check below could ever
24
+ * paper over, and it is the step that actually catches "the old
25
+ * container stays healthy while the deployment fails" -- the container's
26
+ * own status never moves in that scenario, so only the deployment record
27
+ * itself tells the two apart.
28
+ * 2. The health check named by `health.kind`, decided per call site and
29
+ * stated there (#252's own decision):
30
+ * - `'version'` -- the service's health response carries a version
31
+ * string this service actually exposes; polled until it matches
32
+ * `health.expected` exactly. Unambiguous proof it is the *new*
33
+ * version specifically, but only usable where the service has one.
34
+ * - `'ready'` -- the health url answers 2xx. Works for every service
35
+ * with a url this helper's caller can actually reach, including ones
36
+ * with no version field (the fallback, not the preferred mode), and
37
+ * the old-container loophole this mode alone cannot close ("can be
38
+ * fooled by the old container staying up") is already closed by step
39
+ * 1 having confirmed Coolify's own deployment record finished first.
40
+ * - `'deployment-only'` -- no health check at all; step 1 is the whole
41
+ * answer. For a step whose app has nothing public wired (no Coolify
42
+ * `domains`, tunnel, DNS record -- `platform:ai`/`platform:files`'
43
+ * own doc comments say so explicitly) there is no url this helper's
44
+ * caller, running outside that app's own Docker network, could ever
45
+ * reach, so inventing one to poll would just be a slower, flakier way
46
+ * of saying "I called deployAndWait". A step that can only use this
47
+ * mode is not unverified the way `'ready'` is *within* `'ready'`'s own
48
+ * guarantee -- it is strictly weaker than the other two (a deployment
49
+ * that "finished" with a crash-looping container is read as success),
50
+ * but it is the one honest answer this helper can give it, and it is
51
+ * still what catches #252's named failure (the old container staying
52
+ * healthy while the deployment record itself fails), since that
53
+ * record is exactly what step 1 reads.
54
+ *
55
+ * Throws on any failure -- the deployment itself, or the health check
56
+ * timing out -- naming `appliedRefDescription` (what is still live, for
57
+ * whoever reads the error) so the caller's own "record applied ref" line is
58
+ * never reached. Rule 1 from `CLAUDE.md` applies here too: never call this
59
+ * from inside a retry loop that could mint a one-time secret again, since a
60
+ * failed wait is meant to be repaired by re-running the whole step, not
61
+ * silently retried from inside it.
62
+ */
63
+ const DEFAULT_READY_TIMEOUT_MS = 300_000;
64
+ const DEFAULT_READY_INTERVAL_MS = 5_000;
65
+ /** The one terminal `CoolifyDeploymentStatus` that means the new version landed. */
66
+ const DEPLOYMENT_SUCCEEDED = 'finished';
67
+ /**
68
+ * Phase 1 alone: deploys `appId` and blocks until Coolify's own deployment
69
+ * record reaches a terminal state, throwing unless it is `'finished'`. Split
70
+ * out of {@link waitForNewDeployment} for a step whose health url only
71
+ * resolves once *later* work in the same step (archon's/mail's own tunnel
72
+ * and DNS wiring) has run -- that work must happen between this call and
73
+ * {@link waitForHealthyVersion}, so the two cannot be one call there. A step
74
+ * with no such ordering constraint calls {@link waitForNewDeployment}
75
+ * instead, never this plus {@link waitForHealthyVersion} by hand.
76
+ */
77
+ export async function waitForDeploymentFinished(coolify, appId, runtime, label, appliedRefDescription) {
78
+ let result;
79
+ try {
80
+ result = await coolify.deployAndWait(appId, {
81
+ force: runtime.deployForce ?? true,
82
+ attempts: runtime.deployAttempts,
83
+ intervalMs: runtime.deployIntervalMs,
84
+ });
85
+ }
86
+ catch (error) {
87
+ const reason = error instanceof Error ? error.message : String(error);
88
+ throw new Error(`${label}: the deployment of ${appId} did not finish (${reason}); ${appliedRefDescription}. Apply again`);
89
+ }
90
+ const { status, deploymentUuid } = result;
91
+ if (status !== DEPLOYMENT_SUCCEEDED) {
92
+ throw new Error(`${label}: the deployment (${deploymentUuid}) of ${appId} did not finish (status: ${status}); ${appliedRefDescription}. Apply again`);
93
+ }
94
+ }
95
+ /**
96
+ * Phase 2 alone: the health check named by `health.kind`, with no deploy and
97
+ * no Coolify polling -- see {@link waitForDeploymentFinished}'s own doc
98
+ * comment for when a step calls this separately instead of
99
+ * {@link waitForNewDeployment}. A `'deployment-only'` check is a no-op here
100
+ * by design: phase 1 was already the whole answer for it.
101
+ */
102
+ export async function waitForHealthyVersion(appId, health, runtime, sleep, label, appliedRefDescription) {
103
+ if (health.kind === 'deployment-only')
104
+ return;
105
+ const fetchImpl = runtime.fetch ?? globalThis.fetch;
106
+ const now = runtime.now ?? Date.now;
107
+ const timeout = runtime.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
108
+ const interval = runtime.readyIntervalMs ?? DEFAULT_READY_INTERVAL_MS;
109
+ const deadline = now() + timeout;
110
+ for (;;) {
111
+ if (await matchesHealth(fetchImpl, health))
112
+ return;
113
+ if (now() >= deadline) {
114
+ const detail = health.kind === 'version'
115
+ ? `never answered version "${health.expected}" at ${health.url}`
116
+ : `never answered 2xx at ${health.url}`;
117
+ throw new Error(`${label}: ${appId} ${detail} within ${Math.round(timeout / 1000)}s; ${appliedRefDescription}. Apply again`);
118
+ }
119
+ await sleep(interval);
120
+ }
121
+ }
122
+ /**
123
+ * Deploys `appId` and does not return until Coolify's own deployment record
124
+ * says it finished *and* `health` confirms the new version is answering --
125
+ * {@link waitForDeploymentFinished} then {@link waitForHealthyVersion}, in
126
+ * that order, for a step with no reason to run anything between them. See
127
+ * this module's own doc comment for why each half exists; `label` names the
128
+ * step in every error (`platform:mail`, …) and `appliedRefDescription` names
129
+ * what is still live so a caller's error is actionable without the helper
130
+ * itself knowing any step's scope keys.
131
+ */
132
+ export async function waitForNewDeployment(coolify, appId, health, runtime, sleep, label, appliedRefDescription) {
133
+ await waitForDeploymentFinished(coolify, appId, runtime, label, appliedRefDescription);
134
+ await waitForHealthyVersion(appId, health, runtime, sleep, label, appliedRefDescription);
135
+ }
136
+ async function matchesHealth(fetchImpl, health) {
137
+ try {
138
+ const response = await fetchImpl(health.url);
139
+ if (!response.ok)
140
+ return false;
141
+ if (health.kind === 'ready')
142
+ return true;
143
+ const body = await response.json().catch(() => undefined);
144
+ const value = body && typeof body === 'object'
145
+ ? body[health.field ?? 'version']
146
+ : undefined;
147
+ return value === health.expected;
148
+ }
149
+ catch {
150
+ // Not up yet: DNS, the tunnel, the container, or a transient network error.
151
+ return false;
152
+ }
153
+ }
package/dist/plan/diff.js CHANGED
@@ -10,6 +10,7 @@ import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHo
10
10
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
11
11
  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, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
12
12
  import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
13
+ import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
13
14
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
14
15
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
15
16
  export function toPlan(detail) {
@@ -82,7 +83,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
82
83
  const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
83
84
  if (issuer.step)
84
85
  plannedSteps.push(issuer.step);
85
- plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady));
86
+ plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, 'error' in agoraHost ? null : agoraHost.issuer));
86
87
  // #267: after the ZITADEL org step, before any app's own issuer
87
88
  // application. Apps created later in this run are not in the binding yet;
88
89
  // the next run replans this step and repoints them.
@@ -188,7 +189,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
188
189
  if (database)
189
190
  plannedSteps.push(database);
190
191
  const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
191
- const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer);
192
+ const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, platformReadiness);
192
193
  plannedSteps.push(...app.steps);
193
194
  // After the apps: the broker entry's key is minted into each app's env.
194
195
  const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false);
@@ -433,7 +434,7 @@ function planIssuer(org, byKey, claimed, authReady) {
433
434
  * nothing here puts it anywhere beyond what audit already records for the
434
435
  * org.
435
436
  */
436
- function planOwners(org, recordedIds, authReady) {
437
+ function planOwners(org, recordedIds, authReady, authIssuer) {
437
438
  // #177: nothing to invite into yet -- `planIssuer` already reports the
438
439
  // platform's sign-in as blocked, so these steps stay silent rather than
439
440
  // repeating it.
@@ -455,6 +456,8 @@ function planOwners(org, recordedIds, authReady) {
455
456
  email: owner.email,
456
457
  givenName: owner.givenName,
457
458
  familyName: owner.familyName,
459
+ // The platform's login app, where the invite link must land (it serves `/verify`).
460
+ ...(authIssuer ? { authIssuer } : {}),
458
461
  },
459
462
  },
460
463
  step: {
@@ -1004,6 +1007,16 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
1004
1007
  * own files host (#171) is off or not yet applied, so there is nothing here
1005
1008
  * for `FilesOrganisationPort.provisionOrganisation` to reach.
1006
1009
  *
1010
+ * F6b: `shareHost` is `share.<domain>` when any entry declares
1011
+ * `uses.files.shareHost`, else `null`. It is passed to the provision call
1012
+ * every time, so files always knows the name an org wants. The route and
1013
+ * DNS come from `tunnelIngressHostnames`; `planFilesKeys` warns that the
1014
+ * platform files runner must serve the host too. Computed once, at the only point `provisionOrganisation`
1015
+ * is ever planned (nothing to diff once the organisation is live, same as
1016
+ * `ceilingBytes`/`ceilingMicros` above): a `shareHost` declared after the
1017
+ * organisation already exists needs its own follow-up, matching every other
1018
+ * field on this singleton step.
1019
+ *
1007
1020
  * #273: `desired.agoraOrigin` is every org's own Agora origin (#164's
1008
1021
  * `agoraHostnameFor`, always on, never blocked on anything this step
1009
1022
  * doesn't already wait for) -- `executor.ts`'s `filesOrganisation` adds it
@@ -1026,6 +1039,9 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
1026
1039
  blocked: `${org.slug}'s Files organisation is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
1027
1040
  };
1028
1041
  }
1042
+ const shareHost = org.domain && org.hosting.entries.some((e) => e.uses?.files?.shareHost)
1043
+ ? `share.${org.domain}`
1044
+ : null;
1029
1045
  return {
1030
1046
  step: {
1031
1047
  action: {
@@ -1036,6 +1052,7 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
1036
1052
  provision: true,
1037
1053
  ceilingBytes: null,
1038
1054
  ceilingMicros: null,
1055
+ shareHost,
1039
1056
  agoraOrigin: `https://${agoraHostnameFor(org)}`,
1040
1057
  },
1041
1058
  },
@@ -1636,7 +1653,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
1636
1653
  * resource's `enabled` field -- a live resource nothing has actually
1637
1654
  * checked yet is assumed fine rather than replanned every time.
1638
1655
  */
1639
- function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
1656
+ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness) {
1640
1657
  // #243: an `appEnv` source with no recorded value and no step planned to
1641
1658
  // record one this run has nothing to push, now or after this apply.
1642
1659
  const sourceStep = {
@@ -1651,9 +1668,12 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
1651
1668
  // other entry is named in them.
1652
1669
  const label = entryKey === APP_ENTRY_KEY ? 'app' : `${entryKey} app`;
1653
1670
  const blockedKey = (rest) => entryKey === APP_ENTRY_KEY ? `coolify:app:${rest}` : `coolify:app:${entryKey}:${rest}`;
1654
- const { values: wantedEnv, unresolved } = entry
1655
- ? resolveAppEnv(org, entry, recordedIds)
1656
- : { values: {}, unresolved: [] };
1671
+ const { marker: wantedEnv, unresolved, unresolvedSecrets, } = entry
1672
+ ? resolveAppEnv(org, entry, recordedIds, {
1673
+ platformMailKey: readiness.mailKeyHeld === true,
1674
+ sharedSecret: readiness.canHoldSecrets === true,
1675
+ })
1676
+ : { marker: {}, unresolved: [], unresolvedSecrets: [] };
1657
1677
  const stepKey = appStepKey(org, entryKey);
1658
1678
  const blocked = entry
1659
1679
  ? unresolved
@@ -1663,6 +1683,14 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
1663
1683
  description: `${org.slug}'s ${label} env ${name} is blocked, not planned: its source ${source} has no recorded value yet`,
1664
1684
  key: blockedKey(`env:${name}`),
1665
1685
  }))
1686
+ .concat(
1687
+ // Never a value: the ref of the missing key, or what the host lacks.
1688
+ unresolvedSecrets.map(([name, source]) => ({
1689
+ description: `${org.slug}'s ${label} env ${name} is blocked, not planned: ${source === 'platformMailKey'
1690
+ ? `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`
1691
+ : `its source ${source} needs a held-key store that can put, and the host has none`}`,
1692
+ key: blockedKey(`env:${name}`),
1693
+ })))
1666
1694
  : [];
1667
1695
  const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
1668
1696
  if (toggled)
@@ -1825,7 +1853,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
1825
1853
  * {@link planApp} once per hosting entry. A key with no declared entry still
1826
1854
  * runs for `app`, so an org that turns hosting off parks the app it has live.
1827
1855
  */
1828
- function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
1856
+ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness) {
1829
1857
  const steps = [];
1830
1858
  const blocked = [];
1831
1859
  // An entry removed from the list while its app is recorded is parked like a
@@ -1846,8 +1874,21 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
1846
1874
  if (!resource || (typeof status === 'string' && /^(exited|stopped)/.test(status)))
1847
1875
  continue;
1848
1876
  }
1849
- const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer);
1877
+ const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer, readiness);
1850
1878
  // The executor pushes the issuer the plan read, the way Agora gets its host values.
1879
+ // The same availability the plan's marker used: the executor records its
1880
+ // marker from this, never from its own port, so the two cannot disagree.
1881
+ const usesSecrets = org.hosting.entries.some((e) => e.key === key &&
1882
+ Object.values(e.env).some((v) => typeof v !== 'string' && (v.from === 'sharedSecret' || v.from === 'platformMailKey')));
1883
+ if (usesSecrets && app.step && app.step.action.desired.enabled !== false) {
1884
+ app.step.action.desired = {
1885
+ ...app.step.action.desired,
1886
+ secretSources: {
1887
+ platformMailKey: readiness.mailKeyHeld === true,
1888
+ sharedSecret: readiness.canHoldSecrets === true,
1889
+ },
1890
+ };
1891
+ }
1851
1892
  if (app.step && app.step.action.desired.enabled !== false) {
1852
1893
  const signsIn = org.hosting.entries.some((e) => e.key === key && e.uses?.auth);
1853
1894
  if (signsIn)
@@ -56,8 +56,10 @@ export interface EntryFilesWant {
56
56
  permissions: string[];
57
57
  collections: string[];
58
58
  filesUrl: string;
59
+ /** `https://share.<domain>`, set only when the entry declares `shareHost`. */
60
+ shareOrigin?: string;
59
61
  }
60
- export declare function wantedEntryFiles(entry: HostingEntry, filesUrl: string): EntryFilesWant;
62
+ export declare function wantedEntryFiles(entry: HostingEntry, filesUrl: string, shareHostname?: string | null): EntryFilesWant;
61
63
  export type EntryFilesMode = 'mint' | 'renew' | 'settings' | 'finish' | 'retire';
62
64
  type Blocked = Array<{
63
65
  description: string;
@@ -1,4 +1,5 @@
1
1
  import { FILES_ENTRY_PERMISSIONS } from '../org.js';
2
+ import { filesShareHostname } from './hostnames.js';
2
3
  import { appStepKey } from './platform-diff.js';
3
4
  /**
4
5
  * F6b: the org's own files key, minted below the files organisation's
@@ -67,12 +68,13 @@ export function filesOwnerUserId(org, recordedIds) {
67
68
  const first = org.owners[0];
68
69
  return first ? recordedIds[`zitadel:owner:${org.id}:${first.email.toLowerCase()}`] : undefined;
69
70
  }
70
- export function wantedEntryFiles(entry, filesUrl) {
71
+ export function wantedEntryFiles(entry, filesUrl, shareHostname = null) {
71
72
  const files = entry.uses?.files;
72
73
  return {
73
74
  permissions: [...(files?.permissions ?? FILES_ENTRY_PERMISSIONS)],
74
75
  collections: [...(files?.collections ?? [])],
75
76
  filesUrl,
77
+ ...(files?.shareHost && shareHostname ? { shareOrigin: `https://${shareHostname}` } : {}),
76
78
  };
77
79
  }
78
80
  /**
@@ -157,14 +159,9 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
157
159
  }
158
160
  if (entries.length === 0 || !readiness.filesReady)
159
161
  return { steps, blocked };
160
- for (const entry of entries) {
161
- if (entry.uses?.files?.shareHost) {
162
- blocked.push({
163
- description: `${org.slug}'s ${entry.key} share host is blocked: the platform's files host has no route for share.<domain> yet, so FILES_SHARE_ORIGIN is not delivered`,
164
- key: `files:share:${org.id}:${entry.key}`,
165
- });
166
- }
167
- }
162
+ // The route and DNS for share.<domain> are planned with the tunnel; the org
163
+ // apply then gives the platform files runner the host (`FoundryPlanApply.apply`).
164
+ const shareHostname = filesShareHostname(org);
168
165
  // The org's own key: minted, or renewed when it is short or its expiry is unknown.
169
166
  const rootBlock = (why) => blocked.push({
170
167
  description: `${org.slug}'s own files key is blocked: ${why}`,
@@ -244,7 +241,7 @@ export function planFilesKeys(org, recordedIds, plannedSteps, readiness, now) {
244
241
  block("the entry's app is not recorded yet");
245
242
  continue;
246
243
  }
247
- const want = wantedEntryFiles(entry, readiness.filesUrl);
244
+ const want = wantedEntryFiles(entry, readiness.filesUrl, shareHostname);
248
245
  const applied = recordedIds[entryFilesAppliedScopeKey(entry.key)];
249
246
  const wantJson = JSON.stringify(want);
250
247
  let mode = null;