@wtfalch/foundry 0.62.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/bootstrap/declare.js +4 -1
  2. package/dist/bootstrap/versions.d.ts +2 -0
  3. package/dist/index.d.ts +6 -2
  4. package/dist/index.js +2 -1
  5. package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
  6. package/dist/migrations/0068_platform_registry.sql +17 -0
  7. package/dist/native-build.d.ts +7 -27
  8. package/dist/native-build.js +7 -27
  9. package/dist/plan/archon-blue-green.d.ts +5 -0
  10. package/dist/plan/archon-blue-green.js +2 -0
  11. package/dist/plan/blue-green-apply.d.ts +5 -0
  12. package/dist/plan/blue-green-apply.js +197 -22
  13. package/dist/plan/blue-green.d.ts +61 -2
  14. package/dist/plan/blue-green.js +80 -6
  15. package/dist/plan/boule.d.ts +1 -1
  16. package/dist/plan/boule.js +2 -0
  17. package/dist/plan/broker-entries.d.ts +9 -0
  18. package/dist/plan/broker-entries.js +29 -0
  19. package/dist/plan/diff.d.ts +5 -0
  20. package/dist/plan/diff.js +4 -0
  21. package/dist/plan/edge-access.d.ts +2 -3
  22. package/dist/plan/edge-access.js +7 -0
  23. package/dist/plan/executor.d.ts +71 -1
  24. package/dist/plan/executor.js +426 -24
  25. package/dist/plan/index.d.ts +2 -0
  26. package/dist/plan/index.js +1 -0
  27. package/dist/plan/jobs-scheduler-http.d.ts +2 -0
  28. package/dist/plan/jobs-scheduler-http.js +11 -0
  29. package/dist/plan/own-limits.d.ts +6 -0
  30. package/dist/plan/own-limits.js +1 -0
  31. package/dist/plan/plan-apply.d.ts +51 -1
  32. package/dist/plan/plan-apply.js +298 -25
  33. package/dist/plan/platform-diff.d.ts +6 -0
  34. package/dist/plan/platform-diff.js +74 -0
  35. package/dist/plan/platform-org.d.ts +2 -0
  36. package/dist/plan/platform-providers.d.ts +46 -1
  37. package/dist/plan/platform-providers.js +156 -1
  38. package/dist/plan/platform-registry-keys.d.ts +58 -0
  39. package/dist/plan/platform-registry-keys.js +69 -0
  40. package/dist/plan/platform-registry.d.ts +67 -0
  41. package/dist/plan/platform-registry.js +298 -0
  42. package/dist/plan/release-host.d.ts +31 -2
  43. package/dist/plan/release-host.js +114 -6
  44. package/dist/plan/stage-copy-host.d.ts +104 -0
  45. package/dist/plan/stage-copy-host.js +259 -0
  46. package/dist/plan/stage-copy.js +44 -0
  47. package/dist/plan/stage-env.d.ts +9 -0
  48. package/dist/plan/stage-env.js +38 -0
  49. package/dist/plan/stage-guards.d.ts +57 -1
  50. package/dist/plan/stage-guards.js +106 -0
  51. package/dist/plan/stage-isolation.js +56 -0
  52. package/dist/plan/stage-services.d.ts +93 -0
  53. package/dist/plan/stage-services.js +153 -0
  54. package/dist/plan/stage-signin.d.ts +152 -0
  55. package/dist/plan/stage-signin.js +133 -0
  56. package/dist/plan/stage-sink.d.ts +2 -0
  57. package/dist/plan/stage-sink.js +6 -1
  58. package/dist/platform.d.ts +7 -1
  59. package/dist/platform.js +1 -0
  60. package/dist/release/decide.d.ts +12 -0
  61. package/dist/release/decide.js +37 -2
  62. package/dist/release/run.d.ts +71 -1
  63. package/dist/release/run.js +92 -8
  64. package/dist/release/state.d.ts +14 -1
  65. package/dist/release/state.js +43 -0
  66. package/dist/release/types.d.ts +20 -0
  67. package/dist/release/types.js +2 -0
  68. package/dist/requested-changes.d.ts +11 -2
  69. package/dist/requested-changes.js +11 -0
  70. package/dist/store/postgres.js +8 -0
  71. package/dist/store/schema.d.ts +51 -0
  72. package/dist/store/schema.js +4 -0
  73. package/package.json +3 -2
  74. package/versions.digests.json +1 -0
  75. package/versions.json +1 -0
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { VERSIONS, pinnedRef } from '../bootstrap/versions.js';
3
3
  import { kosmosOn } from '../org.js';
