@wtfalch/foundry 0.16.7 → 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.
@@ -143,6 +143,19 @@ function mailOrgFor(org, usesDomain, mode) {
143
143
  const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
144
144
  /** The one Access policy foundry puts on its own Access app (#84). */
145
145
  export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
146
+ /**
147
+ * The Access policy name for each `hosting.access` rule kind (#246),
148
+ * `cloudflareAccess`'s way of telling its own policy apart from a foreign
149
+ * one on the same app -- the same role {@link ACCESS_POLICY_NAME} plays for
150
+ * the `app` entry's own email gate, which keeps that exact name so a
151
+ * re-run against an org applied before #246 still recognises its own
152
+ * policy.
153
+ */
154
+ const ACCESS_RULE_POLICY_NAMES = {
155
+ gate: ACCESS_POLICY_NAME,
156
+ bypass: 'foundry: bypass',
157
+ 'service-token': 'foundry: service token',
158
+ };
146
159
  /**
147
160
  * The sign-in broker's own route mount (`@wtfalch/auth`'s `NextAuth`,
148
161
  * `dist/next.d.ts`'s `handlers` doc comment: "`export const { GET, POST } =
@@ -635,7 +648,7 @@ export class FoundryStepExecutor {
635
648
  case 'cloudflare:zone':
636
649
  return this.cloudflareZone(action, org);
637
650
  case 'cloudflare:access':
638
- return this.cloudflareAccess(org);
651
+ return this.cloudflareAccess(action, org);
639
652
  case 'cloudflare:ratelimit':
640
653
  return this.cloudflareRateLimit(org);
641
654
  case 'files:organisation':
@@ -677,40 +690,91 @@ export class FoundryStepExecutor {
677
690
  }
678
691
  }
679
692
  /**
680
- * `plan/diff.ts`'s `planAccess` (#84): puts the org's app hostname behind a
681
- * Cloudflare Access app that lets only the `app` entry's `accessEmails` through.
682
- * foundry names its Access app `foundry:<orgId>` and reuses only an app with
683
- * that name, so a re-run after a crash does not duplicate it. It refuses,
684
- * rather than adopt, when another Access app already holds the hostname:
685
- * destroy would later delete it. It also refuses when its own app carries
693
+ * `plan/diff.ts`'s `planAccess` (#84, widened by #246): puts a hostname
694
+ * behind a Cloudflare Access app. `action.key` tells the `app` entry's own
695
+ * gate (`accessEmails`, email-only) apart from a `hosting.access` rule on
696
+ * any other hostname -- `appStepKey` builds both, keyed by the entry's key
697
+ * or by the rule's hostname respectively, and the two never collide
698
+ * because an entry key never holds a `.` while a rule's hostname always
699
+ * does (`org.ts`'s `validateAccessRule`). The app-entry gate keeps the
700
+ * Access app name `foundry:<orgId>` it has always used, so a re-run
701
+ * against an org applied before #246 still finds the app it already
702
+ * created; a rule's own app is named `foundry:<orgId>:<hostname>` instead,
703
+ * since an org may declare more than one. Either way foundry reuses only
704
+ * an app with its own name (so a re-run after a crash does not duplicate
705
+ * it), refuses to adopt a hostname another Access app already holds
706
+ * (destroy would later delete it), and refuses when its own app carries
686
707
  * any policy but foundry's, since that policy could let others in.
687
708
  */
688
- async cloudflareAccess(org) {
709
+ async cloudflareAccess(action, org) {
689
710
  // Only the `app` entry has a public name (`planAccess`); per-entry
690
711
  // hostnames are issue 309.
691
712
  const entry = appEntry(org.hosting);
692
713
  if (!entry)
693
714
  throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
694
- const domain = appHostname(org);
695
- const name = `foundry:${org.id}`;
715
+ if (action.key === appStepKey(org, entry.key)) {
716
+ return this.cloudflareAccessApp(org, {
717
+ appName: `foundry:${org.id}`,
718
+ domain: appHostname(org),
719
+ policyName: ACCESS_POLICY_NAME,
720
+ policy: {
721
+ decision: 'allow',
722
+ include: entry.accessEmails.map((email) => ({ email: { email } })),
723
+ },
724
+ });
725
+ }
726
+ const rule = (org.hosting.access ?? []).find((r) => appStepKey(org, r.hostname) === action.key);
727
+ if (!rule) {
728
+ throw new Error(`cloudflare:access for ${org.slug}: no hosting.access rule matches step key '${action.key}'`);
729
+ }
730
+ return this.cloudflareAccessApp(org, {
731
+ appName: `foundry:${org.id}:${rule.hostname}`,
732
+ domain: rule.hostname,
733
+ policyName: ACCESS_RULE_POLICY_NAMES[rule.kind],
734
+ policy: await this.accessPolicyFor(org, rule),
735
+ });
736
+ }
737
+ /** The Cloudflare Access policy body for one `hosting.access` rule, by kind (#246). */
738
+ async accessPolicyFor(org, rule) {
739
+ switch (rule.kind) {
740
+ case 'gate':
741
+ return {
742
+ decision: 'allow',
743
+ include: rule.emails.map((email) => ({ email: { email } })),
744
+ };
745
+ case 'bypass':
746
+ // A bypass policy still needs an `include` rule to match against;
747
+ // `everyone` is Cloudflare's own shape for "match every visitor".
748
+ return { decision: 'bypass', include: [{ everyone: {} }] };
749
+ case 'service-token': {
750
+ const token = await this.ports.cloudflare.findServiceTokenByName(rule.serviceTokenName);
751
+ if (!token) {
752
+ throw new Error(`cloudflare:access for ${org.slug}: no Cloudflare Access service token named '${rule.serviceTokenName}'`);
753
+ }
754
+ return { decision: 'non_identity', include: [{ service_token: { token_id: token.id } }] };
755
+ }
756
+ }
757
+ }
758
+ /**
759
+ * The create-or-adopt-refusal logic `cloudflareAccess` shares between the
760
+ * `app` entry's own gate and every `hosting.access` rule (#84, #246).
761
+ */
762
+ async cloudflareAccessApp(org, opts) {
763
+ const { appName, domain, policyName, policy } = opts;
696
764
  const cf = this.ports.cloudflare;
697
765
  const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
698
- const foreign = onDomain.find((a) => a.name !== name);
766
+ const foreign = onDomain.find((a) => a.name !== appName);
699
767
  if (foreign) {
700
768
  throw new Error(`cloudflare:access for ${org.slug}: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
701
769
  }
702
- const app = onDomain[0] ?? (await cf.createAccessApp({ name, domain, type: 'self_hosted' }));
770
+ const app = onDomain[0] ?? (await cf.createAccessApp({ name: appName, domain, type: 'self_hosted' }));
703
771
  const policies = await cf.listAccessPolicies(app.id);
704
- const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME);
772
+ const other = policies.find((p) => p.name !== policyName);
705
773
  if (other) {
706
774
  throw new Error(`cloudflare:access for ${org.slug}: Access app ${app.id} has policy '${other.name}' that foundry did not create`);
707
775
  }
708
776
  if (policies.length === 0) {
709
- await cf.createAccessPolicy(app.id, {
710
- name: ACCESS_POLICY_NAME,
711
- decision: 'allow',
712
- include: entry.accessEmails.map((email) => ({ email: { email } })),
713
- });
777
+ await cf.createAccessPolicy(app.id, { name: policyName, ...policy });
714
778
  }
715
779
  return { resourceId: app.id };
716
780
  }
@@ -3055,10 +3119,11 @@ export class FoundryStepExecutor {
3055
3119
  * (`bouleBrokerEntry`).
3056
3120
  */
3057
3121
  async coolifyBoule(action, org, context) {
3058
- // The issuer the plan read, carried in the step: the env written is the
3059
- // env the plan showed.
3122
+ // The issuer and Archon URL the plan read, carried in the step: the env
3123
+ // written is the env the plan showed.
3060
3124
  const issuer = action.desired.issuer ?? null;
3061
- const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer);
3125
+ const archonUrl = action.desired.archonUrl ?? null;
3126
+ const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl);
3062
3127
  if (missing.length > 0) {
3063
3128
  throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
3064
3129
  }
@@ -3905,6 +3970,12 @@ export class FoundryStepExecutor {
3905
3970
  * safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
3906
3971
  * this is called every time regardless of whether it already ran --
3907
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`.
3908
3979
  */
3909
3980
  async filesOrganisation(action, org, context) {
3910
3981
  const files = await this.filesPort();
@@ -3920,6 +3991,9 @@ export class FoundryStepExecutor {
3920
3991
  ceilingMicros: action.desired.ceilingMicros ?? null,
3921
3992
  ...(owner ? { name: org.displayName, owner } : {}),
3922
3993
  });
3994
+ const agoraOrigin = action.desired.agoraOrigin;
3995
+ if (agoraOrigin)
3996
+ await files.ensureAllowedOrigin(org.id, agoraOrigin);
3923
3997
  // Recorded now, so the org key is minted below the member files made.
3924
3998
  if (owner && provisioned.created)
3925
3999
  context.recordScopeId(FILES_ORG_OWNER_SCOPE_KEY, owner);
@@ -3,7 +3,7 @@ import type { DeclaredOrg, OrgId } from '../org.js';
3
3
  import type { Plan, PlanStep } from '../plan.js';
4
4
  import type { DeclaredPlatform } from '../platform.js';
5
5
  import type { AppliedState } from '../state.js';
6
- import type { DeclaredOrgStore, PlatformStore } from '../store.js';
6
+ import type { AppliedVersionsStore, DeclaredOrgStore, PlatformStore } from '../store.js';
7
7
  import { type AgoraHostPort } from './agora.js';
8
8
  import { type StepAction } from './diff.js';
9
9
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
@@ -204,6 +204,8 @@ export interface PlatformStepExecutor {
204
204
  driftedSteps?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<{
205
205
  archon?: boolean;
206
206
  auth?: boolean;
207
+ /** #380: see `PlanPlatformOptions.drifted.files` (`platform-diff.ts`). */
208
+ files?: boolean;
207
209
  backupStorage?: boolean;
208
210
  /** #257 fix round 4: see `PlanPlatformOptions.drifted.backupStorageBlocked` (`platform-diff.ts`). */
209
211
  backupStorageBlocked?: string;
@@ -315,6 +317,13 @@ export interface FoundryPlanApplyOptions {
315
317
  aiOrganisation?: boolean;
316
318
  /** Overridable for tests; defaults to `() => new Date()`. */
317
319
  now?: () => Date;
320
+ /**
321
+ * #253: appends a history row every time `runSteps` records a new
322
+ * version as applied, beside the single-snapshot `store.putAppliedState`
323
+ * write that already happens there. Optional, and a no-op without it --
324
+ * every existing caller of this constructor is unaffected.
325
+ */
326
+ appliedVersions?: AppliedVersionsStore;
318
327
  /**
319
328
  * #167: dependencies for planning/applying the shared platform (#166)
320
329
  * alongside an org. Optional so an org-only caller — every #19-era caller,
@@ -364,6 +373,7 @@ export declare class FoundryPlanApply implements PlanApply {
364
373
  private readonly hostFiles;
365
374
  private readonly hostAi;
366
375
  private readonly now;
376
+ private readonly appliedVersions;
367
377
  private readonly platformDeps;
368
378
  constructor(options: FoundryPlanApplyOptions);
369
379
  plan(id: OrgId): Promise<Plan>;
@@ -422,6 +432,16 @@ export declare class FoundryPlanApply implements PlanApply {
422
432
  * (perhaps not yet wired to this platform) keeps them.
423
433
  */
424
434
  private orgAgoraHost;
435
+ /**
436
+ * #374: the Archon that bills every org, written into each Boule as
437
+ * `ARCHON_URL` -- `https://` plus the declared platform's `archon.hostname`
438
+ * (today `archon.companyincluded.com`), the same `platformDeps`
439
+ * {@link orgAgoraHost} reads. `null` with no platform declared, Archon not
440
+ * enabled, or Archon not yet applied -- the same `enabled && applied` gate
441
+ * {@link agoraHostValuesFromPlatform} uses for `issuer`, so a Boule never
442
+ * points at an Archon that is not live yet.
443
+ */
444
+ private orgArchonUrl;
425
445
  /** #167: `FoundryPlanApplyOptions.platform`, or a plain `Error` naming what's missing. */
426
446
  private requirePlatformDeps;
427
447
  private computeFreshPlatformPlan;
@@ -8,7 +8,7 @@ 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 { planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
11
+ import { PLATFORM_ARCHON_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
13
  export class DeclaredOrgNotFoundError extends Error {
14
14
  constructor(orgId) {
@@ -98,6 +98,7 @@ export class FoundryPlanApply {
98
98
  hostFiles;
99
99
  hostAi;
100
100
  now;
101
+ appliedVersions;
101
102
  platformDeps;
102
103
  constructor(options) {
103
104
  this.store = options.store;
@@ -114,6 +115,7 @@ export class FoundryPlanApply {
114
115
  this.hostFiles = options.filesOrganisation ?? false;
115
116
  this.hostAi = options.aiOrganisation ?? false;
116
117
  this.now = options.now ?? (() => new Date());
118
+ this.appliedVersions = options.appliedVersions;
117
119
  this.platformDeps = options.platform;
118
120
  }
119
121
  async plan(id) {
@@ -344,14 +346,25 @@ export class FoundryPlanApply {
344
346
  });
345
347
  throw e;
346
348
  }
349
+ const lastApplied = { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) };
347
350
  const applied = {
348
351
  orgId,
349
352
  serviceIds,
350
353
  checkpoints,
351
354
  failedFields: failedFields(org, detail, detail.plannedSteps.length, serviceIds),
352
- lastApplied: { at: this.now().toISOString(), declaredOrgVersion: hashOrg(org) },
355
+ lastApplied,
353
356
  };
354
357
  await this.store.putAppliedState(applied);
358
+ // #253: the single-snapshot write just above replaces whatever
359
+ // `lastApplied` this org had; this is the point in today's code where a
360
+ // version is recorded as applied, so it's also where the appended
361
+ // history row goes. Optional, and a no-op without it, like every other
362
+ // host-supplied dependency on `FoundryPlanApplyOptions`.
363
+ await this.appliedVersions?.record({
364
+ service: orgId,
365
+ ref: lastApplied.declaredOrgVersion,
366
+ appliedAt: lastApplied.at,
367
+ });
355
368
  await this.audit.recordApply({
356
369
  actor: this.actor,
357
370
  orgId,
@@ -365,11 +378,12 @@ export class FoundryPlanApply {
365
378
  const org = await this.store.get(id);
366
379
  if (!org)
367
380
  throw new DeclaredOrgNotFoundError(id);
368
- const [live, applied, platformReadiness, agoraHost] = await Promise.all([
381
+ const [live, applied, platformReadiness, agoraHost, archonUrl] = await Promise.all([
369
382
  readLiveResources(id, this.readers),
370
383
  this.store.getAppliedState(id),
371
384
  this.orgPlatformReadiness(),
372
385
  this.orgAgoraHost(id),
386
+ this.orgArchonUrl(),
373
387
  ]);
374
388
  const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
375
389
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
@@ -377,6 +391,7 @@ export class FoundryPlanApply {
377
391
  broker: this.broker,
378
392
  otherSlugs,
379
393
  now: () => this.now().getTime(),
394
+ archonUrl,
380
395
  });
381
396
  const invalid = await this.invalidHosting(org);
382
397
  if (invalid) {
@@ -448,6 +463,26 @@ export class FoundryPlanApply {
448
463
  const recorded = await this.platformDeps.store.getServiceIds();
449
464
  return agoraHostValuesFromPlatform(platform, recorded);
450
465
  }
466
+ /**
467
+ * #374: the Archon that bills every org, written into each Boule as
468
+ * `ARCHON_URL` -- `https://` plus the declared platform's `archon.hostname`
469
+ * (today `archon.companyincluded.com`), the same `platformDeps`
470
+ * {@link orgAgoraHost} reads. `null` with no platform declared, Archon not
471
+ * enabled, or Archon not yet applied -- the same `enabled && applied` gate
472
+ * {@link agoraHostValuesFromPlatform} uses for `issuer`, so a Boule never
473
+ * points at an Archon that is not live yet.
474
+ */
475
+ async orgArchonUrl() {
476
+ if (!this.platformDeps)
477
+ return null;
478
+ const platform = await this.platformDeps.store.get();
479
+ if (!platform || !platform.archon.enabled)
480
+ return null;
481
+ const recorded = await this.platformDeps.store.getServiceIds();
482
+ if (!recorded[`coolify:app:${PLATFORM_ARCHON_KEY}`])
483
+ return null;
484
+ return `https://${platform.archon.hostname}`;
485
+ }
451
486
  /** #167: `FoundryPlanApplyOptions.platform`, or a plain `Error` naming what's missing. */
452
487
  requirePlatformDeps() {
453
488
  if (!this.platformDeps) {
@@ -579,11 +579,15 @@ export interface PlanPlatformOptions {
579
579
  * check itself failed (Coolify unreachable, credentials revoked) -- the
580
580
  * plan is never failed over it, but it is reported in `unmanaged` rather
581
581
  * than only logged, so a persistent failure is visible instead of silently
582
- * retried forever.
582
+ * retried forever. `files` (#380): the runner's live env is missing a
583
+ * setting `ensureStarted()` requires -- see `filesRunnerEnvDrifted`
584
+ * (`platform-providers.ts`) -- which the recorded hostname/gitRef alone
585
+ * could never show.
583
586
  */
584
587
  drifted?: {
585
588
  archon?: boolean;
586
589
  auth?: boolean;
590
+ files?: boolean;
587
591
  backupStorage?: boolean;
588
592
  backupStorageBlocked?: string;
589
593
  };
@@ -625,7 +625,7 @@ export function planPlatform(platform, live, options = {}) {
625
625
  // #171: after auth, so a first apply builds the sign-in the files step
626
626
  // creates its ZITADEL project in before that step runs.
627
627
  const authReady = auth.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]);
628
- const files = planFiles(platform, recordedIds, blocked.files, authReady);
628
+ const files = planFiles(platform, recordedIds, blocked.files, authReady, drifted.files);
629
629
  if (files.step)
630
630
  plannedSteps.push(files.step);
631
631
  // #169: the platform's own mail server. After auth, because its OIDC
@@ -1392,12 +1392,18 @@ function planIntegrations(platform, recordedIds, executorBlocked) {
1392
1392
  * `gitRef`, for the reason {@link planAuth} gives: no platform reader reports
1393
1393
  * these resources, so a live check would replan the step forever. A changed
1394
1394
  * hostname or `gitRef` plans the step again (a redeploy, no new secrets).
1395
+ * `drifted` (#380, `FoundryPlatformStepExecutor.driftedSteps`'s own live
1396
+ * check on the runner's env) replans it too, with neither changed: this is
1397
+ * how a setting {@link provisionPlatformFiles} only started setting after
1398
+ * the runner was already recorded applied -- `R2_BUCKET`, `R2_ENDPOINT` and
1399
+ * `KEYS_VERIFY_KEYS` among them -- ever reaches a runner that deployed
1400
+ * before.
1395
1401
  *
1396
1402
  * Blocked, not planned, while the platform's sign-in is neither recorded nor
1397
1403
  * planned in this same plan: the step creates its ZITADEL project and client
1398
1404
  * there, so it would throw on every apply.
1399
1405
  */
1400
- function planFiles(platform, recordedIds, executorBlocked, authReady) {
1406
+ function planFiles(platform, recordedIds, executorBlocked, authReady, drifted) {
1401
1407
  if (!platform.files.enabled)
1402
1408
  return { step: null, blocked: null };
1403
1409
  // A secret minted but never delivered and written is lost (the private key
@@ -1423,11 +1429,17 @@ function planFiles(platform, recordedIds, executorBlocked, authReady) {
1423
1429
  };
1424
1430
  }
1425
1431
  }
1426
- if (recordedIds[resourceKey('coolify', 'app', PLATFORM_FILES_KEY)] &&
1432
+ const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_FILES_KEY)]) &&
1427
1433
  recordedIds[PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY] === platform.files.hostname &&
1428
- recordedIds[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] === platform.files.gitRef) {
1434
+ recordedIds[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] === platform.files.gitRef;
1435
+ // #380: applied is not enough on its own -- the runner's live env can be
1436
+ // missing a setting `ensureStarted()` requires (`driftedSteps`'s own
1437
+ // `filesRunnerEnvDrifted`), the exact gap that left the platform's own
1438
+ // files host recorded applied and `running:unhealthy` at once. Drifted
1439
+ // replans the step so `provisionPlatformFiles` (which redeploys
1440
+ // unconditionally on every run) fills in whatever is still missing.
1441
+ if (applied && !drifted)
1429
1442
  return { step: null, blocked: null };
1430
- }
1431
1443
  if (executorBlocked)
1432
1444
  return { step: null, blocked: executorBlocked };
1433
1445
  if (!authReady) {
@@ -207,6 +207,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
207
207
  driftedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
208
208
  archon?: boolean;
209
209
  auth?: boolean;
210
+ files?: boolean;
210
211
  backupStorage?: boolean;
211
212
  backupStorageBlocked?: string;
212
213
  }>;
@@ -249,7 +250,8 @@ export declare const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = "foundry:platform:adm
249
250
  * ({@link PlatformMailDeps.stalwartForInstance}), the same way
250
251
  * `zitadelForInstance` is built from a freshly issued instance's issuer URL.
251
252
  * {@link defaultStalwartForInstance} is the real implementation, wrapping
252
- * `@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
253
255
  * `platformMailSettingsObjects` (see `provisionPlatformMail`'s own doc
254
256
  * comment for why not `mailObjects`) and `StalwartClient.createApiKey`.
255
257
  */
@@ -257,7 +259,8 @@ export interface PlatformMailAdminPort {
257
259
  /**
258
260
  * Sets the instance's default hostname and its OIDC directory
259
261
  * (`x:SystemSettings`/`x:Directory`/`x:Domain`/`x:Authentication`) --
260
- * 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.
261
264
  */
262
265
  applySettings(input: {
263
266
  mailHost: string;
@@ -645,13 +648,62 @@ export interface PlatformFilesDeps {
645
648
  * The keys worker the files host verifies and seals with, from the host's
646
649
  * held keys (`KEYS_WORKER_URL`, `KEYS_CREDENTIAL`). The `files-migrate` app
647
650
  * never becomes healthy without them, so the step sets both on both apps,
648
- * and the executor reports the step blocked while this is unset. Never
651
+ * and the executor reports the step blocked while either is unset. Never
649
652
  * recorded or logged.
653
+ *
654
+ * #380: `verifyKeys` (`KEYS_VERIFY_KEYS`) is the same kind of requirement,
655
+ * but only on the `files` runner, not `files-migrate` -- the runner's own
656
+ * `ensureStarted()` (`apps/host/src/lib/authz/files-host.ts`) requires it
657
+ * with `requireEnv(...)` to build its keys issuer, and the step reports
658
+ * the whole thing blocked while it is unset, the same as a missing
659
+ * `workerUrl`/`credential`. `auditToken` (`KEYS_AUDIT_TOKEN`) is not: it
660
+ * gates only the keys repo's own reconcile job against
661
+ * `GET /v1/keys/audit`, so it is set on the runner only when supplied and
662
+ * never blocks the step.
650
663
  */
651
664
  keys?: {
652
665
  workerUrl: string;
653
666
  credential: string;
667
+ verifyKeys?: string;
668
+ auditToken?: string;
654
669
  };
670
+ /**
671
+ * #380: the object storage (Cloudflare R2) the `files` runner stores
672
+ * objects in -- `R2_BUCKET`/`R2_ENDPOINT`, both `required(...)` by
673
+ * `ensureStarted()`. The actual R2 access key and secret are not here:
674
+ * the host reads them back through the keys worker above at runtime
675
+ * (files' own `.env.example`), never a plaintext env var this step would
676
+ * set. Absent, the step reports the runner blocked the same way a
677
+ * missing `keys` already does -- issue #380's "Decide" leaves open
678
+ * whether foundry ever mints this bucket itself (today, only a host that
679
+ * already has one can supply it).
680
+ */
681
+ r2?: {
682
+ bucket: string;
683
+ endpoint: string;
684
+ };
685
+ /**
686
+ * #380: `FILES_CLAMD_HOST`/`FILES_CLAMD_PORT` -- a clamd daemon reachable
687
+ * on the Coolify network. Optional: unset keeps files' own default
688
+ * (`confirm()` runs unscanned), and never blocks the step. Issue #380's
689
+ * "Decide" leaves open which clamd this should point at.
690
+ */
691
+ clamd?: {
692
+ host: string;
693
+ port?: string;
694
+ };
695
+ /**
696
+ * #380: `FILES_PUBLIC_HOSTS`, the public share hosts this deployment
697
+ * answers on, comma-separated bare host names. Optional; unset keeps
698
+ * `/s/<token>` 404 everywhere, today's behaviour.
699
+ */
700
+ publicHosts?: string;
701
+ /**
702
+ * #380: `JOBS_SIGNING_SECRETS`, the scheduled-jobs project's own HMAC
703
+ * secret(s), comma-separated, current first. Optional; unset keeps every
704
+ * `/api/jobs/*` route answering 503 `not_configured`, today's behaviour.
705
+ */
706
+ jobsSigningSecrets?: string;
655
707
  /** Clock and sleep for the migration wait and delivery retries; real ones by default. */
656
708
  runtime?: PlatformFilesRuntime;
657
709
  }