@wtfalch/foundry 0.60.0 → 0.61.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 (218) hide show
  1. package/dist/apps/cloudflare.d.ts +173 -0
  2. package/dist/apps/cloudflare.js +17 -0
  3. package/dist/apps/deploy.d.ts +116 -0
  4. package/dist/apps/deploy.js +791 -0
  5. package/dist/apps/github.d.ts +47 -0
  6. package/dist/apps/github.js +201 -0
  7. package/dist/apps/index.d.ts +9 -0
  8. package/dist/apps/index.js +9 -0
  9. package/dist/apps/migrations.d.ts +110 -0
  10. package/dist/apps/migrations.js +458 -0
  11. package/dist/audit/connector.d.ts +43 -0
  12. package/dist/audit/connector.js +22 -0
  13. package/dist/audit/evidence.d.ts +49 -0
  14. package/dist/audit/evidence.js +56 -0
  15. package/dist/audit/index.d.ts +9 -0
  16. package/dist/audit/index.js +9 -0
  17. package/dist/audit/migrations.d.ts +5 -0
  18. package/dist/audit/migrations.js +5 -0
  19. package/dist/audit/registry.d.ts +49 -0
  20. package/dist/audit/registry.js +44 -0
  21. package/dist/audit/seal.d.ts +49 -0
  22. package/dist/audit/seal.js +131 -0
  23. package/dist/audit/sealer-role.d.ts +33 -0
  24. package/dist/audit/sealer-role.js +56 -0
  25. package/dist/audit/signer.d.ts +40 -0
  26. package/dist/audit/signer.js +78 -0
  27. package/dist/audit/tick.d.ts +36 -0
  28. package/dist/audit/tick.js +87 -0
  29. package/dist/catalogue/copy-en.d.ts +1 -0
  30. package/dist/catalogue/copy-en.js +129 -0
  31. package/dist/catalogue/index.d.ts +9 -0
  32. package/dist/catalogue/index.js +21 -0
  33. package/dist/catalogue/integrations.d.ts +4 -0
  34. package/dist/catalogue/integrations.js +104 -0
  35. package/dist/catalogue/systems.d.ts +7 -0
  36. package/dist/catalogue/systems.js +31 -0
  37. package/dist/catalogue/types.d.ts +110 -0
  38. package/dist/catalogue/types.js +1 -0
  39. package/dist/entry-rules.d.ts +53 -0
  40. package/dist/entry-rules.js +71 -0
  41. package/dist/hosted-label.d.ts +13 -0
  42. package/dist/hosted-label.js +81 -0
  43. package/dist/index.d.ts +10 -5
  44. package/dist/index.js +5 -3
  45. package/dist/migrations/0062_hosting_github.sql +6 -0
  46. package/dist/migrations/0063_boule_apps.sql +7 -0
  47. package/dist/migrations/0064_requested_changes_boule_apps.sql +6 -0
  48. package/dist/modules/ai.d.ts +3 -0
  49. package/dist/modules/ai.js +64 -0
  50. package/dist/modules/assistants.d.ts +3 -0
  51. package/dist/modules/assistants.js +72 -0
  52. package/dist/modules/audit-ledger.d.ts +29 -0
  53. package/dist/modules/audit-ledger.js +49 -0
  54. package/dist/modules/audit.d.ts +7 -0
  55. package/dist/modules/audit.js +44 -0
  56. package/dist/modules/database.d.ts +23 -0
  57. package/dist/modules/database.js +189 -0
  58. package/dist/modules/email.d.ts +3 -0
  59. package/dist/modules/email.js +18 -0
  60. package/dist/modules/entry-parts.d.ts +23 -0
  61. package/dist/modules/entry-parts.js +63 -0
  62. package/dist/modules/files.d.ts +3 -0
  63. package/dist/modules/files.js +64 -0
  64. package/dist/modules/index.d.ts +74 -0
  65. package/dist/modules/index.js +774 -0
  66. package/dist/modules/jobs.d.ts +3 -0
  67. package/dist/modules/jobs.js +23 -0
  68. package/dist/modules/organisations.d.ts +3 -0
  69. package/dist/modules/organisations.js +20 -0
  70. package/dist/modules/outputs.d.ts +9 -0
  71. package/dist/modules/outputs.js +38 -0
  72. package/dist/modules/sign-in.d.ts +3 -0
  73. package/dist/modules/sign-in.js +197 -0
  74. package/dist/modules/signed-records.d.ts +3 -0
  75. package/dist/modules/signed-records.js +32 -0
  76. package/dist/modules/types.d.ts +194 -0
  77. package/dist/modules/types.js +1 -0
  78. package/dist/modules/workspaces.d.ts +3 -0
  79. package/dist/modules/workspaces.js +75 -0
  80. package/dist/org.d.ts +163 -22
  81. package/dist/org.js +419 -47
  82. package/dist/plan/apps-db-route.d.ts +90 -0
  83. package/dist/plan/apps-db-route.js +168 -0
  84. package/dist/plan/apps-zones.d.ts +56 -0
  85. package/dist/plan/apps-zones.js +118 -0
  86. package/dist/plan/archon-blue-green.js +11 -1
  87. package/dist/plan/blue-green-apply.d.ts +37 -1
  88. package/dist/plan/blue-green-apply.js +55 -15
  89. package/dist/plan/blue-green.d.ts +41 -1
  90. package/dist/plan/blue-green.js +80 -2
  91. package/dist/plan/boule.d.ts +23 -1
  92. package/dist/plan/boule.js +35 -2
  93. package/dist/plan/broker-entries.d.ts +28 -3
  94. package/dist/plan/broker-entries.js +74 -3
  95. package/dist/plan/broker-registrar.d.ts +168 -0
  96. package/dist/plan/broker-registrar.js +440 -0
  97. package/dist/plan/broker-workspaces-database.d.ts +48 -0
  98. package/dist/plan/broker-workspaces-database.js +87 -0
  99. package/dist/plan/desktop-callback.js +12 -7
  100. package/dist/plan/diff.d.ts +8 -2
  101. package/dist/plan/diff.js +373 -42
  102. package/dist/plan/edge-access.d.ts +6 -0
  103. package/dist/plan/edge-dns.d.ts +6 -0
  104. package/dist/plan/edge-dns.js +9 -7
  105. package/dist/plan/entry-ai-key.d.ts +1 -1
  106. package/dist/plan/entry-ai-key.js +2 -2
  107. package/dist/plan/entry-audit.d.ts +58 -0
  108. package/dist/plan/entry-audit.js +122 -0
  109. package/dist/plan/entry-customer-grants.d.ts +75 -0
  110. package/dist/plan/entry-customer-grants.js +154 -0
  111. package/dist/plan/entry-edge-key.d.ts +26 -1
  112. package/dist/plan/entry-edge-key.js +42 -3
  113. package/dist/plan/entry-jobs.d.ts +64 -0
  114. package/dist/plan/entry-jobs.js +92 -0
  115. package/dist/plan/entry-repo-key-apply.d.ts +55 -0
  116. package/dist/plan/entry-repo-key-apply.js +185 -0
  117. package/dist/plan/entry-repo-key.d.ts +92 -0
  118. package/dist/plan/entry-repo-key.js +235 -0
  119. package/dist/plan/entry-workspaces.d.ts +55 -0
  120. package/dist/plan/entry-workspaces.js +90 -0
  121. package/dist/plan/executor.d.ts +191 -1
  122. package/dist/plan/executor.js +1505 -90
  123. package/dist/plan/gates.d.ts +1 -1
  124. package/dist/plan/gates.js +62 -9
  125. package/dist/plan/github-deploy-keys.d.ts +78 -0
  126. package/dist/plan/github-deploy-keys.js +305 -0
  127. package/dist/plan/github-source.d.ts +53 -0
  128. package/dist/plan/github-source.js +140 -0
  129. package/dist/plan/held-key-refs.d.ts +12 -0
  130. package/dist/plan/held-key-refs.js +12 -0
  131. package/dist/plan/hostnames.d.ts +9 -0
  132. package/dist/plan/hostnames.js +28 -1
  133. package/dist/plan/index.d.ts +9 -0
  134. package/dist/plan/index.js +6 -0
  135. package/dist/plan/jobs-scheduler-http.js +3 -0
  136. package/dist/plan/keys-registrar-http.d.ts +32 -0
  137. package/dist/plan/keys-registrar-http.js +105 -0
  138. package/dist/plan/keys-registrar.d.ts +67 -0
  139. package/dist/plan/keys-registrar.js +103 -0
  140. package/dist/plan/live.d.ts +1 -1
  141. package/dist/plan/locked-hostnames.d.ts +2 -2
  142. package/dist/plan/locked-hostnames.js +2 -2
  143. package/dist/plan/login-host.js +11 -5
  144. package/dist/plan/machine-paths.d.ts +36 -0
  145. package/dist/plan/machine-paths.js +137 -0
  146. package/dist/plan/mcp.d.ts +1 -1
  147. package/dist/plan/mcp.js +49 -17
  148. package/dist/plan/org-apps-admin.d.ts +108 -0
  149. package/dist/plan/org-apps-admin.js +434 -0
  150. package/dist/plan/org-names.js +12 -1
  151. package/dist/plan/own-diff.d.ts +23 -0
  152. package/dist/plan/own-diff.js +53 -0
  153. package/dist/plan/own-hostnames.d.ts +2 -0
  154. package/dist/plan/own-hostnames.js +1 -0
  155. package/dist/plan/own-jobs-route.d.ts +47 -0
  156. package/dist/plan/own-jobs-route.js +91 -0
  157. package/dist/plan/own-services.d.ts +4 -0
  158. package/dist/plan/own-services.js +4 -0
  159. package/dist/plan/plan-apply.d.ts +39 -0
  160. package/dist/plan/plan-apply.js +163 -9
  161. package/dist/plan/platform-diff.d.ts +2 -2
  162. package/dist/plan/platform-diff.js +7 -2
  163. package/dist/plan/platform-org.d.ts +30 -0
  164. package/dist/plan/platform-org.js +12 -1
  165. package/dist/plan/platform-providers.d.ts +2 -0
  166. package/dist/plan/platform-providers.js +7 -2
  167. package/dist/plan/post-logout-uris.js +7 -1
  168. package/dist/plan/private-pages.d.ts +26 -0
  169. package/dist/plan/private-pages.js +39 -0
  170. package/dist/plan/proxy-labels.d.ts +12 -1
  171. package/dist/plan/proxy-labels.js +32 -2
  172. package/dist/plan/release-host.d.ts +6 -2
  173. package/dist/plan/release-host.js +36 -8
  174. package/dist/plan/stage-env.d.ts +19 -1
  175. package/dist/plan/stage-env.js +25 -1
  176. package/dist/plan/stage-guards.d.ts +20 -0
  177. package/dist/plan/stage-guards.js +24 -0
  178. package/dist/plan/stage-isolation.d.ts +24 -0
  179. package/dist/plan/stage-isolation.js +29 -1
  180. package/dist/plan/worker-app-database.d.ts +46 -42
  181. package/dist/plan/worker-app-database.js +150 -153
  182. package/dist/plan/worker-app.d.ts +5 -65
  183. package/dist/plan/worker-app.js +28 -105
  184. package/dist/plan/worker.d.ts +2 -2
  185. package/dist/plan/worker.js +2 -1
  186. package/dist/plan/workspace-client.d.ts +25 -0
  187. package/dist/plan/workspace-client.js +48 -0
  188. package/dist/plan/workspace-sign-in.d.ts +55 -0
  189. package/dist/plan/workspace-sign-in.js +273 -0
  190. package/dist/plan/workspace-worker.d.ts +4 -0
  191. package/dist/plan/workspace-worker.js +13 -0
  192. package/dist/plan/workspace.d.ts +101 -0
  193. package/dist/plan/workspace.js +305 -0
  194. package/dist/release/decide.d.ts +30 -1
  195. package/dist/release/decide.js +73 -11
  196. package/dist/release/health.d.ts +8 -3
  197. package/dist/release/health.js +58 -5
  198. package/dist/release/run.d.ts +2 -5
  199. package/dist/release/run.js +4 -0
  200. package/dist/release/state.d.ts +3 -1
  201. package/dist/release/state.js +6 -0
  202. package/dist/release/types.d.ts +20 -0
  203. package/dist/requested-changes.d.ts +11 -2
  204. package/dist/requested-changes.js +11 -0
  205. package/dist/service-labels.d.ts +4 -0
  206. package/dist/service-labels.js +11 -0
  207. package/dist/slug.d.ts +2 -0
  208. package/dist/slug.js +10 -6
  209. package/dist/store/postgres.js +12 -2
  210. package/dist/store/schema.d.ts +37 -1
  211. package/dist/store/schema.js +4 -0
  212. package/dist/worker-app-config.d.ts +1 -11
  213. package/dist/worker-app-config.js +4 -17
  214. package/dist/worker-app-migrations.d.ts +105 -0
  215. package/dist/worker-app-migrations.js +166 -0
  216. package/dist/worker-app-upload.d.ts +103 -0
  217. package/dist/worker-app-upload.js +128 -0
  218. package/package.json +18 -1