4
- import { agoraHostnameFor } from './agora.js';
4
+ import { ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor } from './agora.js';
5
5
  import { bouleHostnameFor } from './boule.js';
6
6
  import { serverConnectorScopeKey } from './cloudflared-connector.js';
7
7
  import { entryHostnames, tunnelIngressHostnames, wildcardHostname } from './hostnames.js';
@@ -9,6 +9,7 @@ import { kosmosHostnameFor } from './kosmos.js';
9
9
  import { appNameFor, appStepKey } from './platform-diff.js';
10
10
  import { remoteDatabaseAppliedFor } from './remote-database.js';
11
11
  import { buildStageEnv } from './stage-env.js';
12
+ import { stageHostname } from './stage-guards.js';
12
13
  import { assertStageIsolated, envClass } from './stage-isolation.js';
13
14
  import { STAGE_SINK_VERSION } from './stage-sink.js';
14
15
  export const COLORS = ['blue', 'green'];
@@ -40,6 +41,8 @@ export const bgKey = {
40
41
  digest: (id, color) => `bg.${id}.digest.${color}`,
41
42
  weights: (id) => `bg.${id}.weights`,
42
43
  stage: (id) => `bg.${id}.stage`,
44
+ /** The last `deploy_stage`: `<ISO time>|ok|<commit>` or `<ISO time>|failed|<step>`. */
45
+ stageDeploy: (id) => `bg.${id}.stagedeploy`,
43
46
  window: (id) => `bg.${id}.window`,
44
47
  held: (id, digest) => `bg.${id}.held.${digest}`,
45
48
  /** The weights the load balancer on `server` was last written with. */
@@ -66,8 +69,29 @@ export const bgKey = {
66
69
  stageIsolation: (id) => `bg.${id}.stageisolation`,
67
70
  /** The id of the Access gate over the stage host: staff only, no bypass, no service-token rule. */
68
71
  stageGate: (id) => `bg.${id}.stagegate`,
72
+ /** The hash of the sorted email list last written to the stage gate (owners plus the people holding `releases:preview`). */
73
+ stageGateMembers: (id) => `bg.${id}.stagegatemembers`,
69
74
  /** `ok:<hash>` once every apply-time probe was rejected; `notrun:<names>` or `failed:<names>` otherwise. */
70
75
  stageProbes: (id) => `bg.${id}.stageprobes`,
76
+ /** Stage's own sign-in client: its ZITADEL app id, and its client id (public, not a secret). */
77
+ stageClient: (id) => `bg.${id}.stageclient`,
78
+ stageClientId: (id) => `bg.${id}.stageclientid`,
79
+ /** The held ref of stage's app key, recorded and persisted as soon as it is minted (no `:`: undelivered). */
80
+ stageSignInKey: (id) => `bg.${id}.stagesigninkey`,
81
+ /** `{name, organizationId}` of stage's broker entry, so org destroy can remove it. No key. */
82
+ stageBroker: (id) => `bg.${id}.stagebroker`,
83
+ /** `ok:<hash of the client, app and broker entry ids>` or `none`; the copy waits for it. */
84
+ stageSignIn: (id) => `bg.${id}.stagesignin`,
85
+ /** `none` (production's app uses no files, ai or jobs) or `ok:<hash>` once every service it uses is held by stage or listed absent. */
86
+ stageServices: (id) => `bg.${id}.stageservices`,
87
+ /** `ok:<hash of the ids>` or `absent:<service>:<reason>` for one service (`files`, `ai` or `jobs`). */
88
+ stageService: (id, service) => `bg.${id}.stage${service}`,
89
+ /** The id of stage's own organisation (files, ai) or project (jobs). An id, not a secret. */
90
+ stageServiceOrg: (id, service) => `bg.${id}.stage${service}org`,
91
+ /** The name stage's project or organisation was made under, when the service is deleted by name (jobs). */
92
+ stageServiceName: (id, service) => `bg.${id}.stage${service}name`,
93
+ /** `{ids, refs}` of stage's keys in that service: key ids and held refs, never a value. */
94
+ stageServiceKeys: (id, service) => `bg.${id}.stage${service}keys`,
71
95
  /** `applied` once the stage app holds the sink's mail env. The copy refuses without it. */
72
96
  stageSink: (id) => `bg.${id}.stagesink`,
73
97
  /** `<ISO time>|<dump execution id>|ok`, written by a finished copy of production into stage. */
@@ -437,12 +461,42 @@ export function structureHash(subject, input) {
437
461
  primary: input.primary,
438
462
  lb: VERSIONS.traefik,
439
463
  ...(input.primary ? { stageSink: STAGE_SINK_VERSION } : {}),
464
+ // Only a primary has a stage, and only a present guard counts: no guard hashes as before.
465
+ ...(input.primary && input.guards?.length ? { stageGuards: [...input.guards].sort() } : {}),
440
466
  }))
