@monster-orange/den 0.2.8 → 0.3.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 (127) hide show
  1. package/CHANGELOG.md +1547 -0
  2. package/README.md +4 -2
  3. package/bin/den +2 -1
  4. package/docs/distributions.md +507 -4
  5. package/make/den.mk +19 -4
  6. package/make/journeys.mk +2 -1
  7. package/make/local_dev.mk +6 -13
  8. package/make/secrets-1password.mk +18 -0
  9. package/make/test.mk +1 -1
  10. package/package.json +9 -2
  11. package/schemas/den.plugin.v1.json +156 -0
  12. package/schemas/den.theme.v1.json +269 -0
  13. package/scripts/auth-configure.ts +16 -8
  14. package/scripts/heal-config.ts +0 -5
  15. package/scripts/setup-1password.ts +41 -19
  16. package/scripts/setup-secrets.ts +67 -101
  17. package/src/pillars/api/global-setup.ts +1 -1
  18. package/src/pillars/journeys/sandbox.ts +23 -18
  19. package/src/pillars/journeys/tools/browser-session.ts +2 -2
  20. package/src/scripts/setup-default-workspace.ts +2 -2
  21. package/src/shared/auth/headless-login.ts +1 -1
  22. package/src/shared/auth/sudo-vault.ts +93 -31
  23. package/src/shared/auth/token-providers/index.ts +7 -3
  24. package/src/shared/brand.ts +7 -0
  25. package/src/shared/config-error.ts +33 -3
  26. package/src/shared/constants.ts +3 -24
  27. package/src/shared/den-config.ts +141 -5
  28. package/src/shared/edge/config.ts +147 -21
  29. package/src/shared/edge/exec.ts +31 -0
  30. package/src/shared/edge/included.ts +169 -0
  31. package/src/shared/edge/render.ts +69 -14
  32. package/src/shared/edge/runtime.ts +128 -121
  33. package/src/shared/edge/status.ts +68 -8
  34. package/src/shared/edge/tls.ts +328 -0
  35. package/src/shared/edge/types.ts +25 -9
  36. package/src/shared/environment-manager/default-workspace.ts +16 -1
  37. package/src/shared/environment-manager/health.ts +73 -38
  38. package/src/shared/environment-manager/scope.ts +168 -0
  39. package/src/shared/environment-manager/service-runner.ts +789 -43
  40. package/src/shared/environment-manager/up-targets.ts +132 -0
  41. package/src/shared/health/rollup.ts +48 -0
  42. package/src/shared/open-url.ts +21 -2
  43. package/src/shared/org-sources.ts +78 -59
  44. package/src/shared/org.ts +53 -5
  45. package/src/shared/plugins/manifest.ts +406 -0
  46. package/src/shared/plugins/store.ts +358 -0
  47. package/src/shared/process/legacy-adopt.ts +285 -0
  48. package/src/shared/process/process-group.ts +202 -0
  49. package/src/shared/process/spawn-streaming.ts +7 -0
  50. package/src/shared/secrets/doppler.ts +1 -1
  51. package/src/shared/secrets/index.ts +279 -50
  52. package/src/shared/secrets/none.ts +56 -0
  53. package/src/shared/secrets/strategies.ts +202 -0
  54. package/src/shared/secrets/types.ts +1 -1
  55. package/src/shared/smoke/git-remote.ts +40 -0
  56. package/src/shared/stacks/manifest-commands.ts +1 -1
  57. package/src/shared/stacks/namespace.ts +178 -0
  58. package/src/shared/stacks/owners.ts +303 -0
  59. package/src/shared/stacks/render.ts +8 -3
  60. package/src/shared/stacks/runtime.ts +100 -16
  61. package/src/shared/stacks/status.ts +63 -12
  62. package/src/shared/strategies/registry.ts +89 -0
  63. package/src/shared/theme/contract.ts +538 -0
  64. package/src/shared/types/index.ts +12 -3
  65. package/src/ui/cli/create.ts +24 -7
  66. package/src/ui/cli/doctor-command.ts +131 -0
  67. package/src/ui/cli/edge-command.ts +56 -10
  68. package/src/ui/cli/flag-parser.ts +7 -2
  69. package/src/ui/cli/health-display.ts +25 -0
  70. package/src/ui/cli/index.ts +548 -60
  71. package/src/ui/cli/notice.ts +20 -0
  72. package/src/ui/cli/plugin-command.ts +849 -0
  73. package/src/ui/cli/stack-command.ts +63 -2
  74. package/src/ui/cli/test-runner.ts +11 -9
  75. package/src/ui/cli/theme-command.ts +136 -0
  76. package/src/ui/cli/web-command.ts +47 -23
  77. package/src/ui/mcp/api.ts +28 -2
  78. package/src/ui/mcp/scope.ts +22 -1
  79. package/src/ui/mcp/tools.ts +50 -7
  80. package/src/ui/server/appearance-prefs.ts +95 -0
  81. package/src/ui/server/daemon-reaper.ts +29 -1
  82. package/src/ui/server/engine-stamp.ts +83 -0
  83. package/src/ui/server/github-service.ts +23 -44
  84. package/src/ui/server/graph-page.ts +20 -0
  85. package/src/ui/server/graph-prefs.ts +44 -1
  86. package/src/ui/server/http-errors.ts +293 -0
  87. package/src/ui/server/http-helpers.ts +22 -3
  88. package/src/ui/server/index.ts +532 -51
  89. package/src/ui/server/public/appearance.js +531 -0
  90. package/src/ui/server/public/edge-page.js +248 -0
  91. package/src/ui/server/public/edge.html +251 -0
  92. package/src/ui/server/public/environments.html +65 -83
  93. package/src/ui/server/public/error-page.js +247 -0
  94. package/src/ui/server/public/error.html +260 -0
  95. package/src/ui/server/public/graph-model.js +332 -14
  96. package/src/ui/server/public/graph.css +141 -47
  97. package/src/ui/server/public/graph.js +223 -44
  98. package/src/ui/server/public/header.js +396 -600
  99. package/src/ui/server/public/health-fit.js +248 -0
  100. package/src/ui/server/public/health-rollup.js +128 -0
  101. package/src/ui/server/public/i18n/es.js +287 -0
  102. package/src/ui/server/public/i18n/ja.js +276 -0
  103. package/src/ui/server/public/index.html +139 -438
  104. package/src/ui/server/public/local-dev.html +3468 -4044
  105. package/src/ui/server/public/logs.html +71 -208
  106. package/src/ui/server/public/panel.js +14 -2
  107. package/src/ui/server/public/release.html +175 -389
  108. package/src/ui/server/public/services.html +596 -770
  109. package/src/ui/server/public/settings-page.js +759 -0
  110. package/src/ui/server/public/settings-registry.js +521 -0
  111. package/src/ui/server/public/settings.html +590 -0
  112. package/src/ui/server/public/shell.js +3869 -0
  113. package/src/ui/server/public/topbar.js +1413 -0
  114. package/src/ui/server/public/version-label.js +12 -0
  115. package/src/ui/server/public/workspace-picker.js +74 -34
  116. package/src/ui/server/readonly-routes.ts +12 -1
  117. package/src/ui/server/services-list.ts +94 -17
  118. package/src/ui/server/settings-distribution.ts +74 -0
  119. package/src/ui/server/shell-prefs.ts +192 -0
  120. package/src/ui/server/theme-routes.ts +292 -0
  121. package/src/ui/server/theme-runtime.ts +407 -0
  122. package/src/ui/server/ui-assets.ts +16 -1
  123. package/src/ui/server/ui-language.ts +50 -0
  124. package/src/ui/server/workspace-routes.ts +45 -3
  125. package/themes/den/den-theme.json +72 -0
  126. package/themes/den/favicon.ico +0 -0
  127. package/themes/den/mark.svg +6 -0