@@ -7,10 +7,11 @@ import { waitForNewDeployment } from './deploy-health.js';
7
7
  import { commandHealthCheckSettings, wgetProbe } from './health-check-settings.js';
8
8
  import { ENTRY_CONTAINER_LABELS } from './proxy-labels.js';
9
9
  import { remoteDatabaseAddress, remoteDatabaseHost } from './remote-database.js';
10
- import { buildStageEnv } from './stage-env.js';
10
+ import { STAGE_SIGN_IN_NAMES, buildStageEnv } from './stage-env.js';
11
11
  import { buildStageProbes, runStageProbes, stageProbesRecord, } from './stage-guards.js';
12
12
  import { assertStageIsolated, envClass, stageIsolationHash, } from './stage-isolation.js';
13
13
  import { STAGE_SINK_APPLIED_SCOPE_KEY, STAGE_SINK_APP_SCOPE_KEY, STAGE_SINK_HTTP_PORT, STAGE_SINK_IMAGE_TAG, STAGE_SINK_REPOSITORY, stageMailEnv, stageSinkAppName, } from './stage-sink.js';
14
+ import { readWorkspaceCommit, wsKey } from './workspace.js';
14
15
  /** How long one Coolify call of the sink may take. */
15
16
  const SINK_CALL_TIMEOUT_MS = 30_000;