441
467
  .digest('hex')
442
468
  .slice(0, 16);
443
469
  }
444
- /** The kind of stage app a subject has: platform-archon, else a customer app. */
445
- export const stageKindOf = (subject) => subject.id === ARCHON_SUBJECT_ID ? 'archon' : 'customer';
470
+ /**
471
+ * The kind of stage app a subject has: Boule and Agora by `builtin` (a hosting entry that happens to be keyed
472
+ * `boule` is still a customer app), platform-archon by its id, else a customer app (kosmos included: it has no lists of its own).
473
+ */
474
+ export const stageKindOf = (subject) => subject.builtin === 'boule' || subject.builtin === 'agora'
475
+ ? subject.builtin
476
+ : subject.id === ARCHON_SUBJECT_ID
477
+ ? 'archon'
478
+ : 'customer';
479
+ /**
480
+ * The stage guards that can be made now, by name. The names go into the applied structure hash, so a guard
481
+ * that becomes possible after the first apply changes the hash and plans the structure step once more.
482
+ */
483
+ export function stageGuardInputs(facts) {
484
+ const out = [];
485
+ if (facts.probesPort)
486
+ out.push('probes');
487
+ if (facts.accessPort &&
488
+ facts.providerId &&
489
+ facts.domain &&
490
+ facts.claimValue &&
491
+ facts.emails > 0 &&
492
+ !facts.hostRuled) {
493
+ out.push('gate');
494
+ }
495
+ return out;
496
+ }
497
+ /** The guard names as the plan's `desired.guards` carries them, and back. */
498
+ export const formatGuards = (guards) => [...guards].sort().join(',');
499
+ export const parseGuards = (value) => typeof value === 'string' && value ? value.split(',') : [];
446
500
  /** The names stage can collide on. */
447
501
  export function stageResources(subject) {
448
502
  const database = `${subject.base}-stage-db`;
@@ -623,10 +677,22 @@ export function planBlueGreen(input) {
623
677
  key: `bg:${id}:stage`,
624
678
  });
625
679
  }
