@cotal-ai/cli 0.50.1 → 0.52.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 (179) hide show
  1. package/dist/command.d.ts.map +1 -1
  2. package/dist/command.js +32 -1
  3. package/dist/command.js.map +1 -1
  4. package/dist/commands/agents.d.ts +41 -2
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +85 -19
  7. package/dist/commands/agents.js.map +1 -1
  8. package/dist/commands/clean.d.ts.map +1 -1
  9. package/dist/commands/clean.js +5 -1
  10. package/dist/commands/clean.js.map +1 -1
  11. package/dist/commands/console.d.ts.map +1 -1
  12. package/dist/commands/console.js +9 -1
  13. package/dist/commands/console.js.map +1 -1
  14. package/dist/commands/doctor.d.ts.map +1 -1
  15. package/dist/commands/doctor.js +13 -8
  16. package/dist/commands/doctor.js.map +1 -1
  17. package/dist/commands/down.d.ts.map +1 -1
  18. package/dist/commands/down.js +151 -7
  19. package/dist/commands/down.js.map +1 -1
  20. package/dist/commands/endpoints.d.ts.map +1 -1
  21. package/dist/commands/endpoints.js +2 -1
  22. package/dist/commands/endpoints.js.map +1 -1
  23. package/dist/commands/join.d.ts.map +1 -1
  24. package/dist/commands/join.js +25 -0
  25. package/dist/commands/join.js.map +1 -1
  26. package/dist/commands/meshes-add.d.ts +2 -54
  27. package/dist/commands/meshes-add.d.ts.map +1 -1
  28. package/dist/commands/meshes-add.js +20 -105
  29. package/dist/commands/meshes-add.js.map +1 -1
  30. package/dist/commands/meshes.d.ts.map +1 -1
  31. package/dist/commands/meshes.js +5 -2
  32. package/dist/commands/meshes.js.map +1 -1
  33. package/dist/commands/service.d.ts +17 -0
  34. package/dist/commands/service.d.ts.map +1 -0
  35. package/dist/commands/service.js +541 -0
  36. package/dist/commands/service.js.map +1 -0
  37. package/dist/commands/setup.d.ts.map +1 -1
  38. package/dist/commands/setup.js +9 -8
  39. package/dist/commands/setup.js.map +1 -1
  40. package/dist/commands/spawn.d.ts +44 -2
  41. package/dist/commands/spawn.d.ts.map +1 -1
  42. package/dist/commands/spawn.js +310 -47
  43. package/dist/commands/spawn.js.map +1 -1
  44. package/dist/commands/status.d.ts.map +1 -1
  45. package/dist/commands/status.js +133 -39
  46. package/dist/commands/status.js.map +1 -1
  47. package/dist/commands/sync.d.ts +50 -0
  48. package/dist/commands/sync.d.ts.map +1 -0
  49. package/dist/commands/sync.js +284 -0
  50. package/dist/commands/sync.js.map +1 -0
  51. package/dist/commands/up.d.ts +7 -0
  52. package/dist/commands/up.d.ts.map +1 -1
  53. package/dist/commands/up.js +174 -23
  54. package/dist/commands/up.js.map +1 -1
  55. package/dist/commands/update.d.ts +12 -0
  56. package/dist/commands/update.d.ts.map +1 -1
  57. package/dist/commands/update.js +83 -9
  58. package/dist/commands/update.js.map +1 -1
  59. package/dist/commands/use.js +1 -1
  60. package/dist/commands/use.js.map +1 -1
  61. package/dist/console/app.d.ts +14 -6
  62. package/dist/console/app.d.ts.map +1 -1
  63. package/dist/console/app.js +308 -40
  64. package/dist/console/app.js.map +1 -1
  65. package/dist/console/commands.d.ts +29 -1
  66. package/dist/console/commands.d.ts.map +1 -1
  67. package/dist/console/commands.js +109 -13
  68. package/dist/console/commands.js.map +1 -1
  69. package/dist/console/control.d.ts +141 -0
  70. package/dist/console/control.d.ts.map +1 -0
  71. package/dist/console/control.js +189 -0
  72. package/dist/console/control.js.map +1 -0
  73. package/dist/console/mesh.d.ts +1 -1
  74. package/dist/console/mesh.d.ts.map +1 -1
  75. package/dist/console/root.d.ts +13 -1
  76. package/dist/console/root.d.ts.map +1 -1
  77. package/dist/console/root.js +25 -5
  78. package/dist/console/root.js.map +1 -1
  79. package/dist/console/ui/CommandPalette.d.ts +2 -1
  80. package/dist/console/ui/CommandPalette.d.ts.map +1 -1
  81. package/dist/console/ui/CommandPalette.js +3 -2
  82. package/dist/console/ui/CommandPalette.js.map +1 -1
  83. package/dist/console/ui/Confirm.d.ts +13 -4
  84. package/dist/console/ui/Confirm.d.ts.map +1 -1
  85. package/dist/console/ui/Confirm.js +13 -5
  86. package/dist/console/ui/Confirm.js.map +1 -1
  87. package/dist/console/ui/Detail.d.ts +8 -1
  88. package/dist/console/ui/Detail.d.ts.map +1 -1
  89. package/dist/console/ui/Detail.js +11 -4
  90. package/dist/console/ui/Detail.js.map +1 -1
  91. package/dist/console/ui/Help.d.ts.map +1 -1
  92. package/dist/console/ui/Help.js +3 -2
  93. package/dist/console/ui/Help.js.map +1 -1
  94. package/dist/console/ui/Roster.d.ts +9 -1
  95. package/dist/console/ui/Roster.d.ts.map +1 -1
  96. package/dist/console/ui/Roster.js +16 -5
  97. package/dist/console/ui/Roster.js.map +1 -1
  98. package/dist/console/ui/Sparkline.d.ts +13 -0
  99. package/dist/console/ui/Sparkline.d.ts.map +1 -0
  100. package/dist/console/ui/Sparkline.js +21 -0
  101. package/dist/console/ui/Sparkline.js.map +1 -0
  102. package/dist/console/ui/StatusBar.d.ts +4 -1
  103. package/dist/console/ui/StatusBar.d.ts.map +1 -1
  104. package/dist/console/ui/StatusBar.js +5 -3
  105. package/dist/console/ui/StatusBar.js.map +1 -1
  106. package/dist/console/ui/Tabs.d.ts +4 -2
  107. package/dist/console/ui/Tabs.d.ts.map +1 -1
  108. package/dist/console/ui/Tabs.js +5 -3
  109. package/dist/console/ui/Tabs.js.map +1 -1
  110. package/dist/console/ui/topo/Map.d.ts.map +1 -1
  111. package/dist/console/ui/topo/Map.js +34 -2
  112. package/dist/console/ui/topo/Map.js.map +1 -1
  113. package/dist/console/ui/topo/Matrix.d.ts.map +1 -1
  114. package/dist/console/ui/topo/Matrix.js +12 -3
  115. package/dist/console/ui/topo/Matrix.js.map +1 -1
  116. package/dist/console/ui/topo/Sequence.js +1 -1
  117. package/dist/console/ui/topo/Sequence.js.map +1 -1
  118. package/dist/console/ui/topo/Topo.d.ts +10 -2
  119. package/dist/console/ui/topo/Topo.d.ts.map +1 -1
  120. package/dist/console/ui/topo/Topo.js +6 -4
  121. package/dist/console/ui/topo/Topo.js.map +1 -1
  122. package/dist/console/ui/topo/model.d.ts +50 -2
  123. package/dist/console/ui/topo/model.d.ts.map +1 -1
  124. package/dist/console/ui/topo/model.js +69 -6
  125. package/dist/console/ui/topo/model.js.map +1 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +27 -0
  128. package/dist/index.js.map +1 -1
  129. package/dist/lib/attach-client.d.ts.map +1 -1
  130. package/dist/lib/attach-client.js +7 -1
  131. package/dist/lib/attach-client.js.map +1 -1
  132. package/dist/lib/delivery-proc.d.ts +10 -2
  133. package/dist/lib/delivery-proc.d.ts.map +1 -1
  134. package/dist/lib/delivery-proc.js +7 -1
  135. package/dist/lib/delivery-proc.js.map +1 -1
  136. package/dist/lib/manifest/apply.d.ts.map +1 -1
  137. package/dist/lib/manifest/apply.js +2 -0
  138. package/dist/lib/manifest/apply.js.map +1 -1
  139. package/dist/lib/manifest/model.d.ts +2 -0
  140. package/dist/lib/manifest/model.d.ts.map +1 -1
  141. package/dist/lib/manifest/prepare.d.ts +2 -0
  142. package/dist/lib/manifest/prepare.d.ts.map +1 -1
  143. package/dist/lib/manifest/prepare.js +1 -0
  144. package/dist/lib/manifest/prepare.js.map +1 -1
  145. package/dist/lib/manifest/resolve.js +1 -0
  146. package/dist/lib/manifest/resolve.js.map +1 -1
  147. package/dist/lib/manifest/schema.d.ts +2 -0
  148. package/dist/lib/manifest/schema.d.ts.map +1 -1
  149. package/dist/lib/manifest/schema.js +2 -0
  150. package/dist/lib/manifest/schema.js.map +1 -1
  151. package/dist/lib/restore.d.ts +3 -0
  152. package/dist/lib/restore.d.ts.map +1 -1
  153. package/dist/lib/restore.js +17 -0
  154. package/dist/lib/restore.js.map +1 -1
  155. package/dist/lib/seat-admission.d.ts +51 -0
  156. package/dist/lib/seat-admission.d.ts.map +1 -0
  157. package/dist/lib/seat-admission.js +81 -0
  158. package/dist/lib/seat-admission.js.map +1 -0
  159. package/dist/lib/seat-capture.d.ts +47 -0
  160. package/dist/lib/seat-capture.d.ts.map +1 -0
  161. package/dist/lib/seat-capture.js +195 -0
  162. package/dist/lib/seat-capture.js.map +1 -0
  163. package/dist/lib/seat-restore.d.ts +39 -0
  164. package/dist/lib/seat-restore.d.ts.map +1 -0
  165. package/dist/lib/seat-restore.js +249 -0
  166. package/dist/lib/seat-restore.js.map +1 -0
  167. package/dist/lib/seat-resume.d.ts +24 -0
  168. package/dist/lib/seat-resume.d.ts.map +1 -0
  169. package/dist/lib/seat-resume.js +127 -0
  170. package/dist/lib/seat-resume.js.map +1 -0
  171. package/dist/view/mesh-view.d.ts +41 -1
  172. package/dist/view/mesh-view.d.ts.map +1 -1
  173. package/dist/view/mesh-view.js +107 -8
  174. package/dist/view/mesh-view.js.map +1 -1
  175. package/package.json +14 -7
  176. package/dist/lib/join-target.d.ts +0 -173
  177. package/dist/lib/join-target.d.ts.map +0 -1
  178. package/dist/lib/join-target.js +0 -335
  179. package/dist/lib/join-target.js.map +0 -1