16
17
  /** Env names whose value is a credential: registered before they are copied. */
@@ -97,6 +98,30 @@ export async function applyBlueGreenServer(deps, subject, server) {
97
98
  deps.record(bgKey.applied(id, server.uuid), structureHash(subject, { primary: isPrimary }));
98
99
  await deps.persist();
99
100
  }
101
+ /**
102
+ * Applies a server workspace on the primary: the stage mail sink, then the stage app with its own empty
103
+ * database, the stage guards and the gate, exactly as a blue/green stage, and no colors. The commit the stage
104
+ * deployed is recorded as `ws.<id>.commit` for the live app to pin to. A failure halfway leaves the sink, app
105
+ * and database already made, recorded by id; the next run finds them and does the rest. Stage fails closed for
106
+ * itself: a refused guard records `bg.<id>.stagestate` and writes nothing to the stage app.
107
+ */
108
+ export async function applyWorkspaceServer(deps, subject, serverUuid) {
109
+ const sinkReady = await ensureStageSink(deps, subject, serverUuid);
110
+ await ensureStage(deps, subject, serverUuid);
111
+ if (!sinkReady) {
112
+ deps.record(bgKey.stageState(subject.id), 'blocked:sink');
113
+ await deps.persist();
114
+ }
115
+ const stageId = deps.recorded()[bgKey.app(subject.id, serverUuid, 'stage')];
116
+ if (stageId && deps.recorded()[bgKey.stageState(subject.id)] === 'ok') {
117
+ const commit = (await readWorkspaceCommit(deps.coolify, stageId)) ??
118
+ deps.recorded()[bgKey.stage(subject.id)];
119
+ if (commit) {
120
+ deps.record(wsKey.commit(subject.id), commit);
121
+ await deps.persist();
122
+ }
123
+ }
124
+ }
100
125
  /** Writes the balancer's config with the recorded weights and records them for this server. */