680
+ // The guards that can be made now go into the applied hash: one that becomes possible after the first
681
+ // apply (a provider, a probe port, an owner) changes the hash and plans the structure step once more.
682
+ const stageHost = org.domain ? stageHostname(org.domain, id) : null;
683
+ const guards = stageGuardInputs({
684
+ probesPort: input.stageGuardPorts?.probes === true,
685
+ accessPort: input.stageGuardPorts?.access === true,
686
+ providerId: input.accessProviderId,
687
+ domain: org.domain,
688
+ claimValue: recordedIds[ZITADEL_ORG_SCOPE_KEY],
689
+ emails: org.owners.length,
690
+ hostRuled: (org.hosting.access ?? []).some((rule) => rule.hostname === stageHost),
691
+ });
626
692
  for (const server of targets) {
627
693
  const isPrimary = server === primary;
628
694
  const key = `${org.id}:${id}:${server}`;
629
- const hash = structureHash(subject, { primary: isPrimary });
695
+ const hash = structureHash(subject, { primary: isPrimary, guards });
630
696
  if (!isPrimary && subject.database && !remoteDatabaseAppliedFor(recordedIds, id, server)) {
631
697
  unmanaged.push({
632
698
  description: `${org.slug}'s ${id} app has no blue/green copies on Coolify server ${server} yet: its database route to that server is not applied`,
@@ -651,6 +717,7 @@ export function planBlueGreen(input) {
651
717
  primary,
652
718
  weights: formatWeights(weights),
653
719
  ...(input.accessProviderId ? { accessProviderId: input.accessProviderId } : {}),
720
+ ...(isPrimary && guards.length > 0 ? { guards: formatGuards(guards) } : {}),
654
721
  },
655
722
  });
656
723
  }
@@ -749,7 +816,9 @@ export function planArchonBlueGreen(input) {
749
816
  if (!primary || !up.includes(primary)) {
750
817
  return blocked(`the primary Coolify server ${primary ?? '(none)'} is not usable and reachable`);
751
818
  }
752
- const done = recorded[bgKey.applied(subject.id, primary)] === structureHash(subject, { primary: true }) &&
819
+ const guards = input.guards ?? [];
820
+ const done = recorded[bgKey.applied(subject.id, primary)] ===
821
+ structureHash(subject, { primary: true, guards }) &&
753
822
  recorded[bgKey.lb(subject.id, primary)] === formatWeights(weights) &&
754
823
  recorded[ARCHON_BG_ORIGIN_SCOPE_KEY] === lbOrigin(subject);
755
824
  if (done)
@@ -761,7 +830,12 @@ export function planArchonBlueGreen(input) {
761
830
  phase: 'after',
762
831
  key: subject.id,
763
832
  description: `run platform-archon as blue and green behind a load balancer on Coolify server ${primary}, then send its tunnel to the balancer`,
764
- desired: { subject: subject.id, primary, weights: formatWeights(weights) },
833
+ desired: {
834
+ subject: subject.id,
835
+ primary,
836
+ weights: formatWeights(weights),
837
+ ...(guards.length > 0 ? { guards: formatGuards(guards) } : {}),
838
+ },
765
839
  },
766
840
  };
767
841
  }