@@ -1,15 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { createConnection, createServer } from "node:net";
4
4
  import { hostname } from "node:os";
5
5
  import { mkdirSync, writeFileSync, readFileSync, existsSync, openSync, statSync, readSync, closeSync, lstatSync, rmSync, realpathSync, } from "node:fs";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { isReachable, DEFAULT_SERVER, createSpaceAuth, serverConfig, openServerConfig, validateTlsMaterial, mintCreds, mintLifecycleUid, DEV_OWNER, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, setupSpaceStreams, reconcileSpaceTtls, standaloneConnectOpts, seedChannelRegistry, ensureDefaultDeliveryClass, mkSecretDir, writeSecretFile, } from "@cotal-ai/core";
8
8
  import { connect } from "@nats-io/transport-node";
9
- import { assertSingleSpaceBroker, assertUserAuthInfo, authDir, getSoleSpaceAuth, getSpaceAuth, hasUserAuthState, listSpaceAccounts, preloadSpaceAccounts, putSpaceAuth, clearCurrent, findMesh, getCurrent, loadMeshes, connectionEvictorCredsKey, membershipConfigPath, membershipObserverCredsKey, membershipRwCredsKey, MEMBERSHIP_CONFIG_KIND, MEMBERSHIP_RW_CREDS_KIND, parsePositiveIntegerFlag, recordMesh, meshesForRoot, removeMesh, rotateSystemCreds, setCurrent, staleSystemCreds, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, acquireMaintenanceLock, assertStoreIdentity, assessRestoreClaim, beginOrdinaryResume, bindOrdinaryResumeListener, consumeRetiredMaintenance, markOrdinaryResumeActive, markOrdinaryResumeDegraded, replaceDeadOrdinaryResumeListener, localProcessOwnerStatus, readMaintenanceJournal, readMaintenanceResumeDocument, readStoreIdentity, recordOrdinaryResumeManagerCommit, releaseMaintenanceLock, retireOrdinaryResume, sameStoreIdentity, readBrokerPolicy, writeBrokerPolicy, removeIdentityPin, writeIdentityPin, } from "@cotal-ai/workspace";
9
+ import { assertSingleSpaceBroker, assertUserAuthInfo, authDir, getSoleSpaceAuth, getSpaceAuth, hasUserAuthState, listSpaceAccounts, preloadSpaceAccounts, putSpaceAuth, clearCurrent, findMesh, getCurrent, loadMeshes, connectionEvictorCredsKey, membershipConfigPath, membershipObserverCredsKey, membershipRwCredsKey, MEMBERSHIP_CONFIG_KIND, MEMBERSHIP_RW_CREDS_KIND, parsePositiveIntegerFlag, recordMesh, meshesForRoot, removeMesh, rotateSystemCreds, setCurrent, staleSystemCreds, SYSTEM_CREDS_FILES, userAuthStateDir, userAuthSpacesOnDisk, workspaceSecretStore, acquireMaintenanceLock, assertStoreIdentity, assessRestoreClaim, beginOrdinaryResume, bindOrdinaryResumeListener, consumeRetiredMaintenance, markOrdinaryResumeActive, markOrdinaryResumeDegraded, replaceDeadOrdinaryResumeListener, localProcessOwnerStatus, readMaintenanceJournal, readMaintenanceResumeDocument, seatCheckpointDir, readStoreIdentity, recordOrdinaryResumeManagerCommit, releaseMaintenanceLock, retireOrdinaryResume, sameStoreIdentity, readBrokerPolicy, writeBrokerPolicy, removeIdentityPin, writeIdentityPin, } from "@cotal-ai/workspace";
10
10
  import { ensureAuthService, resolveAuthProvider, stopAuthService } from "../lib/auth-proc.js";