101
126
  export async function applyBlueGreenWeights(deps, subject, server) {
102
127
  const weights = parseWeights(deps.recorded()[bgKey.weights(subject.id)]);
@@ -109,15 +134,14 @@ export async function applyBlueGreenWeights(deps, subject, server) {
109
134
  }
110
135
  async function writeLbConfig(deps, subject, server, lbId, weights) {
111
136
  const onPrimary = server.isPrimary;
112
- let pinKey = deps.recorded()[bgKey.pinKey(subject.id)];
113
- if (!pinKey) {
114
- pinKey = randomBytes(32).toString('hex');
115
- deps.record(bgKey.pinKey(subject.id), pinKey);
116
- await deps.persist();
117
- }
137
+ const pinKey = await deps.pinKey();
138
+ // The idle slot is the one `bg.<id>.live` does not name; the preview router follows it on every weight write.
139
+ const idle = deps.recorded()[bgKey.live(subject.id)] === 'green' ? 'blue' : 'green';
118
140
  const content = renderLbConfig({
119
141
  weights,
120
142
  pinKey,
143
+ idle,
144
+ reporting: subject.reporting,
121
145
  backends: {
122
146
  blue: [colorBackend(subject, 'blue', onPrimary)],
123
147
  green: [colorBackend(subject, 'green', onPrimary)],
@@ -211,14 +235,16 @@ async function ensureGitApp(deps, subject, input) {
211
235
  await deps.persist();
212
236
  return found.uuid;
213
237
  }
214
- const githubApp = await coolify.requireGithubApp();
238
+ const githubAppUuid = deps.githubAppUuid
239
+ ? await deps.githubAppUuid(subject.repo)
240
+ : (await coolify.requireGithubApp()).uuid;
215
241
  const created = await coolify.createGithubAppApplication({
216
242
  is_preview_deployments_enabled: false,
217
243
  project_uuid: deps.projectUuid,
218
244
  server_uuid: input.server,
219
245
  environment_name: deps.environment.name,
220
246
  environment_uuid: deps.environment.uuid,
221
- github_app_uuid: githubApp.uuid,
247
+ github_app_uuid: githubAppUuid,
222
248
  git_repository: subject.repo,
223
249
  git_branch: input.branch,
224
250
  build_pack: subject.buildPack,
@@ -319,7 +345,7 @@ async function ensureLoadBalancer(deps, subject, serverUuid, name, isPrimary) {
319
345
  * the sink deploys; a failure clears it, logs fixed text and the error name, and returns false. Production's
320
346
  * structure goes on (stage then holds no mail env and the copy stays refused).
321
347
  */
322
- async function ensureStageSink(deps, subject, serverUuid) {
348
+ export async function ensureStageSink(deps, subject, serverUuid) {
323
349
  const org = deps.stageSink;
324
350
  if (!org)
325
351
  return true;
@@ -377,7 +403,7 @@ async function ensureStageSink(deps, subject, serverUuid) {
377
403
  return true;
378
404
  }
379
405
  /** The stage app and its own empty database, on the primary. Its env comes from the allowlist, never from a copy of the live app's. */
380
- async function ensureStage(deps, subject, serverUuid) {
406
+ export async function ensureStage(deps, subject, serverUuid) {
381
407
  const { coolify } = deps;
382
408
  const name = stageAppName(subject.base);
383
409
  const appId = await stageAppId(deps, subject, name, serverUuid);
@@ -419,12 +445,12 @@ async function ensureStage(deps, subject, serverUuid) {
419
445
  }
420
446
  }
421
447
  const kind = stageKindOf(subject);
422
- const prodEnv = await coolify.listEnvs(deps.primaryBlueAppId);
448
+ const prodEnv = deps.primaryBlueAppId ? await coolify.listEnvs(deps.primaryBlueAppId) : [];
423
449
  const greenId = deps.recorded()[bgKey.app(subject.id, serverUuid, 'green')];
424
450
  const greenEnv = greenId ? await coolify.listEnvs(greenId) : [];
425
451
  const { env } = buildStageEnv({
426
452
  kind,
427
- appUrl: stageAppUrl(subject),
453
+ appUrl: deps.stageAppUrl ?? stageAppUrl(subject),
428
454
  logDir: deps.logDir,
429
455
  database,
430
456
  prod: prodEnv,
@@ -434,8 +460,14 @@ async function ensureStage(deps, subject, serverUuid) {
434
460
  deps.secret(value);
435
461
  return value;
436
462
  },
463
+ ...(deps.signIn ? { signIn: deps.signIn } : {}),
437
464
  });
438
465
  const stageEnv = [...env];
466
+ const signInEnv = deps.signIn ? env.filter((e) => STAGE_SIGN_IN_NAMES.includes(e.key)) : [];
467
+ // A secret kept from an earlier apply was not minted by this run, so it is registered here.
468
+ for (const e of signInEnv)
469
+ if (envClass(e.key) === 'secret')
470
+ deps.secret(e.value);
439
471
  if (subject.reporting && deps.actionsKey) {
440
472
  // Stage never shares a production secret: its key is its own, minted once and kept.
441
473
  const have = (await coolify.listEnvs(appId)).find((e) => e.key === ACTIONS_KEY_ENV && !e.is_preview);
@@ -447,7 +479,7 @@ async function ensureStage(deps, subject, serverUuid) {
447
479
  const sinkApplied = deps.stageSink && deps.recorded()[STAGE_SINK_APPLIED_SCOPE_KEY];
448
480
  const mail = deps.stageSink && sinkApplied ? stageMailEnv(deps.stageSink) : [];
449
481
  const stageHas = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
450
- const mailChanged = mail.some((e) => stageHas.get(e.key) !== e.value);
482
+ const mailChanged = [...mail, ...signInEnv].some((e) => stageHas.get(e.key) !== e.value);
451
483
  stageEnv.push(...mail);
452
484
  const stageSurface = {
453
485
  env: stageEnv.map((e) => ({ name: e.key, class: envClass(e.key), value: e.value })),
@@ -464,6 +496,9 @@ async function ensureStage(deps, subject, serverUuid) {
464
496
  },
465
497
  stage: stageSurface,
466
498
  sinkMail: Object.fromEntries(mail.map((e) => [e.key, e.value])),
499
+ ...(deps.signIn
500
+ ? { signIn: { customers: deps.signIn.customers, live: deps.signIn.live } }
501
+ : {}),
467
502
  });
468
503
  if (violations.length > 0) {
469
504
  // Stage fails closed for itself: nothing is written to it, production's structure goes on.
@@ -483,7 +518,12 @@ async function ensureStage(deps, subject, serverUuid) {
483
518
  probeResult = await runStageProbes(buildStageProbes({
484
519
  port: deps.stageGuards?.probes,
485
520
  stage: stageValues,
486
- database: subject.database,
521
+ // With no live app there is no production database to try the stage role against.
522
+ database: subject.database && deps.primaryBlueAppId !== null,
523
+ ...(deps.signIn?.live.customersOrgId
524
+ ? { liveCustomersOrgId: deps.signIn.live.customersOrgId }
525
+ : {}),
526
+ ...(deps.signIn ? { signInRedirectUri: deps.signIn.redirectUri } : {}),
487
527
  prod: new Map(prodEnv
488
528
  .filter((e) => !e.is_preview && e.value !== undefined)
489
529
  .map((e) => [e.key, e.value])),
@@ -46,7 +46,10 @@ export declare const bgKey: {
46
46
  stageState: (id: string) => string;
47
47
  /** The Coolify name of the blue app on the primary: the adopted single app's name, or `<base>-blue`. */
48
48
  blueName: (id: string) => string;
49
- /** The pin key of the balancer's header routers. Not a credential: it only pins a color. */
49
+ /**
50
+ * The held ref of the balancer's pin key (a credential: it lets a caller pick a color). A value that is 64 hex
51
+ * characters is the older recorded key, moved to the held ref on the next read and then overwritten by the ref.
52
+ */
50
53
  pinKey: (id: string) => string;
51
54
  /** The hash of the stage env name set and resource set that passed the isolation check. */
52
55
  stageIsolation: (id: string) => string;
@@ -91,6 +94,34 @@ export declare function mintActionsKey(input: {
91
94
  /** Registers the value for redaction before it is written anywhere. */
92
95
  secret: (value: string) => void;
93
96
  }): Promise<string>;
97
+ /** The held-key name of a subject's balancer pin key: one mint per subject. */
98
+ export declare const pinKeyName: (id: string) => string;
99
+ /** The held store the pin key lives in; `put` is absent on a host that cannot mint. */
100
+ export interface PinHeldKeys {
101
+ resolve(ref: string): Promise<string | null | undefined>;
102
+ put?(ref: string, value: string): Promise<void>;
103
+ }
104
+ interface PinKeyInput {
105
+ id: string;
106
+ ref: string;
107
+ recorded: () => Readonly<Record<string, string>>;
108
+ record: (key: string, value: string) => void;
109
+ persist: () => Promise<void>;
110
+ heldKeys: PinHeldKeys;
111
+ /** Registers the value for redaction before it is written anywhere. */
112
+ secret: (value: string) => void;
113
+ }
114
+ /**
115
+ * The balancer's pin key, held (CLAUDE.md, Platform steps, rules 1 and 6). Order: the held key; else the older
116
+ * recorded key (64 hex), put in the held store and replaced by the ref; else one mint, put first, then the ref is
117
+ * recorded and persisted. A crash between the put and the record reuses the held key. A recorded ref whose key is
118
+ * no longer held stops: it never mints again. Errors name the ref only.
119
+ */
120
+ export declare function mintPinKey(input: PinKeyInput & {
121
+ generate: () => string;
122
+ }): Promise<string>;
123
+ /** Reads the pin key without minting: the held key, else the older recorded key, else null. */
124
+ export declare function readPinKey(input: Omit<PinKeyInput, 'record' | 'persist'>): Promise<string | null>;
94
125
  /** The held-key name of an entry's health token (`sharedSecretRef(org.id, name)`): one mint per entry. */
95
126
  export declare const healthTokenName: (id: string) => string;
96
127
  /** What a color's container gets for the reporting health read. The release is left out until a digest is known. */
@@ -200,6 +231,10 @@ export declare const LB_COOKIE_NAME = "bg_color";
200
231
  export declare const LB_COLOR_HEADER = "X-Foundry-Color";
201
232
  /** A pinned request also carries this header with the entry's pin key, so a stranger cannot pin a color. */
202
233
  export declare const LB_PIN_HEADER = "X-Foundry-Pin";
234
+ /** The cookie that sends a signed-in person with `releases:preview` to the idle slot. */
235
+ export declare const LB_PREVIEW_COOKIE = "bg_preview";
236
+ /** The path the balancer asks the live slot whether a request may see the idle slot. */
237
+ export declare const LB_PREVIEW_CHECK_PATH = "/api/foundry/preview-check";
203
238
  export declare const LB_CONFIG_PATH = "/etc/traefik/dynamic/lb.yml";
204
239
  /**
205
240
  * The balancer's inline Dockerfile: a pinned Traefik with a file provider watching one directory.
@@ -218,6 +253,10 @@ export declare function renderLbConfig(input: {
218
253
  weights: Weights;
219
254
  backends: Record<Color, string[]>;
220
255
  pinKey: string;
256
+ /** The slot that is not live: the `preview` router sends a cookie holder here. */
257
+ idle: Color;
258
+ /** True when the subject has a server that can answer the permission check; a static subject gets no router. */
259
+ reporting: boolean;
221
260
  }): string;
222
261
  /**
223
262
  * The URL of one color's container on its own server's network. On the primary, blue is the app that was
@@ -324,3 +363,4 @@ export declare function planArchonBlueGreen(input: {
324
363
  step: BlueGreenPlanStep | null;
325
364
  unmanaged: UnmanagedLine[];
326
365
  };
366
+ export {};
@@ -55,7 +55,10 @@ export const bgKey = {
55
55
  stageState: (id) => `bg.${id}.stagestate`,
56
56
  /** The Coolify name of the blue app on the primary: the adopted single app's name, or `<base>-blue`. */
57
57
  blueName: (id) => `bg.${id}.bluename`,
58
- /** The pin key of the balancer's header routers. Not a credential: it only pins a color. */
58
+ /**
59
+ * The held ref of the balancer's pin key (a credential: it lets a caller pick a color). A value that is 64 hex
60
+ * characters is the older recorded key, moved to the held ref on the next read and then overwritten by the ref.
61
+ */
59
62
  pinKey: (id) => `bg.${id}.pinkey`,
60
63
  /** The hash of the stage env name set and resource set that passed the isolation check. */
61
64
  stageIsolation: (id) => `bg.${id}.stageisolation`,
@@ -102,6 +105,56 @@ export async function mintActionsKey(input) {
102
105
  await input.persist();
103
106
  return key;
104
107
  }
108
+ /** The held-key name of a subject's balancer pin key: one mint per subject. */
109
+ export const pinKeyName = (id) => `bg-pin-${id}`;
110
+ /**
111
+ * The balancer's pin key, held (CLAUDE.md, Platform steps, rules 1 and 6). Order: the held key; else the older
112
+ * recorded key (64 hex), put in the held store and replaced by the ref; else one mint, put first, then the ref is
113
+ * recorded and persisted. A crash between the put and the record reuses the held key. A recorded ref whose key is
114
+ * no longer held stops: it never mints again. Errors name the ref only.
115
+ */
116
+ export async function mintPinKey(input) {
117
+ const held = await input.heldKeys.resolve(input.ref);
118
+ const recordedValue = input.recorded()[bgKey.pinKey(input.id)];
119
+ if (held) {
120
+ input.secret(held);
121
+ if (recordedValue !== input.ref) {
122
+ input.record(bgKey.pinKey(input.id), input.ref);
123
+ await input.persist();
124
+ }
125
+ return held;
126
+ }
127
+ const put = input.heldKeys.put?.bind(input.heldKeys);
128
+ if (!put)
129
+ throw new Error(`pin key ${input.ref} cannot be held: the host has no held store to write`);
130
+ if (recordedValue && PIN_KEY.test(recordedValue)) {
131
+ input.secret(recordedValue);
132
+ await put(input.ref, recordedValue);
133
+ input.record(bgKey.pinKey(input.id), input.ref);
134
+ await input.persist();
135
+ return recordedValue;
136
+ }
137
+ if (recordedValue) {
138
+ throw new Error(`pin key ${input.ref} was minted but is no longer held; restore the held value`);
139
+ }
140
+ const key = input.generate();
141
+ input.secret(key);
142
+ await put(input.ref, key);
143
+ input.record(bgKey.pinKey(input.id), input.ref);
144
+ await input.persist();
145
+ return key;
146
+ }
147
+ /** Reads the pin key without minting: the held key, else the older recorded key, else null. */
148
+ export async function readPinKey(input) {
149
+ const held = await input.heldKeys.resolve(input.ref);
150
+ const value = held ||
151
+ (PIN_KEY.test(input.recorded()[bgKey.pinKey(input.id)] ?? '')
152
+ ? input.recorded()[bgKey.pinKey(input.id)]
153
+ : null);
154
+ if (value)
155
+ input.secret(value);
156
+ return value ?? null;
157
+ }
105
158
  /** The held-key name of an entry's health token (`sharedSecretRef(org.id, name)`): one mint per entry. */
106
159
  export const healthTokenName = (id) => `reporting-health-${id}`;
107
160
  /** What a color's container gets for the reporting health read. The release is left out until a digest is known. */
@@ -246,6 +299,11 @@ export const LB_COLOR_HEADER = 'X-Foundry-Color';
246
299
  /** A pinned request also carries this header with the entry's pin key, so a stranger cannot pin a color. */
247
300
  export const LB_PIN_HEADER = 'X-Foundry-Pin';
248
301
  const PIN_KEY = /^[0-9a-f]{64}$/;
302
+ /** The cookie that sends a signed-in person with `releases:preview` to the idle slot. */
303
+ export const LB_PREVIEW_COOKIE = 'bg_preview';
304
+ /** The path the balancer asks the live slot whether a request may see the idle slot. */
305
+ export const LB_PREVIEW_CHECK_PATH = '/api/foundry/preview-check';
306
+ const LB_PREVIEW_PRIORITY = 9000;
249
307
  /** Above the default priority of `app` (its rule length), so a pinned request never reaches the weighted service. */
250
308
  const LB_PIN_PRIORITY = 10000;
251
309
  export const LB_CONFIG_PATH = '/etc/traefik/dynamic/lb.yml';
@@ -272,7 +330,8 @@ export const LB_IMAGE_REF = `traefik:${VERSIONS.traefik}`;
272
330
  * line, so a reader sees both numbers. `backends` are the URLs of each color's containers, one per server.
273
331
  */
274
332
  export function renderLbConfig(input) {
275
- const { weights, backends, pinKey } = input;
333
+ const { weights, backends, pinKey, idle } = input;
334
+ const live = COLORS.find((color) => color !== idle);
276
335
  if (!PIN_KEY.test(pinKey))
277
336
  throw new Error('the load balancer pin key is not 64 hex characters');
278
337
  const listed = COLORS.filter((color) => weights[color] > 0);
@@ -287,10 +346,29 @@ export function renderLbConfig(input) {
287
346
  ` entryPoints: [${LB_ENTRYPOINT}]`,
288
347
  ` service: ${color}`,
289
348
  ]),
349
+ ...(input.reporting
350
+ ? [
351
+ ' preview:',
352
+ ` rule: HeaderRegexp(\`Cookie\`, \`(^|;\\s*)${LB_PREVIEW_COOKIE}=1\`)`,
353
+ ` priority: ${LB_PREVIEW_PRIORITY}`,
354
+ ` entryPoints: [${LB_ENTRYPOINT}]`,
355
+ ` service: ${idle}`,
356
+ ' middlewares: [preview-check]',
357
+ ]
358
+ : []),
290
359
  ' app:',
291
360
  ' rule: PathPrefix(`/`)',
292
361
  ` entryPoints: [${LB_ENTRYPOINT}]`,
293
362
  ' service: weighted',
363
+ ...(input.reporting
364
+ ? [
365
+ ' middlewares:',
366
+ ' preview-check:',
367
+ ' forwardAuth:',
368
+ ` address: ${backends[live][0]}${LB_PREVIEW_CHECK_PATH}`,
369
+ ' trustForwardHeader: false',
370
+ ]
371
+ : []),
294
372
  ' services:',
295
373
  ' weighted:',
296
374
  ' weighted:',
@@ -91,6 +91,12 @@ export declare const BOULE_ARCHON_CREDENTIAL_ENV = "ARCHON_CREDENTIAL";
91
91
  /** The company's customers organisation in the sign-in service, and the read-only credential that lists its people (C3). */
92
92
  export declare const BOULE_CUSTOMERS_ORG_ENV = "CUSTOMERS_ORGANISATION_ID";
93
93
  export declare const BOULE_CUSTOMERS_CREDENTIAL_ENV = "CUSTOMERS_DIRECTORY_CREDENTIAL";
94
+ /**
95
+ * F5 (Q44): the company's test customers organisation, beside {@link BOULE_CUSTOMERS_ORG_ENV}, so a workspace copy
96
+ * of an app registers its sign-in against it. Written once the organisation is recorded (a server workspace with
97
+ * customers makes it); the plan plans Boule again then, as it does for the customers organisation.
98
+ */
99
+ export declare const BOULE_TEST_CUSTOMERS_ORG_ENV = "TEST_CUSTOMERS_ORGANISATION_ID";
94
100
  /**
95
101
  * When the Archon credential was last minted and held (ISO date-time). No secret. Archon's mint rotates
96
102
  * on every call and the credential lives 365 days, so foundry mints only when none was pushed or the
@@ -130,9 +136,25 @@ export interface BouleCredentialWants {
130
136
  customersDirectory?: boolean;
131
137
  /** The recorded Archon credential is missing or 335 days old: the step mints it again. */
132
138
  archonRenewDue?: boolean;
139
+ /** F13: Boule is given `CUSTOMER_GRANTS` (an entry declares `uses.customerGrants`; needs `customersDirectory`). */
140
+ customerGrants?: boolean;
141
+ /** T3: Boule is given its company's registrar key at the sign-in broker (`plan/broker-registrar.ts`). */
142
+ registrar?: boolean;
143
+ /** T3b: customers are off but the broker still holds the record, so Boule keeps its registrar env (plan only). */
144
+ registrarKept?: boolean;
133
145
  }
146
+ /** T3: the broker's base and the company's registrar key, in Boule's env (`plan/broker-registrar.ts`). */
147
+ export declare const AUTH_REGISTRAR_URL_ENV = "AUTH_REGISTRAR_URL";
148
+ export declare const AUTH_REGISTRAR_KEY_ENV = "AUTH_REGISTRAR_KEY";
149
+ /**
150
+ * A company gets a registrar while it has customers (the people its own apps sign in) or its Boule runs apps
151
+ * (F5: any company that installs an app with sign-in, `org.boule.apps`).
152
+ */
153
+ export declare const registrarWanted: (org: Pick<DeclaredOrg, "customers" | "boule">) => boolean;
154
+ /** The broker's base as Boule calls it: `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`. */
155
+ export declare const registrarUrl: (issuer: string) => string;
134
156
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
135
- 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", "SIGNIN_UPSTREAM"];
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"];
136
158
  /**
137
159
  * The env vars an org's Boule app should carry, and every input still missing
138
160
  * to build them. Callers that write must refuse on a non-empty `missing`: a
@@ -1,6 +1,7 @@
1
- import { DEFAULT_NO_REPLY_LOCAL_PART, ownerMailboxes, } from '../org.js';
1
+ import { DEFAULT_NO_REPLY_LOCAL_PART, appsOn, ownerMailboxes, } from '../org.js';
2
2
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
3
  import { SIGN_IN_UPSTREAM_ENV, inAppHostApplied, signInUpstream, staleSignInUpstream, } from './sign-in.js';
4
+ import { testCustomersOrgKey } from './workspace-client.js';
4
5
  /**
5
6
  * #161 (Boule part): what both the planner (`diff.ts`) and the executor
6
7
  * (`executor.ts`) need to agree on for an org's Boule -- the hostname, the
@@ -102,6 +103,12 @@ export const BOULE_ARCHON_CREDENTIAL_ENV = 'ARCHON_CREDENTIAL';
102
103
  /** The company's customers organisation in the sign-in service, and the read-only credential that lists its people (C3). */
103
104
  export const BOULE_CUSTOMERS_ORG_ENV = 'CUSTOMERS_ORGANISATION_ID';
104
105
  export const BOULE_CUSTOMERS_CREDENTIAL_ENV = 'CUSTOMERS_DIRECTORY_CREDENTIAL';
106
+ /**
107
+ * F5 (Q44): the company's test customers organisation, beside {@link BOULE_CUSTOMERS_ORG_ENV}, so a workspace copy
108
+ * of an app registers its sign-in against it. Written once the organisation is recorded (a server workspace with
109
+ * customers makes it); the plan plans Boule again then, as it does for the customers organisation.
110
+ */
111
+ export const BOULE_TEST_CUSTOMERS_ORG_ENV = 'TEST_CUSTOMERS_ORGANISATION_ID';
105
112
  /**
106
113
  * When the Archon credential was last minted and held (ISO date-time). No secret. Archon's mint rotates
107
114
  * on every call and the credential lives 365 days, so foundry mints only when none was pushed or the
@@ -144,6 +151,16 @@ export const bouleArchonCredentialRef = (orgId) => `foundry:boule-archon:${orgId
144
151
  export const customersDirectoryRef = (orgId) => `foundry:customers-directory:${orgId}`;
145
152
  /** The customers organisation id `zitadel:customers` recorded. */
146
153
  export const customersOrgIdOf = (org, recorded) => recorded[`zitadel:customers:${org.id}`] || undefined;
154
+ /** T3: the broker's base and the company's registrar key, in Boule's env (`plan/broker-registrar.ts`). */
155
+ export const AUTH_REGISTRAR_URL_ENV = 'AUTH_REGISTRAR_URL';
156
+ export const AUTH_REGISTRAR_KEY_ENV = 'AUTH_REGISTRAR_KEY';
157
+ /**
158
+ * A company gets a registrar while it has customers (the people its own apps sign in) or its Boule runs apps
159
+ * (F5: any company that installs an app with sign-in, `org.boule.apps`).
160
+ */
161
+ export const registrarWanted = (org) => org.customers?.enabled === true || appsOn(org);
162
+ /** The broker's base as Boule calls it: `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`. */
163
+ export const registrarUrl = (issuer) => `${issuer.replace(/\/+$/, '')}/api`;
147
164
  /** The env names `desiredBouleEnv` can write, so the live reader knows which to read back. */
148
165
  export const BOULE_MANAGED_ENV_KEYS = [
149
166
  'APP_URL',
@@ -163,7 +180,11 @@ export const BOULE_MANAGED_ENV_KEYS = [
163
180
  BOULE_ARCHON_CREDENTIAL_ENV,
164
181
  BOULE_CUSTOMERS_ORG_ENV,
165
182
  BOULE_CUSTOMERS_CREDENTIAL_ENV,
183
+ BOULE_TEST_CUSTOMERS_ORG_ENV,
184
+ 'CUSTOMER_GRANTS',
166
185
  SIGN_IN_UPSTREAM_ENV,
186
+ AUTH_REGISTRAR_URL_ENV,
187
+ AUTH_REGISTRAR_KEY_ENV,
167
188
  ];
168
189
  /**
169
190
  * The env vars an org's Boule app should carry, and every input still missing
@@ -218,6 +239,14 @@ export function desiredBouleEnv(org, recorded, issuer, archonUrl, mailWanted = f
218
239
  const customersOrgId = wants.customersDirectory ? customersOrgIdOf(org, recorded) : undefined;
219
240
  if (customersOrgId)
220
241
  env[BOULE_CUSTOMERS_ORG_ENV] = customersOrgId;
242
+ const testCustomersOrgId = wants.customersDirectory
243
+ ? recorded[testCustomersOrgKey(org.id)]
244
+ : undefined;
245
+ if (testCustomersOrgId)
246
+ env[BOULE_TEST_CUSTOMERS_ORG_ENV] = testCustomersOrgId;
247
+ // The key itself is a secret the executor writes; the plan compares it by presence (`bouleRegistrarDrift`).
248
+ if (wants.registrar && issuer)
249
+ env[AUTH_REGISTRAR_URL_ENV] = registrarUrl(issuer);
221
250
  const owner = org.owners[0];
222
251
  const ownerUserId = owner
223
252
  ? recorded[`zitadel:owner:${org.id}:${owner.email.toLowerCase()}`]
@@ -355,5 +384,9 @@ export function planBouleOwners(org, recorded, liveBouleEnv) {
355
384
  export function staleCustomersEnv(org, live) {
356
385
  if (org.customers?.enabled)
357
386
  return [];
358
- return [BOULE_CUSTOMERS_ORG_ENV, BOULE_CUSTOMERS_CREDENTIAL_ENV].filter((key) => live[key] !== undefined);
387
+ return [
388
+ BOULE_CUSTOMERS_ORG_ENV,
389
+ BOULE_TEST_CUSTOMERS_ORG_ENV,
390
+ BOULE_CUSTOMERS_CREDENTIAL_ENV,
391
+ ].filter((key) => live[key] !== undefined);
359
392
  }
@@ -1,5 +1,5 @@
1
1
  import { type DeclaredOrg, type HostingEntry } from '../org.js';
2
- import type { DeclaredOrgStore, PlatformStore } from '../store.js';
2
+ import type { DeclaredOrgStore, HeldLease, MutationLease, PlatformStore } from '../store.js';
3
3
  import type { CoolifyPort } from './executor.js';
4
4
  /**
5
5
  * F5 (https://github.com/wtfalch/foundry/issues/314): an entry with
@@ -62,6 +62,11 @@ export interface BrokerEntryWant {
62
62
  loginHost: string;
63
63
  onboardingUrl?: string;
64
64
  };
65
+ /** Present only for an entry with `uses.auth.workspaces`. The key's hash is not part of it: see {@link writeBrokerEntry}. */
66
+ workspaces?: {
67
+ roles: string[];
68
+ inviteUrl: string;
69
+ };
65
70
  /**
66
71
  * `true` for `signIn: 'email-code'`, `false` for an entry that declares `signIn: 'password'`.
67
72
  * Absent when the entry declares no `signIn`: a hand-set `emailLink` stays as it is.
@@ -95,10 +100,22 @@ export interface BrokerPort {
95
100
  is_preview: boolean;
96
101
  }>>;
97
102
  setAuthApps(raw: string): Promise<void>;
103
+ /** Sets one env of the broker app. Needed only to give the broker its `WORKSPACES_DATABASE_URL`. */
104
+ setEnv?(key: string, value: string): Promise<void>;
98
105
  deploy(): Promise<void>;
99
106
  /** The platform's `PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY` value, if any. */
100
107
  recordedDeploy(): Promise<string | undefined>;
101
- recordDeploy(value: string): Promise<void>;
108
+ /** With `fence` (a `platform` lease the caller holds, from {@link withPlatformLease}) the write carries it and takes no lease itself. */
109
+ recordDeploy(value: string, fence?: MutationLease): Promise<void>;
110
+ /**
111
+ * T3b: runs `fn` holding the `platform` mutation lease, so two companies' applies never read, change and
112
+ * write a shared broker env (`AUTH_REGISTRARS`) at once. `fn` gets the lease (undefined for a port with
113
+ * no lease store), which its `recordDeploy` carries. Returns {@link HeldLease} while another holder has
114
+ * it, and `fn` does not run. Optional: a port without it writes unleased.
115
+ */
116
+ withPlatformLease?<T>(fn: (fence: MutationLease | undefined) => Promise<T>): Promise<{
117
+ value: T;
118
+ } | HeldLease>;
102
119
  /**
103
120
  * The id of Archon's own sign-in organisation, when the platform has made it.
104
121
  * No other entry may sign in that organisation, so an entry that does is
@@ -129,10 +146,14 @@ export declare class PlatformBrokerPort implements BrokerPort {
129
146
  is_preview: boolean;
130
147
  }[]>;
131
148
  setAuthApps(raw: string): Promise<void>;
149
+ setEnv(key: string, value: string): Promise<void>;
132
150
  deploy(): Promise<void>;
133
151
  recordedDeploy(): Promise<string | undefined>;
134
152
  archonOrganisationId(): Promise<string | undefined>;
135
- recordDeploy(value: string): Promise<void>;
153
+ withPlatformLease<T>(fn: (fence: MutationLease | undefined) => Promise<T>): Promise<{
154
+ value: T;
155
+ } | HeldLease>;
156
+ recordDeploy(value: string, fence?: MutationLease): Promise<void>;
136
157
  }
137
158
  /**
138
159
  * Writes one entry's broker entry beside the others and deploys the broker
@@ -149,6 +170,10 @@ export declare function writeBrokerEntry(broker: BrokerPort, input: {
149
170
  want: BrokerEntryWant;
150
171
  appKey: string;
151
172
  previous: BrokerEntryWant | null;
173
+ /** sha256 hex of the entry's workspaces key. Required with `want.workspaces`; the key itself never reaches here. */
174
+ workspacesKeyHash?: string;
175
+ /** The operator's held connection string for the broker's workspaces database, written to the broker when it differs. */
176
+ workspacesDatabaseUrl?: string;
152
177
  }): Promise<{
153
178
  invalidForeign: string[];
154
179
  replaced: string | null;