@@ -154,7 +154,7 @@ export declare const registrarWanted: (org: Pick<DeclaredOrg, "customers" | "bou
154
154
  /** The broker's base as Boule calls it: `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`. */
155
155
  export declare const registrarUrl: (issuer: string) => string;
156
156
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
157
- export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "OWNER_SEED_SECRET", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "TEST_CUSTOMERS_ORGANISATION_ID", "CUSTOMER_GRANTS", "SIGNIN_UPSTREAM", "AUTH_REGISTRAR_URL", "AUTH_REGISTRAR_KEY"];
157
+ export declare const BOULE_MANAGED_ENV_KEYS: readonly ["APP_URL", "AUTH_ISSUER", "AUTH_ORGANIZATION_ID", "AUTH_CLIENT_ID", "BOULE_ORG_NAME", "AUTHZ_BOOTSTRAP_OPERATOR", "AUTHZ_FIRST_OWNER", "ARCHON_URL", "MAIL_FROM", "LETTERMINT_API_TOKEN", "AUTHZ_PEER_PASSWORD_OPERATOR", "ARCHON_BRIDGE_SECRET", "OWNER_SEED_SECRET", "NATIVE_BUILD_KEY", "ARCHON_ORG_ID", "ARCHON_CREDENTIAL", "CUSTOMERS_ORGANISATION_ID", "CUSTOMERS_DIRECTORY_CREDENTIAL", "TEST_CUSTOMERS_ORGANISATION_ID", "CUSTOMER_GRANTS", "SIGNIN_UPSTREAM", "AUTH_REGISTRAR_URL", "AUTH_REGISTRAR_KEY"];
158
158
  /**
159
159
  * The env vars an org's Boule app should carry, and every input still missing
160
160
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -1,3 +1,4 @@
1
+ import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
1
2
  import { DEFAULT_NO_REPLY_LOCAL_PART, appsOn, ownerMailboxes, } from '../org.js';
2
3
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
4
  import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
@@ -176,6 +177,7 @@ export const BOULE_MANAGED_ENV_KEYS = [
176
177
  BOULE_PEER_PASSWORD_ENV,
177
178
  BOULE_BRIDGE_SECRET_ENV,
178
179
  BOULE_OWNER_SEED_SECRET_ENV,
180
+ NATIVE_BUILD_KEY_ENV,
179
181
  BOULE_ARCHON_ORG_ID_ENV,
180
182
  BOULE_ARCHON_CREDENTIAL_ENV,
181
183
  BOULE_CUSTOMERS_ORG_ENV,
@@ -178,3 +178,12 @@ export declare function writeBrokerEntry(broker: BrokerPort, input: {
178
178
  invalidForeign: string[];
179
179
  replaced: string | null;
180
180
  }>;
181
+ /**
182
+ * Takes one entry (and so its key) out of the broker's `AUTH_APPS`, matched by name and organisation, and
183
+ * deploys the broker when the list changed. Every other entry is copied back as it was. An entry that is
184
+ * already gone writes nothing. Errors name the entry, never a key.
185
+ */
186
+ export declare function removeBrokerEntry(broker: BrokerPort, input: {
187
+ name: string;
188
+ organizationId: string;
189
+ }): Promise<boolean>;
@@ -373,3 +373,32 @@ export async function writeBrokerEntry(broker, input) {
373
373
  replaced: replaced.length > 0 ? JSON.stringify(replaced) : null,
374
374
  };
375
375
  }
376
+ /**
377
+ * Takes one entry (and so its key) out of the broker's `AUTH_APPS`, matched by name and organisation, and
378
+ * deploys the broker when the list changed. Every other entry is copied back as it was. An entry that is
379
+ * already gone writes nothing. Errors name the entry, never a key.
380
+ */
381
+ export async function removeBrokerEntry(broker, input) {
382
+ const label = `broker entry '${input.name}'`;
383
+ const env = (await broker.listEnvs()).find((e) => e.key === 'AUTH_APPS' && !e.is_preview);
384
+ if (!env?.value)
385
+ return false;
386
+ let parsed;
387
+ try {
388
+ parsed = JSON.parse(env.value);
389
+ }
390
+ catch {
391
+ throw new Error(`${label}: the broker's AUTH_APPS is not JSON, so nothing was removed`);
392
+ }
393
+ if (!Array.isArray(parsed)) {
394
+ throw new Error(`${label}: the broker's AUTH_APPS is not a JSON array, so nothing was removed`);
395
+ }
396
+ const next = parsed.filter((entry) => !(isObject(entry) &&
397
+ entry.name === input.name &&
398
+ entry.organizationId === input.organizationId));
399
+ if (next.length === parsed.length)
400
+ return false;
401
+ await broker.setAuthApps(JSON.stringify(next));
402
+ await broker.deploy();
403
+ return true;
404
+ }
@@ -150,6 +150,11 @@ export interface PlanOrgOptions {
150
150
  blueGreenServers?: readonly BlueGreenServer[];
151
151
  /** The company's stage mail sink is recorded and its live app is stopped or gone. */
152
152
  stageSinkDown?: boolean;
153
+ /** Which stage-guard ports the host has ({@link PlanBlueGreenInput.stageGuardPorts}). */
154
+ stageGuardPorts?: {
155
+ probes: boolean;
156
+ access: boolean;
157
+ };
153
158
  }