11
11
  import { resolveSpace } from "../lib/status.js";
12
12
  import { c } from "../ui.js";
13
+ import { admitAndRestoreSeatCheckpoints } from "../lib/seat-resume.js";
13
14
  import { resolveNatsServer } from "../lib/nats-bin.js";
14
15
  import { cotalPath, cotalRoot } from "../lib/paths.js";
15
16
  import { renderDetachedSummary } from "../lib/up-report.js";
@@ -31,15 +32,17 @@ const pendingOrdinaryResumes = new Map();
31
32
  /** `cotal up` flags — colocated with the command (like `spawnFlags`) so its completion can read them. */
32
33
  export const upFlags = [
33
34
  { name: "server", type: "string", value: "<url>", description: "listen URL override" },
34
- { name: "host", type: "string", value: "<host>", description: "bind host override" },
35
+ { name: "host", type: "string", value: "<host>", description: "bind host override (an IP/hostname, not a URL; the URL is --server)" },
35
36
  { name: "space", type: "string", value: "<s>", description: "space name (default: the folder's)" },
36
37
  { name: "store-dir", type: "string", value: "<dir>", description: "JetStream store directory" },
38
+ { name: "max-file-store", type: "string", value: "<bytes>", description: "JetStream file storage cap in bytes (default: nats-server's dynamic cap). Fixed at broker start; recorded and kept across `down --preserve-state`" },
37
39
  { name: "channels", type: "string", value: "<path>", description: "channel-registry seed file (JSON; default .cotal/channels.json)" },
38
40
  { name: "tls-cert", type: "string", value: "<file>", description: "serve the broker over TLS with this certificate (requires --tls-key)" },
39
41
  { name: "tls-key", type: "string", value: "<file>", description: "the private key for --tls-cert" },
40
42
  { name: "restore", type: "string", value: "<dir>", description: "restore an offline backup before exposing the normal listener" },
41
43
  { name: "restore-only", type: "string", value: "<registry>", description: "restore only the registry component" },
42
44
  { name: "accept-missing-source", type: "boolean", description: "explicit disaster consent when the inode-bound preserved source is absent" },
45
+ { name: "accept-stale-checkpoint", type: "boolean", description: "explicit consent to resume a seat checkpoint captured outside its recorded recency horizon" },
43
46
  { name: "open", type: "boolean", description: "unauthenticated dev mesh (no JWT/ACLs)" },
44
47
  { name: "user-auth", type: "boolean", description: "per-USER auth: login + bearer through the space's auth service" },
45
48
  { name: "idp", type: "string", value: "<url>", description: "with --user-auth: the IdP auth base URL to pin (first enable)" },
@@ -52,6 +55,7 @@ export const upFlags = [
52
55
  { name: "detach", type: "boolean", description: "run in the background (stop with `cotal down`)" },
53
56
  { name: "runtime", type: "string", value: "<name>", description: "agent runtime for the mesh manager (default pty; extension runtimes are explicit-only, see `cotal runtimes`); with -f overrides the manifest's runtime" },
54
57
  { name: "max-sessions", type: "string", value: "<n>", description: "live-session ceiling for the mesh manager (default 64; one session per console pane, so size for agents × panes). Recorded and reused by later manager launches" },
58
+ { name: "no-manager", type: "boolean", description: "broker-only boot: start the broker and, in auth mode, the delivery daemon, and no local manager (refuses on a mesh whose manager is live)" },
55
59
  { name: "file", type: "string", short: "f", value: "<cotal.yaml>", description: "launch a whole mesh from a manifest" },
56
60
  { name: "dry-run", type: "boolean", description: "with -f: print the plan, mutate nothing" },
57
61
  ];
@@ -143,6 +147,11 @@ async function runUp(args, inheritedLock, onAdopt) {
143
147
  // read. A rotation is a stopped, fresh boot; a half-finished maintenance attempt is neither.
144
148
  if (values["rotate-sys"] && (values.__restoreAttempt || values.__ordinaryResumeAttempt))
145
149
  throw new Error("--rotate-sys cannot run during a restore/resume re-entry - finish or roll back the maintenance attempt, then `cotal down` and `cotal up --rotate-sys`");
150
+ // Same seam, same reason: a restore/resume re-entry needs the manager to commit the preserved
151
+ // inventory (completeResumeActivation asks it for resumePreserved/commitResume/finalizeResume), so
152
+ // a broker-only ask there is a contradiction to refuse, never a flag to drop mid-recovery.
153
+ if (values["no-manager"] && (values.__restoreAttempt || values.__ordinaryResumeAttempt))
154
+ throw new Error("--no-manager cannot run during a restore/resume re-entry - the resume needs a manager to commit the preserved inventory; finish or roll back the maintenance attempt first");
146
155
  if (!values.__restoreAttempt && !values.__ordinaryResumeAttempt && !values.file) {
147
156
  const root = cotalRoot();
148
157
  // Same refusal for the AUTO-recovered journal, raised before the recovery is prepared and
@@ -150,6 +159,11 @@ async function runUp(args, inheritedLock, onAdopt) {
150
159
  // `up` that has already begun adopting a maintenance attempt.
151
160
  if (values["rotate-sys"] && readMaintenanceJournal(root))
152
161
  throw new Error("--rotate-sys is refused while this root has a maintenance attempt to recover - finish or roll back that attempt (`cotal up` alone recovers it), then `cotal down` and `cotal up --rotate-sys`");
162
+ // Raised here for the stronger reason the journal itself carries: a pending maintenance attempt
163
+ // means the store is mid-recovery, and a broker-only boot over it is wrong with or without the
164
+ // manager half. The manager half is why the FLAG specifically cannot apply.
165
+ if (values["no-manager"] && readMaintenanceJournal(root))
166
+ throw new Error("--no-manager is refused while this root has a maintenance attempt to recover - a resume needs a manager to commit the preserved inventory; recover it (`cotal up` alone) or roll it back first");
153
167
  const lock = acquireMaintenanceLock(root);
154
168
  let pending;
155
169
  let recoveredRestore;
@@ -203,9 +217,19 @@ async function runUp(args, inheritedLock, onAdopt) {
203
217
  // Journal the CANONICAL identity-checked path, never the caller's spelling: a relative or
204
218
  // symlinked spelling re-resolved later (other cwd, retargeted link) could open another store.
205
219
  const resumeStore = journal.source.path;
220
+ // The cap is part of the preserved launch, like the store: a resume re-renders it, and a
221
+ // different --max-file-store is refused rather than silently resizing the preserved broker.
222
+ const resumeMaxFileStore = journaledMaxFileStore(launch.maxFileStore);
223
+ const requestedMaxFileStore = parsePositiveIntegerFlag("--max-file-store", values["max-file-store"]);
224
+ if (requestedMaxFileStore !== undefined && requestedMaxFileStore !== resumeMaxFileStore)
225
+ throw new Error(`--max-file-store ${requestedMaxFileStore} is not the preserved launch cap (${resumeMaxFileStore ?? "unset"}); ordinary up resumes exactly the preserved cap - omit --max-file-store`);
206
226
  if (values.runtime && runtimes[0] && values.runtime !== runtimes[0])
207
227
  throw new Error(`--runtime ${values.runtime} contradicts the preserved agent runtime ${runtimes[0]}; omit it to resume the same principals`);
208
228
  const resumeRuntime = values.runtime ?? runtimes[0] ?? "pty";
229
+ // Admit every seat checkpoint the cut wrote and take custody, BEFORE the resume attempt is
230
+ // journalled and long before a manager starts. A refusal here costs nothing because
231
+ // nothing has been started; the same refusal after a launch would be a second writer.
232
+ const staleConsent = admitAndRestoreSeatCheckpoints(root, journal.space, journal.cut.attemptId, values, resume.inventory);
209
233
  const serverNonce = randomUUID().replaceAll("-", "");
210
234
  const serverName = `${attemptId}-${serverNonce}`;
211
235
  beginOrdinaryResume(lock, {
@@ -213,10 +237,17 @@ async function runUp(args, inheritedLock, onAdopt) {
213
237
  launch: {
214
238
  server: resumeServer,
215
239
  storeDir: resumeStore,
240
+ ...(resumeMaxFileStore !== undefined ? { maxFileStore: resumeMaxFileStore } : {}),
216
241
  runtime: resumeRuntime,
217
242
  detached: Boolean(values.detach),
218
243
  serverName,
219
244
  serverNonce,
245
+ // Durable evidence of consent, not a print. An operator asking later why a seat resumed
246
+ // from a checkpoint past its horizon must be able to read that it was admitted, for
247
+ // which seat, and by how far, from the journal rather than from a lost terminal.
248
+ ...(staleConsent.length
249
+ ? { acceptedStaleCheckpoints: staleConsent.map((seat) => ({ ...seat })) }
250
+ : {}),
220
251
  },
221
252
  });
222
253
  pending = {
@@ -226,6 +257,7 @@ async function runUp(args, inheritedLock, onAdopt) {
226
257
  mode: journal.mode,
227
258
  server: resumeServer,
228
259
  storeDir: resumeStore,
260
+ ...(resumeMaxFileStore !== undefined ? { maxFileStore: resumeMaxFileStore } : {}),
229
261
  runtime: resumeRuntime,
230
262
  detached: Boolean(values.detach),
231
263
  inventory: resume.inventory,
@@ -239,6 +271,10 @@ async function runUp(args, inheritedLock, onAdopt) {
239
271
  const resume = readMaintenanceResumeDocument(root, journal.resume);
240
272
  const launch = journal.ordinaryResume.launch;
241
273
  const attemptId = journal.ordinaryResume.attemptId;
274
+ const recoveredMaxFileStore = journaledMaxFileStore(launch.maxFileStore);
275
+ const requestedMaxFileStore = parsePositiveIntegerFlag("--max-file-store", values["max-file-store"]);
276
+ if (requestedMaxFileStore !== undefined && requestedMaxFileStore !== recoveredMaxFileStore)
277
+ throw new Error(`--max-file-store ${requestedMaxFileStore} is not the preserved launch cap (${recoveredMaxFileStore ?? "unset"}); resume attempt ${attemptId} re-renders exactly the preserved cap - omit --max-file-store`);
242
278
  // Recovery re-asserts the source identity rather than trusting the journaled spelling.
243
279
  assertStoreIdentity(journal.source);
244
280
  // The bound listener decides re-entry: a live exact listener is ADOPTED, a provably dead
@@ -283,6 +319,7 @@ async function runUp(args, inheritedLock, onAdopt) {
283
319
  mode: journal.mode,
284
320
  server: typeof launch.server === "string" ? launch.server : DEFAULT_SERVER,
285
321
  storeDir: journal.source.path,
322
+ ...(recoveredMaxFileStore !== undefined ? { maxFileStore: recoveredMaxFileStore } : {}),
286
323
  runtime: typeof launch.runtime === "string" ? launch.runtime : "pty",
287
324
  detached: launch.detached === true,
288
325
  inventory: resume.inventory,
@@ -341,6 +378,7 @@ async function runUp(args, inheritedLock, onAdopt) {
341
378
  space: pending.space,
342
379
  server: pending.server,
343
380
  "store-dir": pending.storeDir,
381
+ "max-file-store": pending.maxFileStore === undefined ? undefined : String(pending.maxFileStore),
344
382
  runtime: pending.runtime,
345
383
  detach: pending.detached,
346
384
  open: pending.mode === "open",
@@ -370,8 +408,22 @@ async function runUp(args, inheritedLock, onAdopt) {
370
408
  if (values.idp && !wantUser && !values.file) {
371
409
  throw new Error('--idp is for user-auth spaces; pair it with --user-auth, or set broker.auth: "user" in a manifest');
372
410
  }
411
+ // BROKER-ONLY MODE (#1417). `--no-manager` names what it omits, and the two manager-shaping flags
412
+ // are refused beside it rather than silently dropped: a runtime selected or a ceiling recorded for
413
+ // a manager that will never start is a misunderstanding, and dropping it quietly is the exact
414
+ // silent-no-op shape this repo refuses everywhere else.
415
+ const noManager = Boolean(values["no-manager"]);
416
+ if (noManager && values.runtime)
417
+ throw new Error("--no-manager cannot be combined with --runtime - no manager starts, so there is no runtime to select; drop --runtime, or drop --no-manager");
418
+ if (noManager && values["max-sessions"])
419
+ throw new Error("--no-manager cannot be combined with --max-sessions - no manager starts, so there is no session ceiling to set; drop --max-sessions, or drop --no-manager");
373
420
  const publicExchange = publicExchangeArgs(values, wantUser);
374
421
  const maxSessions = parsePositiveIntegerFlag("--max-sessions", values["max-sessions"]);
422
+ const maxFileStore = parsePositiveIntegerFlag("--max-file-store", values["max-file-store"]);
423
+ // The manifest path renders its broker from the manifest, which has no store fields, so the flag
424
+ // would be accepted and never rendered. Refuse it rather than drop it.
425
+ if (maxFileStore !== undefined && values.file)
426
+ throw new Error("--max-file-store cannot be combined with --file/-f - a manifest launch does not render a store cap; drop --max-file-store, or start the mesh without -f");
375
427
  // An open mesh has no operator, no system account, and no $SYS creds, so there is nothing to
376
428
  // rotate; the request is a misunderstanding to name, never a silent no-op that reports success.
377
429
  if (values["rotate-sys"] && values.open) {
@@ -461,6 +513,7 @@ async function runUp(args, inheritedLock, onAdopt) {
461
513
  idp: values.idp,
462
514
  rotateSys: values["rotate-sys"],
463
515
  maxSessions,
516
+ noManager,
464
517
  });
465
518
  return;
466
519
  }
@@ -479,6 +532,7 @@ async function runUp(args, inheritedLock, onAdopt) {
479
532
  idp: values.idp,
480
533
  rotateSys: values["rotate-sys"],
481
534
  maxSessions,
535
+ noManager,
482
536
  });
483
537
  }
484
538
  finally {
@@ -616,6 +670,13 @@ async function runUp(args, inheritedLock, onAdopt) {
616
670
  console.error(c.red(`✗ mesh "${held.space}" is already running at ${server} - a running broker can't change its transport; \`cotal down\` it first, then \`cotal up --tls-cert <cert> --tls-key <key>\``));
617
671
  process.exit(1);
618
672
  }
673
+ // The store cap is fixed when nats-server reads its config, and nats-server refuses a reload
674
+ // from its dynamic cap to an explicit one. This branch starts nothing, so a different cap can
675
+ // only be refused; the same cap is a no-op.
676
+ if (maxFileStore !== undefined && maxFileStore !== held.maxFileStore) {
677
+ console.error(c.red(`✗ mesh "${held.space}" is already running at ${server} - a running broker can't change --max-file-store (it is fixed at start); \`cotal down\` it first, then \`cotal up --max-file-store ${maxFileStore}\``));
678
+ process.exit(1);
679
+ }
619
680
  // `--max-sessions` is fixed when the manager starts. A refresh reuses a live manager as-is
620
681
  // (`ensureManager` returns immediately), then used to persist the requested ceiling anyway.
621
682
  // That printed `✓ already running` over an unchanged plane and left MeshEntry lying about it.
@@ -628,6 +689,18 @@ async function runUp(args, inheritedLock, onAdopt) {
628
689
  process.exit(1);
629
690
  }
630
691
  }
692
+ // `--no-manager` on a refresh whose manager is LIVE is the same class of refusal: this branch
693
+ // starts nothing, so the flag cannot apply, and silently keeping the manager would answer a
694
+ // broker-only ask with a full stack while silently stopping it would kill agents this command
695
+ // was never asked to touch. Same shape as `--runtime`/`--max-sessions` above: refuse, name the
696
+ // exact remedy. A refresh of a mesh whose manager is NOT live simply proceeds broker-only.
697
+ if (noManager) {
698
+ const live = managerRecordState(undefined, undefined, held.space);
699
+ if (live.state === "alive") {
700
+ console.error(c.red(`✗ mesh "${held.space}" is already running at ${server} with a live manager - \`cotal up --no-manager\` will not keep or stop one; \`cotal down manager\` first, then \`cotal up --no-manager\``));
701
+ process.exit(1);
702
+ }
703
+ }
631
704
  // Live INFO must agree with the recorded/requested transport. A bare refresh that greets
632
705
  // "already running" over a plaintext listener while policy claims TLS is S5's second half —
633
706
  // the refuse left a durable lie and this path used to reprint TLS success over cleartext.
@@ -739,6 +812,7 @@ async function runUp(args, inheritedLock, onAdopt) {
739
812
  runtime: values.runtime,
740
813
  attachHost: attachHostFor(held.space, values.host),
741
814
  maxSessions: maxSessionsFor(held.space, maxSessions),
815
+ noManager,
742
816
  });
743
817
  if (!controlPlane)
744
818
  process.exitCode = 1;
@@ -751,7 +825,7 @@ async function runUp(args, inheritedLock, onAdopt) {
751
825
  // no transport decision to record — and `recordOurMesh` writes the entry whole, so omitting
752
826
  // the field here would erase the requirement on every bare refresh, exactly the way dropping
753
827
  // `attachHost` would silently demote the mesh to loopback.
754
- recordOurMesh({ space: held.space, server, root, mode: held.mode, ...(held.tlsRequired !== undefined ? { tlsRequired: held.tlsRequired } : {}), ...(userAuth ? { userAuth } : {}), ...(heldAttachHost ? { attachHost: heldAttachHost } : {}), ...(heldMaxSessions !== undefined ? { maxSessions: heldMaxSessions } : {}), ts: new Date().toISOString() }, "refresh");
828
+ recordOurMesh({ space: held.space, server, root, mode: held.mode, ...(held.tlsRequired !== undefined ? { tlsRequired: held.tlsRequired } : {}), ...(userAuth ? { userAuth } : {}), ...(heldAttachHost ? { attachHost: heldAttachHost } : {}), ...(heldMaxSessions !== undefined ? { maxSessions: heldMaxSessions } : {}), ...(held.maxFileStore !== undefined ? { maxFileStore: held.maxFileStore } : {}), ts: new Date().toISOString() }, "refresh");
755
829
  return;
756
830
  }
757
831
  const who = held ? `mesh "${held.space}" (${held.root})` : "a broker not started here";
@@ -783,6 +857,7 @@ async function runUp(args, inheritedLock, onAdopt) {
783
857
  transport,
784
858
  server,
785
859
  storeDir: values["store-dir"],
860
+ maxFileStore,
786
861
  space: values.space,
787
862
  open: values.open,
788
863
  userAuth: wantUser ? { idpUrl: values.idp } : undefined,
@@ -795,6 +870,7 @@ async function runUp(args, inheritedLock, onAdopt) {
795
870
  host: values.host,
796
871
  runtime: values.runtime,
797
872
  maxSessions,
873
+ noManager,
798
874
  resumeAttempt,
799
875
  resumeCommitToken: restored?.managerCommit?.durableCommitToken ?? ordinaryAttempt?.managerCommit?.durableCommitToken,
800
876
  ...(restored ? {
@@ -850,14 +926,14 @@ async function runUp(args, inheritedLock, onAdopt) {
850
926
  // Decided above the branch; the dial host is only settled here (a port collision may have moved
851
927
  // the server, and the hostname is what the certificate has to match).
852
928
  assertServesDialHost(transport, new URL(server).hostname);
853
- const setup = useAuth ? await authSetup(storeDir, server, space, host, wantUser ? { idpUrl: values.idp } : undefined, transport, values["rotate-sys"]) : undefined;
929
+ const setup = useAuth ? await authSetup(storeDir, server, space, host, wantUser ? { idpUrl: values.idp } : undefined, transport, values["rotate-sys"], maxFileStore) : undefined;
854
930
  const port = Number(new URL(server).port) || 4222;
855
931
  const restored = resumeAttempt ? pendingRestores.get(resumeAttempt) : undefined;
856
932
  // Both modes go through a RENDERER, never bare CLI flags. Open mode used to start from
857
933
  // `-js -sd … -p … -a …`, which never called a renderer at all — so the required transport union
858
934
  // protected the auth path and was silent on the open one, and a cert/key pair passed to an
859
935
  // open-mode `up` would have been accepted while the listener came up in cleartext.
860
- const confPath = setup ? setup.confPath : writeOpenBrokerConf(storeDir, { port, host, transport });
936
+ const confPath = setup ? setup.confPath : writeOpenBrokerConf(storeDir, { port, host, transport, maxFileStore });
861
937
  const natsArgs = [
862
938
  "-c", confPath,
863
939
  ...(restored ? ["--name", restored.serverName]
@@ -989,6 +1065,7 @@ async function runUp(args, inheritedLock, onAdopt) {
989
1065
  // though the operator had chosen it.
990
1066
  ...(effectiveAttachHost ? { attachHost: effectiveAttachHost } : {}),
991
1067
  ...(effectiveMaxSessions !== undefined ? { maxSessions: effectiveMaxSessions } : {}),
1068
+ ...(maxFileStore !== undefined ? { maxFileStore } : {}),
992
1069
  ts: new Date().toISOString(),
993
1070
  }, "started");
994
1071
  // Bring up the delivery daemon WITH the server (auth mode only — it self-gates on `.cotal/auth`).
@@ -1002,6 +1079,7 @@ async function runUp(args, inheritedLock, onAdopt) {
1002
1079
  // is never a side effect of anything but the operator binding the mesh somewhere reachable.
1003
1080
  attachHost: effectiveAttachHost,
1004
1081
  maxSessions: effectiveMaxSessions,
1082
+ noManager,
1005
1083
  resumeAttempt,
1006
1084
  resumeCommitToken: restored?.managerCommit?.durableCommitToken ?? ordinaryAttempt?.managerCommit?.durableCommitToken,
1007
1085
  wsPort: setup?.wsPort, // P2 item 6: the console session client's broker ws port
@@ -1353,6 +1431,7 @@ async function resumeProvenOrdinaryListener(pending, heldLock) {
1353
1431
  ...(svc.userAuth ? { userAuth: svc.userAuth } : {}),
1354
1432
  ...(adoptAttachHost ? { attachHost: adoptAttachHost } : {}),
1355
1433
  ...(adoptMaxSessions !== undefined ? { maxSessions: adoptMaxSessions } : {}),
1434
+ ...(pending.maxFileStore !== undefined ? { maxFileStore: pending.maxFileStore } : {}),
1356
1435
  ts: new Date().toISOString(),
1357
1436
  }, "started");
1358
1437
  const controlPlane = await startDeliveryWithBroker(pending.space, pending.server, adoptedTlsRequired(pending.root), {
@@ -1671,10 +1750,16 @@ async function upManifest(file, opts) {
1671
1750
  const host = m.broker?.host ?? "127.0.0.1";
1672
1751
  // Same bind-vs-probe reconciliation as the flag path: a manifest that sets `broker.host` without
1673
1752
  // an explicit `broker.servers` would otherwise bind one address and be probed at another.
1674
- const server = m.broker?.host ? reconcileHostAndServer(m.broker.host, m.broker?.servers) : (m.broker?.servers ?? DEFAULT_SERVER);
1753
+ const server = m.broker?.host ? reconcileHostAndServer(m.broker.host, m.broker?.servers, "broker.host", "broker.servers") : (m.broker?.servers ?? DEFAULT_SERVER);
1675
1754
  const open = m.broker?.auth === false; // default is auth
1676
1755
  const userAuth = m.broker?.auth === "user" ? { idpUrl: opts.idp ?? m.broker?.idp } : undefined; // flag > manifest
1677
1756
  const runtime = m.runtime ?? "pty";
1757
+ // A manifest's agents are booted BY the manager `up -f` starts with the mesh, so `--no-manager`
1758
+ // against a manifest declaring agents asks for a boot that cannot deliver its own plan. Refused
1759
+ // before the dry-run print and before anything boots, with the two workable spellings named.
1760
+ if (opts.noManager && eff.agents.length)
1761
+ throw new Error(`--no-manager cannot launch this manifest: it declares ${eff.agents.length} agent(s), and a manifest's agents are booted by its manager (which --no-manager omits). Deploy it from a manager host with \`cotal spawn -f\`, or drop --no-manager.`);
1762
+ const effectiveNoManager = Boolean(opts.noManager);
1678
1763
  // The open-mesh refusal must be RE-STATED here, not only against the CLI `--open` flag: on this
1679
1764
  // path openness comes from the MANIFEST (`broker.auth: false`), which the flag-level guard cannot
1680
1765
  // see. Without it, `cotal up -f open.yaml --rotate-sys` boots an open broker, exits 0 and rotates
@@ -1691,6 +1776,10 @@ async function upManifest(file, opts) {
1691
1776
  if (opts.transport.kind === "tls-required")
1692
1777
  announceTransport(opts.transport);
1693
1778
  console.log(renderUpPlan(eff, server));
1779
+ // The omission is part of the plan (#1417): a dry run that lists a manager the real boot
1780
+ // will not start is the exact "green-lit a launch the applying command refuses" failure.
1781
+ if (effectiveNoManager)
1782
+ console.log(c.dim(" no manager will start on this host (--no-manager): broker, channels and, in auth mode, the delivery daemon come up, and no agents launch from here"));
1694
1783
  return;
1695
1784
  }
1696
1785
  // Preflight an extension runtime before starting the broker. The detached manager re-execs this
@@ -1744,7 +1833,7 @@ async function upManifest(file, opts) {
1744
1833
  try {
1745
1834
  // `m.broker?.host`, not the defaulted `host`: same reason as the flag path — only a host the
1746
1835
  // manifest actually declared is an exposure decision worth persisting.
1747
- ({ pid, controlPlane, authService } = await startMeshDetached({ transport: opts.transport, server, space: m.space, open, userAuth, rotateSys: opts.rotateSys, host: m.broker?.host, seed: manifestToChannels(eff), runtime, launch: specPath, maxSessions: opts.maxSessions }));
1836
+ ({ pid, controlPlane, authService } = await startMeshDetached({ transport: opts.transport, server, space: m.space, open, userAuth, rotateSys: opts.rotateSys, host: m.broker?.host, seed: manifestToChannels(eff), runtime, launch: specPath, maxSessions: opts.maxSessions, noManager: effectiveNoManager }));
1748
1837
  }
1749
1838
  catch (e) {
1750
1839
  console.error(c.red(`✗ ${e.message}`));
@@ -1755,7 +1844,12 @@ async function upManifest(file, opts) {
1755
1844
  console.log(c.dim(` seeded ${m.channels.length} channel(s): ${m.channels.map((ch) => "#" + ch.name).join(", ")}`));
1756
1845
  // Never claim a launch the control plane can't deliver: the manager carries the launch spec, so a
1757
1846
  // degraded control plane (announced above) means the agents are NOT coming up.
1758
- if (controlPlane) {
1847
+ if (effectiveNoManager) {
1848
+ // Truthful summary for the broker-only boot (#1417): nothing was omitted by accident, and the
1849
+ // agents this manifest declares (none — a declaring manifest is refused above) launch elsewhere.
1850
+ console.log(c.dim(` broker-only (--no-manager): no manager started on this host - run \`cotal supervise --space ${m.space} --server ${server}\` from a manager host to manage agents`));
1851
+ }
1852
+ else if (controlPlane) {
1759
1853
  // U6: on a user mesh the agents run under the OPERATOR's identity — say whose they are.
1760
1854
  console.log(c.green(`✓ launching ${eff.agents.length} agent(s)`) + c.dim(` via manager (${runtime})${owner ? ` as you (owner ${owner})` : ""} - see ${managerLogDisplayPath(m.space)}`));
1761
1855
  }
@@ -1768,7 +1862,7 @@ async function upManifest(file, opts) {
1768
1862
  console.log("\n" + inherited);
1769
1863
  if (eff.warnings.length)
1770
1864
  console.log("\n" + renderWarnings(eff.warnings));
1771
- console.log(c.dim(`\nWatch: \`cotal console --space ${m.space}\` or \`cotal web\` · Tear down: \`cotal down\``));
1865
+ console.log(c.dim(`\nWatch: \`cotal web\` (browser dashboard) or \`cotal console --space ${m.space}\` (terminal) · Tear down: \`cotal down\``));
1772
1866
  // A declared-user-auth manifest whose auth service never became ready: the mesh is up + recorded
1773
1867
  // (re-`cotal up` heals), but this launch did not deliver a usable identity plane — exit non-zero
1774
1868
  // so CI/wrappers don't read success (the red consequence line printed at the failure above).
@@ -1873,11 +1967,11 @@ opts) {
1873
1967
  // effective server is finally known — a manifest may name a different host than the flags did, and
1874
1968
  // a certificate that is valid for one is not thereby valid for the other.
1875
1969
  const transport = assertServesDialHost(opts.transport, new URL(server).hostname);
1876
- const setup = useAuth ? await authSetup(storeDir, server, space, host, opts.userAuth, transport, opts.rotateSys) : undefined;
1970
+ const setup = useAuth ? await authSetup(storeDir, server, space, host, opts.userAuth, transport, opts.rotateSys, opts.maxFileStore) : undefined;
1877
1971
  const port = Number(new URL(server).port) || 4222;
1878
1972
  // Same rule as the foreground path: every route to a listener names its transport (see
1879
1973
  // `writeOpenBrokerConf`). Detach must not be the mode where the fence quietly does not apply.
1880
- const confPath = setup ? setup.confPath : writeOpenBrokerConf(storeDir, { port, host, transport });
1974
+ const confPath = setup ? setup.confPath : writeOpenBrokerConf(storeDir, { port, host, transport, maxFileStore: opts.maxFileStore });
1881
1975
  const args = [
1882
1976
  "-c", confPath,
1883
1977
  ...(opts.boundListener ? ["--name", opts.boundListener.serverName] : []),
@@ -1973,6 +2067,7 @@ opts) {
1973
2067
  // Persist only a real decision — declared now, or carried forward — never the loopback default.
1974
2068
  ...(effectiveAttachHost ? { attachHost: effectiveAttachHost } : {}),
1975
2069
  ...(effectiveMaxSessions !== undefined ? { maxSessions: effectiveMaxSessions } : {}),
2070
+ ...(opts.maxFileStore !== undefined ? { maxFileStore: opts.maxFileStore } : {}),
1976
2071
  ts: new Date().toISOString(),
1977
2072
  }, "started");
1978
2073
  // Commit policy BEFORE delivery launch (S9). Listener is proved; refuse paths never reach here.
@@ -1985,6 +2080,7 @@ opts) {
1985
2080
  // See the foreground path: the broker's bind address is what makes attach reachable off-box.
1986
2081
  attachHost: effectiveAttachHost,
1987
2082
  maxSessions: effectiveMaxSessions,
2083
+ noManager: opts.noManager,
1988
2084
  resumeAttempt: opts.resumeAttempt,
1989
2085
  resumeCommitToken: opts.resumeCommitToken,
1990
2086
  wsPort: setup?.wsPort, // P2 item 6: the console session client's broker ws port
@@ -2073,8 +2169,8 @@ export async function claimSpace(space, server, root) {
2073
2169
  // reclaimed: unreachable is not proof the mesh is gone (the record describes a broker on another
2074
2170
  // machine), the reclaim runs BEFORE this launch starts anything, and `cotal down` — what the
2075
2171
  // liveness branch below would advise — cannot stop a mesh this machine does not run.
2076
- if (existing.origin === "manual")
2077
- throw new Error(`space "${space}" is registered to a mesh at ${existing.server} (${existing.root}) - it was registered by hand, so \`cotal up\` neither takes it over nor reclaims the name: \`cotal meshes rm ${space}\` to drop that record first, or start this one under a different \`--space\``);
2172
+ if (existing.origin === "manual" || existing.origin === "catalog")
2173
+ throw new Error(`space "${space}" is registered to a mesh at ${existing.server} (${existing.root}) - it is ${existing.origin === "catalog" ? "owned by a signed-in space catalog" : "registered by hand"}, so \`cotal up\` neither takes it over nor reclaims the name: ${existing.origin === "catalog" ? "use a different `--space`, or remove access at the IdP and run `cotal sync`" : `\`cotal meshes rm ${space}\` to drop that record first, or start this one under a different \`--space\``}`);
2078
2174
  if (await isReachable(existing.server)) {
2079
2175
  throw new Error(`space "${space}" is already in use by a mesh at ${existing.server} (${existing.root}) - pick a different \`--space\`, or \`cotal down\` it first`);
2080
2176
  }
@@ -2109,6 +2205,15 @@ export function attachHostFor(space, explicit) {
2109
2205
  export function maxSessionsFor(space, explicit) {
2110
2206
  return explicit ?? findMesh(space)?.maxSessions;
2111
2207
  }
2208
+ /** The store cap a preserved launch recorded: absent, or a positive integer byte count. Anything
2209
+ * else is a damaged journal, refused rather than rendered as the dynamic default. */
2210
+ function journaledMaxFileStore(value) {
2211
+ if (value === undefined)
2212
+ return undefined;
2213
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
2214
+ throw new Error(`the preserved launch records an invalid maxFileStore (${JSON.stringify(value)}); refusing to guess the broker's store cap`);
2215
+ return value;
2216
+ }
2112
2217
  /** {@link recordOurMesh} under a name that says it is a test seam: the origin rule it enforces is
2113
2218
  * asserted directly by the registry smoke, since reaching each branch through a full `up` would
2114
2219
  * need a live broker per case. */
@@ -2117,7 +2222,7 @@ function recordOurMesh(m, provenance) {
2117
2222
  const cur = getCurrent();
2118
2223
  const usableCurrent = cur && findMesh(cur) ? cur : undefined; // compute before recording m
2119
2224
  const prior = findMesh(m.space);
2120
- const origin = provenance === "refresh" && prior?.origin === "manual" ? "manual" : "up";
2225
+ const origin = provenance === "refresh" && (prior?.origin === "manual" || prior?.origin === "catalog") ? prior.origin : "up";
2121
2226
  // A REFRESH starts nothing: it concluded the mesh is up from reachability alone, and rebuilds `m`
2122
2227
  // from what THIS launch knows, which is never the operator's past decisions. `origin` was already
2123
2228
  // carried across for that reason; the overlay acceptance is the same class and was not, so a
@@ -2170,6 +2275,36 @@ async function waitReady(server, creds) {
2170
2275
  * the probe/registry URL stays whatever it already is (loopback by default), which the wildcard
2171
2276
  * bind necessarily covers. */
2172
2277
  const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "[::]"]);
2278
+ /** Establish that a bind-host value IS a host, BEFORE anything brackets it, and return the
2279
+ * bracketed literal a URL can hold (#1697).
2280
+ *
2281
+ * `--host` is the bind address; its sibling `--server` takes a URL, the registry stores and prints
2282
+ * URLs, and every message in this area shows one — so an operator plausibly types the URL here
2283
+ * (the manifest's `broker.host` carries the same ambiguity against `broker.servers`). Unrejected,
2284
+ * the IPv6 bracketing wrapped it into `nats://[nats://127.0.0.1:4222]:4222`, which is not a URL at
2285
+ * all. Nothing checked the derived string: the first code to look at it compared it with the
2286
+ * space's registry record, failed for a reason unrelated to what was typed, and showed the
2287
+ * operator the "registered by hand" refusal whose remedy de-registers a live mesh. Refused by name
2288
+ * here instead — pointing at the URL-carrying sibling — and a value that still cannot serve as a
2289
+ * URL host (a `host:port` spelling, say) is refused rather than reaching that comparison as a
2290
+ * silently wrong server string. Never rewritten: a value this flag cannot use is an error, not a
2291
+ * URL to derive from. */
2292
+ function assertBindableHost(host, source, urlSource) {
2293
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(host)) {
2294
+ console.error(c.red(`✗ ${source} ${host} looks like a URL - it takes a bind host only (an IP or hostname, no scheme); pass the broker URL with ${urlSource} instead`));
2295
+ process.exit(1);
2296
+ }
2297
+ const literal = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
2298
+ try {
2299
+ // ONE parse, both consumers served from it: `literal` builds the derived URL (a URL host needs
2300
+ // the brackets), `hostname` compares against the explicit server's (the URL API strips them).
2301
+ return { literal, hostname: new URL(`nats://${literal}:1`).hostname };
2302
+ }
2303
+ catch {
2304
+ console.error(c.red(`✗ ${source} ${host} is not a valid bind host - an IP or hostname only, with no port; the port comes from ${urlSource} (or its default), never from the bind host`));
2305
+ process.exit(1);
2306
+ }
2307
+ }
2173
2308
  /**
2174
2309
  * Reconcile the bind address (`--host`, manifest `broker.host`) with the broker URL the readiness
2175
2310
  * probe, the mesh registry, and every later client use.
@@ -2180,16 +2315,19 @@ const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "[::]"]);
2180
2315
  * derive one from the host (keeping the URL's port); with both, refuse a contradicting pair rather
2181
2316
  * than starting a broker that nothing can reach.
2182
2317
  */
2183
- function reconcileHostAndServer(host, explicitServer) {
2318
+ function reconcileHostAndServer(host, explicitServer, source = "--host", urlSource = "--server") {
2184
2319
  const url = new URL(explicitServer ?? DEFAULT_SERVER);
2185
2320
  if (WILDCARD_HOSTS.has(host))
2186
2321
  return explicitServer ?? DEFAULT_SERVER;
2187
- // An unbracketed IPv6 literal cannot be assigned to a URL host — bracket it so the URL parses.
2188
- const literal = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
2322
+ const { literal, hostname } = assertBindableHost(host, source, urlSource);
2189
2323
  if (!explicitServer)
2190
2324
  return `nats://${literal}:${url.port || "4222"}`;
2191
- if (url.hostname !== new URL(`nats://${literal}:1`).hostname) {
2192
- console.error(c.red(`✗ --host ${host} and --server ${explicitServer} name different addresses - the broker would bind ${host} but be probed at ${url.hostname}. Pass one, or make them agree.`));
2325
+ // NO parse on this path: `assertBindableHost` just parsed the identical literal above, so the
2326
+ // mismatch check compares two parsed hostnames and can only ever print its diagnostic and exit 1
2327
+ // — the "never throw a raw Invalid URL" requirement (#1697) holds by construction, not by a
2328
+ // second guard that no input could reach.
2329
+ if (url.hostname !== hostname) {
2330
+ console.error(c.red(`✗ ${source} ${host} and ${urlSource} ${explicitServer} name different addresses - the broker would bind ${host} but be probed at ${url.hostname}. Pass one, or make them agree.`));
2193
2331
  process.exit(1);
2194
2332
  }
2195
2333
  return explicitServer;
@@ -2464,7 +2602,7 @@ function validateRecorded(transport, dialHost, opts = {}) {
2464
2602
  */
2465
2603
  function writeOpenBrokerConf(storeDir, opts) {
2466
2604
  const confPath = resolve(storeDir, "..", "server-open.conf");
2467
- writeFileSync(confPath, openServerConfig({ port: opts.port, host: opts.host, storeDir, transport: opts.transport }));
2605
+ writeFileSync(confPath, openServerConfig({ port: opts.port, host: opts.host, storeDir, maxFileStore: opts.maxFileStore, transport: opts.transport }));
2468
2606
  return confPath;
2469
2607
  }
2470
2608
  async function authSetup(storeDir, server, space, host = "127.0.0.1", user,
@@ -2473,7 +2611,7 @@ async function authSetup(storeDir, server, space, host = "127.0.0.1", user,
2473
2611
  // operator's `--tls-cert` sat validated and unused. A default here cannot be right, because the
2474
2612
  // safe value and the common value are different values — every caller knows its transport, and
2475
2613
  // the one that does not is the one that must not compile.
2476
- transport, rotateSys = false) {
2614
+ transport, rotateSys = false, maxFileStore) {
2477
2615
  const dir = authDir(cotalRoot()); // the broker config (server.conf) still lands under the FS auth dir
2478
2616
  const store = workspaceSecretStore(cotalRoot());
2479
2617
  let auth = await getSpaceAuth(store, space);
@@ -2569,7 +2707,20 @@ transport, rotateSys = false) {
2569
2707
  // map, so a config rendered from one space evicts the rest silently. `auth` leads the list because
2570
2708
  // this function's copy can be fresher than the disk (a space created above, or one whose trust
2571
2709
  // record `--rotate-sys` just replaced). Refuses on an unreadable record rather than dropping it.
2572
- writeFileSync(confPath, serverConfig(auth, preloadSpaceAccounts(dir, auth), { transport, port, storeDir, host, wsPort, wsHost: host, ...(prepared ? { extraAccounts: prepared.extraAccounts } : {}) }));
2710
+ const spaces = preloadSpaceAccounts(dir, auth);
2711
+ const enabled = userAuthSpacesOnDisk(dir);
2712
+ for (const enabledSpace of enabled) {
2713
+ if (!spaces.some((s) => s.space === enabledSpace))
2714
+ throw new Error(`space "${enabledSpace}" has user auth enabled but no data account record - refusing to render an incomplete broker resolver`);
2715
+ }
2716
+ const extraAccounts = [];
2717
+ for (const enabledSpace of enabled) {
2718
+ const accounts = enabledSpace === space && prepared
2719
+ ? prepared.extraAccounts
2720
+ : await resolveAuthProvider().preloadAccounts({ store, space: enabledSpace });
2721
+ extraAccounts.push(...accounts);
2722
+ }
2723
+ writeFileSync(confPath, serverConfig(auth, spaces, { transport, port, storeDir, maxFileStore, host, wsPort, wsHost: host, extraAccounts }));
2573
2724
  // Ephemeral setup cred: used only to probe reachability, pre-create the space streams/buckets
2574
2725
  // (setupSpaceStreams) and seed the channel registry (seedChannelRegistry) — all within the
2575
2726
  // enumerated `provisioner` scope. No broad `manager` residual for the up path.