@@ -6,7 +6,13 @@
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import { certNames, edgeDomains, edgeNames, routeUrl } from "./render.js";
9
+ import {
10
+ includedRouters,
11
+ stackRouters,
12
+ type IncludedRouters,
13
+ } from "./included.js";
9
14
  import { caTrusted, containerState } from "./runtime.js";
15
+ import { caDir } from "./tls.js";
10
16
  import type { EdgeConfig, EdgePorts } from "./types.js";
11
17
 
12
18
  export interface EdgeStatus {
@@ -23,8 +29,34 @@ export interface EdgeStatus {
23
29
  /** Names on the issued cert, or the planned ones when none is issued yet. */
24
30
  certNames: string[];
25
31
  certIssued: boolean;
26
- /** Whether the CA is trusted on this machine (null: can't tell). */
32
+ /** Whether the CA is trusted on this machine (null: can't tell, or none). */
27
33
  caTrusted: boolean | null;
34
+ /**
35
+ * The edge.tls strategy; whether nothing chose it (implicit mkcert);
36
+ * where it came from (edge.tls, the legacy caroot/issue_cert keys, or
37
+ * neither); and the CA directory (mkcert: the one it issues from; files:
38
+ * `ca` when set; none: null).
39
+ */
40
+ tls: {
41
+ strategy: EdgeConfig["tls"]["strategy"];
42
+ implicit: boolean;
43
+ from: EdgeConfig["tlsFrom"];
44
+ ca: string | null;
45
+ };
46
+ /**
47
+ * Routers in the included dynamic files (`edge include`), each with its
48
+ * source file: Traefik serves them, Den doesn't generate them.
49
+ */
50
+ included: IncludedRouters["routers"];
51
+ /** Included files that could not be parsed (listed, never fatal). */
52
+ includedErrors: IncludedRouters["errors"];
53
+ /**
54
+ * Routers in the stacks' route files (`stack-*.yml`, written by `stack
55
+ * up` into the edge's dynamic directory), each with its file (0.3.0-dev.32).
56
+ */
57
+ stacks: IncludedRouters["routers"];
58
+ /** Stack route files that could not be parsed (listed, never fatal). */
59
+ stackErrors: IncludedRouters["errors"];
28
60
  }
29
61
 
30
62
  export interface EdgeStatusDeps {
@@ -32,6 +64,10 @@ export interface EdgeStatusDeps {
32
64
  caTrusted: (cfg: EdgeConfig) => boolean | null;
33
65
  /** Names on the issued cert (its stamp file), or null when none is issued. */
34
66
  issuedNames: (cfg: EdgeConfig) => string[] | null;
67
+ /** The included files' routers (read from disk by default). */
68
+ included?: (cfg: EdgeConfig) => IncludedRouters;
69
+ /** The stacks' route files' routers (read from disk by default). */
70
+ stacks?: (cfg: EdgeConfig) => IncludedRouters;
35
71
  }
36
72
 
37
73
  /** The names stamp `ensureCerts` writes beside the cert. */
@@ -45,8 +81,19 @@ const defaultDeps: EdgeStatusDeps = {
45
81
  containerState,
46
82
  caTrusted,
47
83
  issuedNames: issuedCertNames,
84
+ included: includedRouters,
85
+ stacks: stackRouters,
48
86
  };
49
87
 
88
+ /** Den's own routes: `<name>.<domain>` → upstream (no Docker asked). */
89
+ export function edgeRoutes(cfg: EdgeConfig): EdgeStatus["routes"] {
90
+ return Object.entries(cfg.routes).map(([name, upstream]) => ({
91
+ name,
92
+ url: routeUrl(name, cfg),
93
+ upstream,
94
+ }));
95
+ }
96
+
50
97
  export function edgeStatus(
51
98
  cfg: EdgeConfig,
52
99
  deps: EdgeStatusDeps = defaultDeps,
@@ -54,6 +101,8 @@ export function edgeStatus(
54
101
  const n = edgeNames(cfg.org);
55
102
  const state = deps.containerState(n.container);
56
103
  const issued = deps.issuedNames(cfg);
104
+ const inc = (deps.included ?? includedRouters)(cfg);
105
+ const stk = (deps.stacks ?? stackRouters)(cfg);
57
106
  return {
58
107
  org: cfg.org,
59
108
  container: n.container,
@@ -62,13 +111,24 @@ export function edgeStatus(
62
111
  ports: cfg.ports,
63
112
  network: n.network,
64
113
  domains: edgeDomains(cfg),
65
- routes: Object.entries(cfg.routes).map(([name, upstream]) => ({
66
- name,
67
- url: routeUrl(name, cfg),
68
- upstream,
69
- })),
70
- certNames: issued ?? certNames(cfg),
114
+ routes: edgeRoutes(cfg),
115
+ certNames: issued ?? (cfg.tls.strategy === "none" ? [] : certNames(cfg)),
71
116
  certIssued: issued !== null,
72
- caTrusted: deps.caTrusted(cfg),
117
+ caTrusted: cfg.tls.strategy === "none" ? null : deps.caTrusted(cfg),
118
+ tls: {
119
+ strategy: cfg.tls.strategy,
120
+ implicit: cfg.tlsFrom === "implicit",
121
+ from: cfg.tlsFrom,
122
+ ca:
123
+ cfg.tls.strategy === "mkcert"
124
+ ? caDir(cfg)
125
+ : cfg.tls.strategy === "files"
126
+ ? cfg.tls.ca
127
+ : null,
128
+ },
129
+ included: inc.routers,
130
+ includedErrors: inc.errors,
131
+ stacks: stk.routers,
132
+ stackErrors: stk.errors,
73
133
  };
74
134
  }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * The edge's TLS strategies (TLS-1a), registered through the generic
3
+ * strategy registry (src/shared/strategies/registry.ts) as kind "tls",
4
+ * exactly as a plugin would register one: `{ id, kind, detect(),
5
+ * capabilities }`.
6
+ *
7
+ * - `none`: http only. No https entrypoint, no cert work, no mkcert.
8
+ * - `mkcert`: the edge issues its own wildcard cert from a local CA, its
9
+ * own under the state dir (minted on first use) or `ca`, an existing one
10
+ * it never mints or uninstalls. The only strategy with `trust`/`untrust`.
11
+ * - `files`: the distribution's included dynamic config and mounts hold the
12
+ * certs; Den issues none. `ca` names the CA that signed them, so health
13
+ * checks trust it.
14
+ *
15
+ * `secret` (cert and key from the selected secrets strategy) and `acme` are
16
+ * later plugins; they register here the same way.
17
+ *
18
+ * Capabilities:
19
+ * - `https`: the edge has an https entrypoint;
20
+ * - `issuesCert`: Den issues the edge's own cert (the TLS store it renders);
21
+ * - `ensure(cfg)`: make sure the cert exists for certNames(cfg);
22
+ * - `trust(cfg)` / `untrust(cfg)`: mkcert only;
23
+ * - `status(cfg)`: the names on the cert and whether its CA is trusted.
24
+ */
25
+ import * as fs from "fs";
26
+ import * as path from "path";
27
+ import {
28
+ getStrategy,
29
+ onPath,
30
+ registerStrategy,
31
+ strategyIds,
32
+ type DetectResult,
33
+ type StrategyEntry,
34
+ } from "../strategies/registry.js";
35
+ import { must, sh } from "./exec.js";
36
+ import { certNames } from "./render.js";
37
+ import type { EdgeConfig, EdgeTls } from "./types.js";
38
+
39
+ export const TLS_KIND = "tls";
40
+
41
+ export type TlsEnsureResult =
42
+ | {
43
+ kind: "cert";
44
+ certFile: string;
45
+ keyFile: string;
46
+ issued: boolean;
47
+ names: string[];
48
+ }
49
+ | { kind: "dynamic" | "none"; issued: false; names: [] };
50
+
51
+ export interface TlsCapabilities {
52
+ label: string;
53
+ https: boolean;
54
+ issuesCert: boolean;
55
+ ensure(cfg: EdgeConfig): TlsEnsureResult;
56
+ trust?(cfg: EdgeConfig): number;
57
+ untrust?(cfg: EdgeConfig): number;
58
+ status(cfg: EdgeConfig): { names: string[]; trusted: boolean | null };
59
+ }
60
+
61
+ export type TlsStrategy = StrategyEntry<typeof TLS_KIND, TlsCapabilities>;
62
+
63
+ // ── The CA ───────────────────────────────────────────────────────────────────
64
+
65
+ /** The configured CA (`edge.tls.ca`, formerly `edge.caroot`), or null. */
66
+ export function tlsCa(cfg: EdgeConfig): string | null {
67
+ return cfg.tls.strategy === "none" ? null : cfg.tls.ca;
68
+ }
69
+
70
+ export function caDir(cfg: EdgeConfig): string {
71
+ return tlsCa(cfg) ?? path.join(cfg.stateDir, "ca");
72
+ }
73
+
74
+ /** True when the edge uses an existing CA (`ca`) rather than its own. */
75
+ export function sharedCa(cfg: EdgeConfig): boolean {
76
+ return tlsCa(cfg) !== null;
77
+ }
78
+
79
+ /**
80
+ * A shared CA must already exist with its key: mkcert silently creates a new
81
+ * CA in an empty CAROOT, and devices that trust the shared one would not
82
+ * trust that. The org's own CA may be created on first use.
83
+ */
84
+ export function assertCaUsable(cfg: EdgeConfig): void {
85
+ if (!sharedCa(cfg)) return;
86
+ const dir = caDir(cfg);
87
+ if (!fs.existsSync(dir))
88
+ throw new Error(
89
+ `edge.tls.ca ${dir} does not exist. The edge never creates a CA there; ` +
90
+ `point it at the existing CA (\`mkcert -CAROOT\`).`,
91
+ );
92
+ for (const f of ["rootCA.pem", "rootCA-key.pem"])
93
+ if (!fs.existsSync(path.join(dir, f)))
94
+ throw new Error(
95
+ `edge.tls.ca ${dir} has no ${f}. Refusing to run: mkcert would mint a ` +
96
+ `new CA that other devices don't trust.`,
97
+ );
98
+ }
99
+
100
+ /** Whether the CA is in the macOS system keychain (null: can't tell). */
101
+ export function caTrusted(cfg: EdgeConfig): boolean | null {
102
+ if (cfg.tls.strategy === "none") return null;
103
+ const pem = path.join(caDir(cfg), "rootCA.pem");
104
+ if (!fs.existsSync(pem)) return false;
105
+ if (process.platform !== "darwin") return null;
106
+ return sh("security", ["verify-cert", "-c", pem]).code === 0;
107
+ }
108
+
109
+ // ── mkcert ───────────────────────────────────────────────────────────────────
110
+
111
+ export const MKCERT_MISSING = "mkcert is not installed";
112
+
113
+ /** detect() first: a missing mkcert is a reason, never a spawn error. */
114
+ function requireMkcert(): void {
115
+ const d = mkcert.detect();
116
+ if (!d.available)
117
+ throw new Error(
118
+ `${d.reason}. Install it (https://github.com/FiloSottile/mkcert, e.g. ` +
119
+ "`brew install mkcert`), or set edge.tls to none or files.",
120
+ );
121
+ }
122
+
123
+ function mkcertEnv(cfg: EdgeConfig): NodeJS.ProcessEnv {
124
+ return { CAROOT: caDir(cfg) };
125
+ }
126
+
127
+ const NO_CERT = { issued: false, names: [] } as const;
128
+
129
+ const none: TlsStrategy = {
130
+ id: "none",
131
+ kind: TLS_KIND,
132
+ detect: () => ({ available: true, reason: "http only" }),
133
+ capabilities: {
134
+ label: "none (http only)",
135
+ https: false,
136
+ issuesCert: false,
137
+ ensure: () => ({ kind: "none", ...NO_CERT, names: [] }),
138
+ status: () => ({ names: [], trusted: null }),
139
+ },
140
+ };
141
+
142
+ const mkcert: TlsStrategy = {
143
+ id: "mkcert",
144
+ kind: TLS_KIND,
145
+ detect: (env: NodeJS.ProcessEnv = process.env): DetectResult =>
146
+ onPath("mkcert", env)
147
+ ? { available: true }
148
+ : { available: false, reason: MKCERT_MISSING },
149
+ capabilities: {
150
+ label: "mkcert",
151
+ https: true,
152
+ issuesCert: true,
153
+ /** Issue (or re-issue when names changed) the edge cert from the CA. */
154
+ ensure(cfg) {
155
+ const names = certNames(cfg);
156
+ assertCaUsable(cfg);
157
+ const dir = path.join(cfg.stateDir, "certs");
158
+ const certFile = path.join(dir, "edge.pem");
159
+ const keyFile = path.join(dir, "edge-key.pem");
160
+ const stamp = path.join(dir, "names.txt");
161
+ const want = names.join("\n");
162
+ if (
163
+ fs.existsSync(certFile) &&
164
+ fs.existsSync(stamp) &&
165
+ fs.readFileSync(stamp, "utf-8") === want
166
+ )
167
+ return { kind: "cert", certFile, keyFile, issued: false, names };
168
+ requireMkcert();
169
+ fs.mkdirSync(dir, { recursive: true });
170
+ if (!sharedCa(cfg))
171
+ fs.mkdirSync(caDir(cfg), { recursive: true, mode: 0o700 });
172
+ must("mkcert", ["-cert-file", certFile, "-key-file", keyFile, ...names], {
173
+ env: mkcertEnv(cfg),
174
+ });
175
+ fs.mkdirSync(path.dirname(stamp), { recursive: true });
176
+ fs.writeFileSync(stamp, want, "utf-8");
177
+ return { kind: "cert", certFile, keyFile, issued: true, names };
178
+ },
179
+ /** Install the CA into the system + browser trust stores (prompts). */
180
+ trust(cfg) {
181
+ assertCaUsable(cfg);
182
+ requireMkcert();
183
+ if (!sharedCa(cfg))
184
+ fs.mkdirSync(caDir(cfg), { recursive: true, mode: 0o700 });
185
+ return sh("mkcert", ["-install"], { env: mkcertEnv(cfg), inherit: true })
186
+ .code;
187
+ },
188
+ untrust(cfg) {
189
+ if (sharedCa(cfg))
190
+ throw new Error(
191
+ `edge.tls.ca is a shared CA (${caDir(cfg)}); other tools and devices ` +
192
+ `rely on it being trusted, so the edge never uninstalls it.`,
193
+ );
194
+ requireMkcert();
195
+ return sh("mkcert", ["-uninstall"], {
196
+ env: mkcertEnv(cfg),
197
+ inherit: true,
198
+ }).code;
199
+ },
200
+ status: (cfg) => ({ names: certNames(cfg), trusted: caTrusted(cfg) }),
201
+ },
202
+ };
203
+
204
+ const files: TlsStrategy = {
205
+ id: "files",
206
+ kind: TLS_KIND,
207
+ detect: () => ({ available: true }),
208
+ capabilities: {
209
+ label: "files (included config)",
210
+ https: true,
211
+ issuesCert: false,
212
+ ensure: () => ({ kind: "dynamic", ...NO_CERT, names: [] }),
213
+ status: (cfg) => ({ names: [], trusted: caTrusted(cfg) }),
214
+ },
215
+ };
216
+
217
+ for (const s of [none, mkcert, files]) registerStrategy(s);
218
+
219
+ /** Every registered TLS strategy id (built-ins first). */
220
+ export function tlsStrategyIds(): string[] {
221
+ return strategyIds(TLS_KIND);
222
+ }
223
+
224
+ /** The registered TLS strategy `id`, or undefined. */
225
+ export function tlsStrategy(id: string): TlsStrategy | undefined {
226
+ return getStrategy<typeof TLS_KIND, TlsCapabilities>(TLS_KIND, id);
227
+ }
228
+
229
+ /** The strategy the edge config selects (always registered once parsed). */
230
+ export function tlsStrategyFor(cfg: EdgeConfig): TlsStrategy {
231
+ const s = tlsStrategy(cfg.tls.strategy);
232
+ if (!s)
233
+ throw new Error(`TLS strategy "${cfg.tls.strategy}" is not registered`);
234
+ return s;
235
+ }
236
+
237
+ /** True when the edge serves https (every strategy but none). */
238
+ export function servesHttps(cfg: EdgeConfig): boolean {
239
+ return cfg.tls.strategy !== "none";
240
+ }
241
+
242
+ /** True when Den issues the edge's own cert (mkcert). */
243
+ export function issuesCert(cfg: EdgeConfig): boolean {
244
+ return cfg.tls.strategy === "mkcert";
245
+ }
246
+
247
+ const TRUST_NEEDS_MKCERT =
248
+ "edge trust needs edge.tls.strategy: mkcert (or files with a ca)";
249
+
250
+ function trustRefusal(cfg: EdgeConfig, verb: "trust" | "untrust"): Error {
251
+ return new Error(
252
+ `${TRUST_NEEDS_MKCERT.replace("trust", verb)} (this edge's is ${cfg.tls.strategy}: ` +
253
+ (cfg.tls.strategy === "none"
254
+ ? "it serves http only, there is no CA to trust)."
255
+ : "its certs come from your included config; trust their CA yourself)."),
256
+ );
257
+ }
258
+
259
+ /**
260
+ * Trust capabilities for the edge: mkcert's own, or, for `files` with an
261
+ * existing `ca` (the legacy caroot + issue_cert: false shape), mkcert's
262
+ * against that CA: `edge trust` installs it, as caroot always did, and
263
+ * untrust refuses it as a shared CA.
264
+ */
265
+ function trustCapabilities(cfg: EdgeConfig): TlsCapabilities | null {
266
+ const c = tlsStrategyFor(cfg).capabilities;
267
+ if (c.trust) return c;
268
+ if (cfg.tls.strategy === "files" && sharedCa(cfg)) return mkcert.capabilities;
269
+ return null;
270
+ }
271
+
272
+ /** `edge trust`: mkcert, or files with an existing ca. */
273
+ export function edgeTrust(cfg: EdgeConfig): number {
274
+ const t = trustCapabilities(cfg)?.trust;
275
+ if (!t) throw trustRefusal(cfg, "trust");
276
+ return t(cfg);
277
+ }
278
+
279
+ /** `edge untrust`: mkcert only, and never for a shared CA. */
280
+ export function edgeUntrust(cfg: EdgeConfig): number {
281
+ const u = trustCapabilities(cfg)?.untrust;
282
+ if (!u) throw trustRefusal(cfg, "untrust");
283
+ return u(cfg);
284
+ }
285
+
286
+ // ── Config warnings ──────────────────────────────────────────────────────────
287
+
288
+ export const IMPLICIT_TLS_WARNING =
289
+ "edge.tls is not set; defaulting to mkcert with a local CA. Set edge.tls explicitly.";
290
+
291
+ /** The explicit `edge.tls` line that says what `tls` says. */
292
+ export function tlsYaml(tls: EdgeTls): string {
293
+ if (tls.strategy === "none") return "tls: none";
294
+ const ca = tls.ca ? `, ca: ${tls.ca}` : "";
295
+ if (tls.strategy === "mkcert") return `tls: { strategy: mkcert${ca} }`;
296
+ return `tls: { strategy: files${ca}, include: <edge.include>, mounts: <edge.mounts> }`;
297
+ }
298
+
299
+ /** Config warnings about the edge's TLS, for `validate` and `doctor`. */
300
+ export function edgeTlsWarnings(cfg: EdgeConfig): string[] {
301
+ if (cfg.tlsFrom === "implicit") return [IMPLICIT_TLS_WARNING];
302
+ if (cfg.tlsFrom === "legacy")
303
+ return [
304
+ "edge.caroot and edge.issue_cert are deprecated; they still work. " +
305
+ `Say the same with edge.${tlsYaml(cfg.tls)}`,
306
+ ];
307
+ return [];
308
+ }
309
+
310
+ let warnedImplicit = false;
311
+
312
+ /**
313
+ * The implicit-mkcert warning, at most once per run: from `validate` and
314
+ * from the paths that would mint a cert (`edge up`, `stack up`).
315
+ */
316
+ export function warnImplicitTlsOnce(
317
+ cfg: EdgeConfig,
318
+ warn: (line: string) => void = (l) => console.warn(`⚠️ ${l}`),
319
+ ): void {
320
+ if (cfg.tlsFrom !== "implicit" || warnedImplicit) return;
321
+ warnedImplicit = true;
322
+ warn(IMPLICIT_TLS_WARNING);
323
+ }
324
+
325
+ /** Test seam: forget that the warning was given. */
326
+ export function __resetTlsWarningForTests(): void {
327
+ warnedImplicit = false;
328
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The org's HTTPS edge: one Traefik that terminates TLS with the org's own CA
3
- * (mkcert, separate CAROOT) and routes `<name>.<domain>` to local services.
2
+ * The org's edge: one Traefik that terminates TLS as `edge.tls` says (none,
3
+ * mkcert or files) and routes `<name>.<domain>` to local services.
4
4
  * File-provider routes, plus Docker labels when `docker_labels` opts in
5
5
  * (through a read-only socket proxy; the edge never mounts the socket).
6
6
  *
@@ -15,6 +15,11 @@ export interface EdgePorts {
15
15
  https: number;
16
16
  }
17
17
 
18
+ export type EdgeTls =
19
+ | { strategy: "none" }
20
+ | { strategy: "mkcert"; ca: string | null }
21
+ | { strategy: "files"; ca: string | null };
22
+
18
23
  export interface EdgeConfig {
19
24
  /** Org slug — names the edge container and its Docker network. */
20
25
  org: string;
@@ -35,14 +40,25 @@ export interface EdgeConfig {
35
40
  /** Where the org's CA, certs and generated config live. */
36
41
  stateDir: string;
37
42
  /**
38
- * An existing mkcert CA to use instead of the org's own under `stateDir`
39
- * (e.g. a CA other devices already trust). The edge refuses to run if its
40
- * key is missing, so mkcert can never mint a replacement, and it never
41
- * uninstalls a shared CA.
43
+ * The state dir the config names, when DEN_EDGE_DIR overrides it with
44
+ * another (0.3.0-dev.40): an unlabelled edge is adopted only from that one.
45
+ */
46
+ defaultStateDir?: string;
47
+ /**
48
+ * How the edge gets its certificates: the `edge.tls` strategy (see
49
+ * tls.ts). `none`: http only, no https entrypoint. `mkcert`: the edge
50
+ * issues its own wildcard cert from a local CA (its own under `stateDir`,
51
+ * or `ca`, an existing one it never mints or uninstalls). `files`: the
52
+ * included dynamic config and mounts hold the certs; `ca` is the CA that
53
+ * signed them, if Den should trust it for health checks.
54
+ */
55
+ tls: EdgeTls;
56
+ /**
57
+ * Where `tls` came from: `explicit` (edge.tls), `legacy` (edge.caroot /
58
+ * edge.issue_cert, mapped), or `implicit` (neither: mkcert with a local CA,
59
+ * deprecated, warned once per run).
42
60
  */
43
- caroot: string | null;
44
- /** Issue the edge's own wildcard cert. Off when included config holds the certs. */
45
- issueCert: boolean;
61
+ tlsFrom: "explicit" | "legacy" | "implicit";
46
62
  /** Catch-all HTTP→HTTPS redirect. Off when included config decides per name. */
47
63
  httpsRedirect: boolean;
48
64
  /**
@@ -73,6 +73,16 @@ function blueprintServices(
73
73
  return services;
74
74
  }
75
75
 
76
+ /**
77
+ * The blueprint's `stack_namespace`, carried into the seeded workspace so
78
+ * its stacks keep that namespace on this machine ({} when it sets none).
79
+ */
80
+ function blueprintStackNamespace(
81
+ bp: ResolvedWorkspaceBlueprint,
82
+ ): Pick<WorkspaceConfig, "stack_namespace"> {
83
+ return bp.stackNamespace ? { stack_namespace: bp.stackNamespace } : {};
84
+ }
85
+
76
86
  /**
77
87
  * The clone slug seeding would give each repo the catalog has no `gh` for:
78
88
  * `<org>/<repo>` with its workspace's resolved org. A repo in several
@@ -174,7 +184,8 @@ function sameEntry(a: unknown, b: unknown): boolean {
174
184
  /**
175
185
  * Whether seeding now would change nothing: every workspace the blueprint
176
186
  * seeds holds exactly what seeding writes (same repos, all managed on the
177
- * blueprint's branch, same autostart defaults), records the blueprint as
187
+ * blueprint's branch, same autostart defaults, its stack_namespace),
188
+ * records the blueprint as
178
189
  * applied, and (multi blueprint) is active. A workspace the user changed
179
190
  * since (a repo switched to a local checkout, one added or removed) is not
180
191
  * "already seeded": seeding would reset it.
@@ -196,6 +207,8 @@ export function blueprintAlreadySeeded(
196
207
  bp.repos.some((r, i) => applied[i] !== r)
197
208
  )
198
209
  return false;
210
+ if (bp.stackNamespace && ws.stack_namespace !== bp.stackNamespace)
211
+ return false;
199
212
  return sameEntry(ws.services ?? {}, blueprintServices(bp));
200
213
  });
201
214
  }
@@ -222,6 +235,7 @@ export function seedDefaultWorkspace(): string[] {
222
235
  ...(file.workspaces.default ?? {}),
223
236
  services: blueprintServices(bp),
224
237
  seeded_repos: [...repos],
238
+ ...blueprintStackNamespace(bp),
225
239
  };
226
240
  if (!file.active?.length) {
227
241
  file.active = ["default"];
@@ -248,6 +262,7 @@ export function seedBlueprintWorkspaces(): { name: string; repos: string[] }[] {
248
262
  ...(prev ?? {}),
249
263
  services: blueprintServices(bp),
250
264
  seeded_repos: [...bp.repos],
265
+ ...blueprintStackNamespace(bp),
251
266
  };
252
267
  }
253
268
  const names = bps.map((b) => b.name);