154
159
  export interface PlanDetail {
155
160
  orgId: OrgId;
package/dist/plan/diff.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { VERSIONS } from '../bootstrap/versions.js';
2
+ import { NATIVE_BUILD_KEY_ENV } from '../native-build.js';
2
3
  import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, appsOn, companyGithubFor, heldEnvRef, kosmosOn, mainEntry, ownerMailboxRecorded, ownerMailboxes, ownerNameProblem, ownsServices, } from '../org.js';
3
4
  import { entryHasDatabase } from '../worker-app-config.js';
4
5
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, entryClientIdScopeKey, withRecordedMessagingProject, } from './agora.js';
@@ -568,6 +569,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
568
569
  canHoldSecrets: platformReadiness.canHoldSecrets === true,
569
570
  accessProviderId: platformReadiness.accessProviderId ?? null,
570
571
  ...(options.stageSinkDown ? { stageSinkDown: true } : {}),
572
+ ...(options.stageGuardPorts ? { stageGuardPorts: options.stageGuardPorts } : {}),
571
573
  appPlanned: (entryKey) => plannedSteps.some((p) => p.action.system === 'coolify' &&
572
574
  p.action.kind === 'app' &&
573
575
  p.action.key === appStepKey(org, entryKey)),
@@ -3386,6 +3388,8 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
3386
3388
  drift.push(BOULE_BRIDGE_SECRET_ENV);
3387
3389
  if (!liveEnv[BOULE_OWNER_SEED_SECRET_ENV])
3388
3390
  drift.push(BOULE_OWNER_SEED_SECRET_ENV);
3391
+ if (!liveEnv[NATIVE_BUILD_KEY_ENV])
3392
+ drift.push(NATIVE_BUILD_KEY_ENV);
3389
3393
  // Secrets written but Boule not restarted since: plan the step so it restarts, minting nothing.
3390
3394
  if (platformOwner && !recordedIds[BOULE_SECRETS_RESTARTED_SCOPE_KEY]) {
3391
3395
  drift.push('restart after the peer password and bridge secret');
@@ -87,9 +87,8 @@ export interface AccessPort {
87
87
  }>;
88
88
  replaceGateHostnames(gateId: string, hostnames: string[]): Promise<void>;
89
89
  /**
90
- * Replaces the whole list of people who pass a gate; nothing else about the gate changes. Edge's platform
91
- * client has no such call yet, so {@link createEdgeAccess} does not implement it: a host that wants a
92
- * workspace's testers to change supplies a port that does (edge's company `gates.setPeople`).
90
+ * Replaces the whole list of people who pass a gate; nothing else about the gate changes. The edge adapter
91
+ * implements it with edge's platform `setGatePeople`; a host may supply its own port instead.
93
92
  */
94
93
  setGatePeople?(gateId: string, emails: string[]): Promise<void>;
95
94
  /** Deletes the gate with its application, policy and list; a gate already gone is done. */
@@ -167,6 +167,13 @@ export function createEdgeAccess(client, options = {}) {
167
167
  async replaceGateHostnames(gateId, hostnames) {
168
168
  await writeSlot(() => access.replaceGateHostnames(gateId, { hostnames }));
169
169
  },
170
+ ...(typeof client.setGatePeople === 'function'
171
+ ? {
172
+ async setGatePeople(gateId, emails) {
173
+ await writeSlot(() => access.setGatePeople(gateId, { emails }));
174
+ },
175
+ }
176
+ : {}),
170
177
  async readiness() {
171
178
  const { authDomain } = await access.readiness();
172
179
  return { authDomain };
@@ -21,7 +21,8 @@ import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor }
21
21
  import type { EdgePortName } from './platform-org.js';
22
22
  import type { RequestHeaderPort } from './platform-providers.js';
23
23
  import { type ReleaseContext, type ReleaseHost } from './release-host.js';
24
- import { type StageProbePort } from './stage-guards.js';
24
+ import { type StageCopyHostConfig } from './stage-copy-host.js';
25
+ import { type ReleasePreviewMembers, type StageProbePort } from './stage-guards.js';
25
26
  import type { WorkerAppDatabasePort } from './worker-app-database.js';
26
27
  import { type KeysCustodyPort, type WorkerAppPort } from './worker-app.js';
27
28
  export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
@@ -1411,6 +1412,11 @@ export interface FilesOrganisationPort {
1411
1412
  * the list is a no-op.
1412
1413
  */
1413
1414
  ensureAllowedOrigin(organisationId: string, origin: string): Promise<void>;
1415
+ /**
1416
+ * Deletes an organisation with its keys. Optional: used only by org destroy for stage's own organisation
1417
+ * (`<org>-stage`); a port without it leaves that organisation, which destroy then reports as blocked.
1418
+ */
1419
+ deleteOrganisation?(organisationId: string): Promise<void>;
1414
1420
  }
1415
1421
  /**
1416
1422
  * Provisions one org's organisation on AI and switches its AI use on (#80),
@@ -1519,6 +1525,11 @@ export interface AiOrganisationPort {
1519
1525
  keyId: string | null;
1520
1526
  limitMicros: string;
1521
1527
  }): Promise<unknown>;
1528
+ /**
1529
+ * Deletes an organisation with its keys and budget. Optional: used only by org destroy for stage's own
1530
+ * organisation (`<org>-stage`); a port without it leaves that organisation, which destroy then reports as blocked.
1531
+ */
1532
+ deleteOrganisation?(organisationId: string): Promise<void>;
1522
1533
  }
1523
1534
  /**
1524
1535
  * #317 (F7): the edge service's own management API
@@ -1634,6 +1645,11 @@ export interface JobsSchedulerPort {
1634
1645
  secret?: string;
1635
1646
  syncKey?: string;
1636
1647
  }>;
1648
+ /**
1649
+ * Removes a project and its keys (`DELETE /admin/projects/<name>`). Optional: used only by org destroy for
1650
+ * stage's own project (`<org>-stage`); a port without it leaves that project, which destroy then reports as blocked.
1651
+ */
1652
+ deleteProject?(name: string): Promise<void>;
1637
1653
  }
1638
1654
  /**
1639
1655
  * #177: where the org steps' files, AI and jobs ports come from when the host
@@ -1746,6 +1762,18 @@ export interface FoundryStepExecutorPorts {
1746
1762
  * one the probes are reported as not run, and the stage copy stays blocked.
1747
1763
  */
1748
1764
  stageProbes?: StageProbePort;
1765
+ /**
1766
+ * The stage copy (`copy_prod_to_stage`, run by the release tick). The host supplies the dump download and the
1767
+ * backup storage; foundry builds the restore port itself and reads production's address live from Coolify.
1768
+ * Without it a copy request is refused with `no-copy-port`. The host needs `pg_restore` (Postgres 16) on PATH.
1769
+ */
1770
+ stageCopy?: StageCopyHostConfig;
1771
+ /**
1772
+ * Who may open an org's stage gate besides its owners: the people holding `releases:preview` in `orgId`. Read on
1773
+ * each release tick (10 s limit) and when the gate is made; a failed read leaves the gate as it is. Without it
1774
+ * the gate keeps the owners it was made with.
1775
+ */
1776
+ releasePreviewMembers?: ReleasePreviewMembers;
1749
1777
  /**
1750
1778
  * F4.ratelimits: overrides the rate limiting rule calls, which otherwise go through
1751
1779
  * `edge.ratelimits` (see {@link EdgePort.ratelimits}). Kept for a host or a test that supplies its own.
@@ -2436,8 +2464,18 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2436
2464
  * blue copy is not recorded. The health token is only read here, never minted.
2437
2465
  */
2438
2466
  releaseHost(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
2467
+ /**
2468
+ * The copy's ports for one subject. Production's database is the org's (`coolify:database:<org id>`) for an
2469
+ * entry and Boule's own for Boule; Agora has none, so its copy is refused with `no-production-database`.
2470
+ */
2471
+ private stageCopyFor;
2439
2472
  /** Whether the stage mail sink app is stopped or gone, read live. A failed read throws; the caller reads that as up. */
2440
2473
  stageSinkDown(appId: string): Promise<boolean>;
2474
+ /** Which stage-guard ports this executor has, for the planner (they decide the guard names in the applied hash). */
2475
+ stageGuardPorts(): {
2476
+ probes: boolean;
2477
+ access: boolean;
2478
+ };
2441
2479
  /** Every server Coolify lists with whether it is usable and reachable, for the planner. */
2442
2480
  blueGreenServers(): Promise<BlueGreenServer[]>;
2443
2481
  /**
@@ -2505,6 +2543,25 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2505
2543
  * bypass or service-token rule there would open stage, so no gate is made and the guard stays unrecorded.
2506
2544
  */
2507
2545
  private stageGuards;
2546
+ /**
2547
+ * One release tick for one subject: its recorded stage gate's list follows the people holding
2548
+ * `releases:preview`, owners always staying (`syncStageGateMembers`). Never throws.
2549
+ */
2550
+ syncStageGateMembers(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext): Promise<void>;
2551
+ /**
2552
+ * Stage's own files organisation, ai organisation and jobs project (`plan/stage-services.ts`), each named
2553
+ * for its subject (`<org id>-stage-<subject id>`, `<slug>-stage-<subject id>`: one per subject, as the recorded keys are) and built from the same ports production's steps use. A service whose port is
2554
+ * missing or not ready answers `available() === false`, and stage then records it absent and mints nothing.
2555
+ */
2556
+ private stageServicesPort;
2557
+ /**
2558
+ * Stage's own sign-in (`plan/stage-signin.ts`): a client named `<production client>-stage` on production's
2559
+ * sign-in organisation and project with redirect URIs on the stage host only, a broker entry
2560
+ * `<production entry>-stage` with a key of its own, and that key's held ref. `{ required: false }` for an entry
2561
+ * that does not sign in. `undefined`, with one fixed log line saying why, when something it needs is missing:
2562
+ * the guard then stays unrecorded and the copy waits. Platform-archon is not covered yet.
2563
+ */
2564
+ private stageSignInPort;
2508
2565
  private coolifyApp;
2509
2566
  /**
2510
2567
  * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
@@ -2711,7 +2768,20 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2711
2768
  private ensurePeerPassword;
2712
2769
  /** Same rule for the secret Archon's invite form sends to Boule. */
2713
2770
  private ensureBridgeSecret;
2771
+ /**
2772
+ * `minLength`, when given, also counts a present-but-empty value as not set -- the same guard
2773
+ * {@link ensureAuthAppKey} already applies for `AUTH_APP_KEY`, now reusable here instead of a
2774
+ * third, bare copy of this check (foundry#970, thermo note 1 in #957). Omitted, the three existing
2775
+ * callers below keep their original behaviour.
2776
+ */
2714
2777
  private mintIfAbsent;
2778
+ /**
2779
+ * Every company's Boule, F3/#970: `NATIVE_BUILD_KEY` is what Boule presents to Archon's
2780
+ * native-build route (estate#262) to ask it to dispatch `build-app.yml`. Minted once, like
2781
+ * {@link ensureAuthAppKey}, and never rotated; a present-but-empty value (the bug
2782
+ * `mintNativeBuildKey`'s own read-then-generate had, see #957) is treated as not set.
2783
+ */
2784
+ private ensureNativeBuildKey;
2715
2785
  /**
2716
2786
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
2717
2787
  * only the first time (task brief), never regenerated or overwritten, so a