okengine 0.2.8 → 0.3.4

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 (180) hide show
  1. package/AGENTS.md +17 -15
  2. package/README.md +59 -31
  3. package/package.json +11 -16
  4. package/site/content/docs/ai/llms-txt.mdx +54 -0
  5. package/site/content/docs/ai/mcp.mdx +123 -0
  6. package/site/content/docs/ai/meta.json +5 -0
  7. package/site/content/docs/ai/skills.mdx +53 -0
  8. package/site/content/docs/console/access.mdx +29 -0
  9. package/site/content/docs/console/ai.mdx +35 -0
  10. package/site/content/docs/console/architecture.mdx +35 -0
  11. package/site/content/docs/console/channels.mdx +37 -0
  12. package/site/content/docs/console/clock.mdx +31 -0
  13. package/site/content/docs/console/flows.mdx +31 -0
  14. package/site/content/docs/console/gates.mdx +35 -0
  15. package/site/content/docs/console/manifest-diff.mdx +34 -0
  16. package/site/content/docs/console/meta.json +23 -0
  17. package/site/content/docs/console/overview.mdx +40 -0
  18. package/site/content/docs/console/plugins.mdx +41 -0
  19. package/site/content/docs/console/privacy.mdx +32 -0
  20. package/site/content/docs/console/runs.mdx +40 -0
  21. package/site/content/docs/console/signals.mdx +31 -0
  22. package/site/content/docs/console/store.mdx +32 -0
  23. package/site/content/docs/console/tenancy.mdx +32 -0
  24. package/site/content/docs/console/traces.mdx +34 -0
  25. package/site/content/docs/console/vault.mdx +37 -0
  26. package/site/content/docs/elements/ai.mdx +180 -0
  27. package/site/content/docs/elements/channel.mdx +167 -0
  28. package/site/content/docs/elements/clock.mdx +182 -0
  29. package/site/content/docs/elements/flow.mdx +288 -0
  30. package/site/content/docs/elements/gate.mdx +171 -0
  31. package/site/content/docs/elements/meta.json +5 -0
  32. package/site/content/docs/elements/signal.mdx +171 -0
  33. package/site/content/docs/elements/store.mdx +320 -0
  34. package/site/content/docs/elements/vault.mdx +263 -0
  35. package/site/content/docs/get-started/basic-usage.mdx +124 -0
  36. package/site/content/docs/get-started/comparison.mdx +65 -0
  37. package/site/content/docs/get-started/installation.mdx +113 -0
  38. package/site/content/docs/get-started/introduction.mdx +123 -0
  39. package/site/content/docs/get-started/meta.json +5 -0
  40. package/site/content/docs/index.mdx +63 -0
  41. package/site/content/docs/meta.json +5 -0
  42. package/site/content/docs/plugins/compression.mdx +60 -0
  43. package/site/content/docs/plugins/cors.mdx +98 -0
  44. package/site/content/docs/plugins/csrf.mdx +96 -0
  45. package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
  46. package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
  47. package/site/content/docs/plugins/meta.json +15 -0
  48. package/site/content/docs/plugins/security-headers.mdx +136 -0
  49. package/site/content/docs/reference/cli.md +101 -0
  50. package/site/content/docs/reference/configuration.mdx +159 -0
  51. package/site/content/docs/reference/environment-variables.mdx +87 -0
  52. package/site/content/docs/reference/errors.mdx +80 -0
  53. package/site/content/docs/reference/fx.mdx +117 -0
  54. package/site/content/docs/reference/meta.json +5 -0
  55. package/site/content/docs/reference/plugins.mdx +249 -0
  56. package/site/content/docs/reference/security.md +63 -0
  57. package/src/auth/auth.test.ts +3 -0
  58. package/src/cli/ask-dev-mode.ts +1 -1
  59. package/src/cli/db.ts +87 -17
  60. package/src/cli/dev-db-push.test.ts +32 -2
  61. package/src/cli/dev-schema-sync.test.ts +66 -0
  62. package/src/cli/dev-schema-sync.ts +139 -0
  63. package/src/cli/dev.test.ts +123 -1
  64. package/src/cli/dev.ts +150 -8
  65. package/src/cli/doc-staleness.test.ts +4 -4
  66. package/src/cli/docker-cli.test.ts +20 -0
  67. package/src/cli/docker.ts +10 -0
  68. package/src/cli/drizzle-env.test.ts +67 -0
  69. package/src/cli/drizzle-env.ts +78 -0
  70. package/src/cli/ensure-drizzle-config.ts +50 -0
  71. package/src/cli/hero-meta.test.ts +1 -1
  72. package/src/cli/load-config.ts +6 -0
  73. package/src/cli/mode.ts +24 -4
  74. package/src/cli/openbao-bootstrap.test.ts +149 -0
  75. package/src/cli/openbao-bootstrap.ts +314 -0
  76. package/src/cli/openbao-restart.integration.test.ts +136 -0
  77. package/src/cli/ports.test.ts +7 -5
  78. package/src/cli/ports.ts +6 -2
  79. package/src/cli/resolve-dev-sql-env.test.ts +48 -0
  80. package/src/cli/resolve-dev-sql-env.ts +42 -0
  81. package/src/cli/stack.ts +7 -4
  82. package/src/cli/vault-cmd.ts +63 -0
  83. package/src/client/types.ts +7 -1
  84. package/src/compiler/extract.test.ts +40 -0
  85. package/src/compiler/extract.ts +123 -1
  86. package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
  87. package/src/compiler/fixtures/skyport.expected.json +1 -1
  88. package/src/compiler/response.ts +12 -0
  89. package/src/config/define-config.test.ts +4 -6
  90. package/src/config/index.ts +2 -15
  91. package/src/console/server/app.ts +2 -0
  92. package/src/console/server/vault.ts +21 -6
  93. package/src/docker/compose.ts +112 -16
  94. package/src/docker/derive.ts +7 -1
  95. package/src/docker/docker.test.ts +103 -0
  96. package/src/docker/index.ts +11 -1
  97. package/src/docker/recipes/index.ts +3 -2
  98. package/src/docker/recipes/openbao.ts +47 -0
  99. package/src/docker/recipes/redis.ts +5 -1
  100. package/src/docker/recipes/rustfs.ts +2 -3
  101. package/src/docker/stack-id.test.ts +43 -8
  102. package/src/docker/stack-id.ts +99 -20
  103. package/src/docker/stack.ts +36 -4
  104. package/src/docker/types.ts +3 -0
  105. package/src/docs-origin.ts +4 -4
  106. package/src/drivers/drizzle-dialect.test.ts +20 -0
  107. package/src/drivers/drizzle-dialect.ts +37 -0
  108. package/src/drivers/index.ts +1 -2
  109. package/src/drivers/memory.ts +278 -39
  110. package/src/drivers/s3.ts +10 -1
  111. package/src/drivers/vault-driver-removal.test.ts +55 -0
  112. package/src/drivers/vault-openbao.test.ts +97 -0
  113. package/src/drivers/vault-openbao.ts +102 -35
  114. package/src/drivers/vault-types.ts +3 -10
  115. package/src/elements/store/declare.ts +4 -1
  116. package/src/elements/store/resource-list-docs.fixture.ts +56 -0
  117. package/src/elements/store/resource-list-docs.test.ts +79 -0
  118. package/src/elements/store/resource.test.ts +253 -0
  119. package/src/elements/store/resource.ts +786 -0
  120. package/src/elements/store/sql-condition.test.ts +132 -0
  121. package/src/elements/store/sql-condition.ts +284 -46
  122. package/src/elements/store/sql-session.test.ts +86 -1
  123. package/src/elements/store/sql-session.ts +187 -27
  124. package/src/elements/store/table.ts +34 -4
  125. package/src/elements/store.ts +16 -0
  126. package/src/elements/vault/runtime.ts +1 -1
  127. package/src/elements/vault.test.ts +1 -28
  128. package/src/elements/vault.ts +1 -1
  129. package/src/kernel/app.ts +59 -25
  130. package/src/kernel/boot-bind/channel.test.ts +60 -0
  131. package/src/kernel/boot-bind/channel.ts +64 -2
  132. package/src/kernel/boot-bind/store.test.ts +10 -1
  133. package/src/kernel/boot-bind/store.ts +49 -2
  134. package/src/kernel/boot.test.ts +0 -1
  135. package/src/kernel/boot.ts +1 -1
  136. package/src/kernel/edge.test.ts +68 -0
  137. package/src/kernel/errors.registry.test.ts +1 -1
  138. package/src/kernel/flow.ts +8 -0
  139. package/src/kernel/fx.test.ts +23 -3
  140. package/src/kernel/fx.ts +115 -18
  141. package/src/kernel/hooks.test.ts +33 -0
  142. package/src/kernel/hooks.ts +22 -0
  143. package/src/kernel/index.ts +7 -0
  144. package/src/kernel/on.ts +44 -3
  145. package/src/kernel/plugin.ts +33 -3
  146. package/src/kernel/registry-isolation.test.ts +74 -0
  147. package/src/kernel/registry.ts +22 -1
  148. package/src/kernel/triggers.ts +59 -0
  149. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  150. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  151. package/src/manifest/index.ts +1 -1
  152. package/src/manifest/types.ts +1 -1
  153. package/src/manifest/validate.ts +2 -2
  154. package/src/plugins/compression.test.ts +127 -0
  155. package/src/plugins/compression.ts +94 -0
  156. package/src/plugins/config-source.test.ts +204 -0
  157. package/src/plugins/config-source.ts +209 -0
  158. package/src/plugins/cors.test.ts +172 -0
  159. package/src/plugins/cors.ts +153 -0
  160. package/src/plugins/csrf.test.ts +102 -0
  161. package/src/plugins/csrf.ts +86 -0
  162. package/src/plugins/headers.ts +54 -0
  163. package/src/plugins/index.ts +26 -0
  164. package/src/plugins/ip-allowlist.test.ts +139 -0
  165. package/src/plugins/ip-allowlist.ts +129 -0
  166. package/src/plugins/maintenance-mode.test.ts +91 -0
  167. package/src/plugins/maintenance-mode.ts +85 -0
  168. package/src/plugins/security-headers.test.ts +243 -0
  169. package/src/plugins/security-headers.ts +255 -0
  170. package/src/release/measure.ts +1 -2
  171. package/src/test/create-test-app.ts +14 -2
  172. package/docs/spec/console.md +0 -762
  173. package/docs/spec/example.md +0 -1374
  174. package/docs/spec/four-applications.md +0 -1376
  175. package/docs/spec/unified-theory.md +0 -498
  176. package/src/cli/doc-drift.test.ts +0 -147
  177. package/src/cli/doc-drift.ts +0 -401
  178. package/src/cli/doctor-diff-examples.ts +0 -90
  179. package/src/drivers/vault-sops.ts +0 -246
  180. /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
@@ -18,7 +18,7 @@ import type {
18
18
  } from "./types.ts";
19
19
  import { DEFAULT_DOCKER_DIR } from "./types.ts";
20
20
  import { generateCredentials } from "./credentials.ts";
21
- import { hostPortForInstance, instancePortOffset } from "./stack-id.ts";
21
+ import { extraHostPortForInstance, hostPortForInstance, STACK_CONTROL_KEYS } from "./stack-id.ts";
22
22
  import { APP_PORT } from "../runtime/types.ts";
23
23
 
24
24
  /** Canonical layer-4 filename — never written by derivation. */
@@ -110,13 +110,17 @@ export function emitComposeLayers(
110
110
  files.push({ path: "compose.yml", content: `${toYaml(base)}\n` });
111
111
 
112
112
  // Layer 2 — per-role
113
- const extraHostOffset = options.instanceId ? instancePortOffset(options.instanceId) : 0;
114
113
  for (const spec of specs) {
115
114
  const recipe = recipeFor(spec.image, recipes);
116
115
  const applied = recipe.apply(spec);
117
116
  const ports = [
118
117
  `${spec.hostPort}:${spec.port}`,
119
- ...(applied.extraPorts ?? []).map((p) => `${p.host + extraHostOffset}:${p.container}`),
118
+ ...(applied.extraPorts ?? []).map((p) => {
119
+ const hostPort = options.instanceId
120
+ ? extraHostPortForInstance(spec.role, p.host, options.instanceId)
121
+ : p.host;
122
+ return `${hostPort}:${p.container}`;
123
+ }),
120
124
  ];
121
125
  const service: Record<string, unknown> = {
122
126
  image: spec.image,
@@ -126,6 +130,7 @@ export function emitComposeLayers(
126
130
  };
127
131
  if (applied.environment) service.environment = applied.environment;
128
132
  if (applied.command) service.command = applied.command;
133
+ if (applied.entrypoint) service.entrypoint = applied.entrypoint;
129
134
  if (applied.healthcheck) service.healthcheck = applied.healthcheck;
130
135
  if (applied.volumes) service.volumes = applied.volumes;
131
136
  if (applied.user) service.user = applied.user;
@@ -194,13 +199,12 @@ export function buildStackEnv(
194
199
  specs: readonly ServiceSpec[],
195
200
  recipes: readonly ImageRecipe[] = [],
196
201
  host = "127.0.0.1",
202
+ controls: Readonly<Record<string, string>> = {},
203
+ instanceId?: string,
197
204
  ): Record<string, string> {
198
205
  const env: Record<string, string> = {};
199
206
  for (const spec of specs) {
200
207
  const prefix = envPrefix(spec.role);
201
- env[`${prefix}_USER`] = spec.credentials.user;
202
- env[`${prefix}_PASSWORD`] = spec.credentials.password;
203
- env[`${prefix}_DB`] = spec.credentials.database;
204
208
  const recipe = recipeFor(spec.image, recipes);
205
209
  const url = recipe.url(spec, {
206
210
  host,
@@ -209,10 +213,53 @@ export function buildStackEnv(
209
213
  password: spec.credentials.password,
210
214
  database: spec.credentials.database,
211
215
  });
212
- env[`${prefix}_URL`] = url;
213
- // Friendly alias for the common sql role
214
- if (spec.role === "store.sql") env.DATABASE_URL = url;
215
- if (spec.role === "store.kv") env.REDIS_URL = url;
216
+ const applied = recipe.apply(spec);
217
+ const uiExtra = applied.extraPorts?.[0];
218
+ const uiHost =
219
+ uiExtra === undefined
220
+ ? undefined
221
+ : instanceId
222
+ ? extraHostPortForInstance(spec.role, uiExtra.host, instanceId)
223
+ : uiExtra.host;
224
+ if (spec.role === "store.sql") {
225
+ env[`${prefix}_USER`] = spec.credentials.user;
226
+ env[`${prefix}_PASSWORD`] = spec.credentials.password;
227
+ env[`${prefix}_DB`] = spec.credentials.database;
228
+ env[`${prefix}_URL`] = url;
229
+ env.DATABASE_URL = url;
230
+ } else if (spec.role === "store.kv") {
231
+ env[`${prefix}_PASSWORD`] = spec.credentials.password;
232
+ env[`${prefix}_URL`] = url;
233
+ env.REDIS_URL = url;
234
+ } else if (spec.role === "store.files") {
235
+ env[`${prefix}_URL`] = url;
236
+ env.S3_ACCESS_KEY_ID = spec.credentials.user;
237
+ env.S3_SECRET_ACCESS_KEY = spec.credentials.password;
238
+ env.S3_BUCKET = spec.credentials.database;
239
+ env.S3_URL = url;
240
+ env.S3_ENDPOINT = new URL(url).origin;
241
+ env.S3_REGION = "us-east-1";
242
+ if (uiHost !== undefined) env.S3_CONSOLE_URL = `http://${host}:${uiHost}`;
243
+ } else if (spec.role === "channel.email") {
244
+ env[`${prefix}_URL`] = url;
245
+ env.SMTP_URL = url;
246
+ env.SMTP_HOST = host;
247
+ env.SMTP_PORT = String(spec.hostPort);
248
+ if (uiHost !== undefined) env.MAILPIT_UI_URL = `http://${host}:${uiHost}`;
249
+ } else if (spec.role === "vault") {
250
+ // Token is minted by the bootstrap — never a generated password here.
251
+ env[`${prefix}_URL`] = url;
252
+ env.OKE_VAULT_URL = url;
253
+ } else {
254
+ env[`${prefix}_USER`] = spec.credentials.user;
255
+ env[`${prefix}_PASSWORD`] = spec.credentials.password;
256
+ env[`${prefix}_DB`] = spec.credentials.database;
257
+ env[`${prefix}_URL`] = url;
258
+ }
259
+ }
260
+ for (const key of STACK_CONTROL_KEYS) {
261
+ const value = controls[key];
262
+ if (value !== undefined) env[key] = value;
216
263
  }
217
264
  return env;
218
265
  }
@@ -225,12 +272,48 @@ const ROLE_SECTION_TITLE: Readonly<Record<string, string>> = {
225
272
  "store.index": "store.index — search index",
226
273
  "channel.email": "channel.email — Mailpit (SMTP + UI)",
227
274
  signal: "signal — message bus",
275
+ vault: "vault — OpenBao",
228
276
  };
229
277
 
230
278
  /** Friendly aliases emitted beside their role block. */
231
279
  const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
232
- "store.sql": ["DATABASE_URL"],
233
- "store.kv": ["REDIS_URL"],
280
+ "store.sql": ["DATABASE_URL", "PGDATA", "POSTGRES_INITDB_ARGS"],
281
+ "store.kv": ["REDIS_URL", "OKE_STORE_KV_MAXMEMORY", "OKE_STORE_KV_MAXMEMORY_POLICY"],
282
+ "store.files": [
283
+ "S3_ACCESS_KEY_ID",
284
+ "S3_SECRET_ACCESS_KEY",
285
+ "S3_BUCKET",
286
+ "S3_URL",
287
+ "S3_ENDPOINT",
288
+ "S3_CONSOLE_URL",
289
+ "S3_REGION",
290
+ "S3_SESSION_TOKEN",
291
+ ],
292
+ "channel.email": [
293
+ "SMTP_URL",
294
+ "SMTP_HOST",
295
+ "SMTP_PORT",
296
+ "MAILPIT_UI_URL",
297
+ "SMTP_USER",
298
+ "SMTP_PASSWORD",
299
+ "MP_MAX_MESSAGES",
300
+ "MP_SMTP_AUTH_ACCEPT_ANY",
301
+ "MP_SMTP_AUTH_ALLOW_INSECURE",
302
+ ],
303
+ };
304
+
305
+ /** Optional controls documented in `.env.docker` and preserved on regeneration. */
306
+ const ROLE_CONTROL_EXAMPLES: Readonly<Record<string, readonly string[]>> = {
307
+ "store.sql": ["PGDATA=/var/lib/postgresql/data/pgdata", "POSTGRES_INITDB_ARGS=--data-checksums"],
308
+ "store.kv": ["OKE_STORE_KV_MAXMEMORY=256mb", "OKE_STORE_KV_MAXMEMORY_POLICY=allkeys-lru"],
309
+ "store.files": ["S3_SESSION_TOKEN="],
310
+ "channel.email": [
311
+ "SMTP_USER=",
312
+ "SMTP_PASSWORD=",
313
+ "MP_MAX_MESSAGES=500",
314
+ "MP_SMTP_AUTH_ACCEPT_ANY=1",
315
+ "MP_SMTP_AUTH_ALLOW_INSECURE=1",
316
+ ],
234
317
  };
235
318
 
236
319
  /**
@@ -240,8 +323,14 @@ const ROLE_ALIASES: Readonly<Record<string, readonly string[]>> = {
240
323
  */
241
324
  function roleFromEnvKey(key: string): string | undefined {
242
325
  const m = /^OKE_(.+)_(USER|PASSWORD|DB|URL)$/.exec(key);
243
- if (!m) return undefined;
244
- return m[1]!.toLowerCase().replaceAll("_", ".");
326
+ if (m) return m[1]!.toLowerCase().replaceAll("_", ".");
327
+ if (key.startsWith("S3_")) return "store.files";
328
+ if (key.startsWith("SMTP_") || key.startsWith("MP_") || key === "MAILPIT_UI_URL") {
329
+ return "channel.email";
330
+ }
331
+ if (key === "PGDATA" || key === "POSTGRES_INITDB_ARGS") return "store.sql";
332
+ if (key.startsWith("OKE_STORE_KV_MAXMEMORY")) return "store.kv";
333
+ return undefined;
245
334
  }
246
335
 
247
336
  /**
@@ -272,8 +361,7 @@ export function formatStackEnv(env: Readonly<Record<string, string>>): string {
272
361
  const prefix = envPrefix(role);
273
362
  const title = ROLE_SECTION_TITLE[role] ?? role;
274
363
  lines.push(`# ── ${title} ${"─".repeat(Math.max(4, 56 - title.length))}`);
275
- for (const field of ["USER", "PASSWORD", "DB", "URL"] as const) {
276
- const key = `${prefix}_${field}`;
364
+ for (const key of [`${prefix}_USER`, `${prefix}_PASSWORD`, `${prefix}_DB`, `${prefix}_URL`]) {
277
365
  const value = env[key];
278
366
  if (value === undefined) continue;
279
367
  lines.push(`${key}=${escapeEnv(value)}`);
@@ -285,6 +373,14 @@ export function formatStackEnv(env: Readonly<Record<string, string>>): string {
285
373
  lines.push(`${alias}=${escapeEnv(value)}`);
286
374
  used.add(alias);
287
375
  }
376
+ const controls = ROLE_CONTROL_EXAMPLES[role] ?? [];
377
+ const inactive = controls.filter(
378
+ (example) => env[example.slice(0, example.indexOf("="))] === undefined,
379
+ );
380
+ if (inactive.length > 0) {
381
+ lines.push("# Optional controls (uncomment to override; preserved on regeneration):");
382
+ for (const example of inactive) lines.push(`# ${example}`);
383
+ }
288
384
  lines.push("");
289
385
  }
290
386
 
@@ -54,7 +54,13 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
54
54
  }
55
55
  }
56
56
 
57
- const stackEnv = buildStackEnv(specs, normalised.recipes ?? [], normalised.host ?? "127.0.0.1");
57
+ const stackEnv = buildStackEnv(
58
+ specs,
59
+ normalised.recipes ?? [],
60
+ normalised.host ?? "127.0.0.1",
61
+ normalised.controls,
62
+ normalised.instanceId,
63
+ );
58
64
 
59
65
  return { specs, files, stackEnv, composeFiles };
60
66
  }
@@ -197,6 +197,78 @@ describe("deriveInfrastructure", () => {
197
197
  expect(formatStackEnv(result.stackEnv)).toContain("OKE_STORE_SQL_PASSWORD=");
198
198
  });
199
199
 
200
+ test("emits protocol-specific env keys plus optional control notes", () => {
201
+ const result = deriveInfrastructure({
202
+ images: {
203
+ "store.sql": "postgres:18-alpine",
204
+ "store.kv": "redis:8-alpine",
205
+ "store.files": "rustfs/rustfs:1.0.0-beta.11",
206
+ "channel.email": "axllent/mailpit:v1.22.3",
207
+ },
208
+ credentials: {
209
+ ...fixedCreds,
210
+ "store.files": {
211
+ user: "files-key",
212
+ password: "files-secret",
213
+ database: "oke",
214
+ },
215
+ "channel.email": {
216
+ user: "unused",
217
+ password: "unused-secret",
218
+ database: "oke",
219
+ },
220
+ },
221
+ });
222
+ expect(result.stackEnv.S3_ACCESS_KEY_ID).toBe("files-key");
223
+ expect(result.stackEnv.S3_SECRET_ACCESS_KEY).toBe("files-secret");
224
+ expect(result.stackEnv.S3_BUCKET).toBe("oke");
225
+ expect(result.stackEnv.S3_URL).toContain("http://files-key:");
226
+ expect(result.stackEnv.S3_ENDPOINT).toBe("http://127.0.0.1:9000");
227
+ expect(result.stackEnv.S3_CONSOLE_URL).toBe("http://127.0.0.1:9001");
228
+ expect(result.stackEnv.SMTP_URL).toBe("smtp://127.0.0.1:1025");
229
+ expect(result.stackEnv.SMTP_HOST).toBe("127.0.0.1");
230
+ expect(result.stackEnv.SMTP_PORT).toBe("1025");
231
+ expect(result.stackEnv.MAILPIT_UI_URL).toBe("http://127.0.0.1:8025");
232
+ expect(result.stackEnv.OKE_STORE_KV_PASSWORD).toBe(fixedCreds["store.kv"].password);
233
+ expect(result.stackEnv.OKE_STORE_KV_USER).toBeUndefined();
234
+ expect(result.stackEnv.OKE_STORE_KV_DB).toBeUndefined();
235
+ expect(result.stackEnv.OKE_CHANNEL_EMAIL_PASSWORD).toBeUndefined();
236
+
237
+ const text = formatStackEnv(result.stackEnv);
238
+ expect(text).toContain("# OKE_STORE_KV_MAXMEMORY=256mb");
239
+ expect(text).toContain("# S3_SESSION_TOKEN=");
240
+ expect(text).toContain("# SMTP_USER=");
241
+ expect(text).toContain("# POSTGRES_INITDB_ARGS=--data-checksums");
242
+ });
243
+
244
+ test("instance-offset UI aliases land in stack env", () => {
245
+ const id = "a3f791";
246
+ const n = Number.parseInt(id.slice(0, 4), 16) % 1000;
247
+ const result = deriveInfrastructure({
248
+ images: {
249
+ "channel.email": "axllent/mailpit:v1.22.3",
250
+ "store.files": "rustfs/rustfs:1.0.0-beta.11",
251
+ },
252
+ instanceId: id,
253
+ credentials: {
254
+ "channel.email": {
255
+ user: "oke",
256
+ password: "unused-mail-password",
257
+ database: "oke",
258
+ },
259
+ "store.files": {
260
+ user: "files-key",
261
+ password: "files-secret",
262
+ database: "oke",
263
+ },
264
+ },
265
+ });
266
+ expect(result.stackEnv.SMTP_PORT).toBe(String(20_000 + n));
267
+ expect(result.stackEnv.MAILPIT_UI_URL).toBe(`http://127.0.0.1:${21_000 + n}`);
268
+ expect(result.stackEnv.S3_ENDPOINT).toBe(`http://127.0.0.1:${18_000 + n}`);
269
+ expect(result.stackEnv.S3_CONSOLE_URL).toBe(`http://127.0.0.1:${19_000 + n}`);
270
+ });
271
+
200
272
  test("resolveStack previews without writing", () => {
201
273
  const rows = resolveStack({
202
274
  images: { "store.sql": "postgres:16" },
@@ -205,5 +277,36 @@ describe("deriveInfrastructure", () => {
205
277
  expect(rows).toHaveLength(1);
206
278
  expect(rows[0]!.recipe).toBe("postgres");
207
279
  expect(rows[0]!.hostPort).toBe(5432);
280
+ expect(rows[0]!.extraPorts).toEqual([]);
281
+ });
282
+
283
+ test("resolveStack includes instance-offset Mailpit UI / RustFS console", () => {
284
+ const id = "a3f791";
285
+ const n = Number.parseInt(id.slice(0, 4), 16) % 1000;
286
+ const rows = resolveStack({
287
+ images: {
288
+ "channel.email": "axllent/mailpit:v1.22.3",
289
+ "store.files": "rustfs/rustfs:1.0.0-beta.11",
290
+ },
291
+ instanceId: id,
292
+ credentials: {
293
+ "channel.email": {
294
+ user: "oke",
295
+ password: "stack-preview-mail-password",
296
+ database: "oke",
297
+ },
298
+ "store.files": {
299
+ user: "oke",
300
+ password: "stack-preview-files-password",
301
+ database: "oke",
302
+ },
303
+ },
304
+ });
305
+ const mail = rows.find((r) => r.role === "channel.email")!;
306
+ const files = rows.find((r) => r.role === "store.files")!;
307
+ expect(mail.hostPort).toBe(20_000 + n);
308
+ expect(mail.extraPorts).toEqual([{ hostPort: 21_000 + n, containerPort: 8025 }]);
309
+ expect(files.hostPort).toBe(18_000 + n);
310
+ expect(files.extraPorts).toEqual([{ hostPort: 19_000 + n, containerPort: 9001 }]);
208
311
  });
209
312
  });
@@ -37,12 +37,22 @@ export { emitDockerfile } from "./dockerfile.ts";
37
37
  export { credEnv, envPrefix, serviceNameFor } from "./helpers.ts";
38
38
  export { generateCredentials } from "./credentials.ts";
39
39
  export { builtinRecipes, mailpit, postgres, recipeFor, redis, rustfs } from "./recipes/index.ts";
40
- export { formatStackPreview, resolveStack, type StackRow } from "./stack.ts";
41
40
  export {
41
+ formatStackPreview,
42
+ resolveExtraPorts,
43
+ resolveStack,
44
+ type StackExtraPort,
45
+ type StackRow,
46
+ } from "./stack.ts";
47
+ export {
48
+ extraHostPortForInstance,
42
49
  hostPortForInstance,
43
50
  instancePortOffset,
51
+ loadExistingStackControls,
44
52
  loadExistingStackCredentials,
53
+ parseStackControls,
45
54
  parseStackCredentials,
55
+ STACK_CONTROL_KEYS,
46
56
  stackAppSlug,
47
57
  stackInstanceId,
48
58
  } from "./stack-id.ts";
@@ -4,14 +4,15 @@
4
4
 
5
5
  import type { ImageRecipe } from "../types.ts";
6
6
  import { mailpit } from "./mailpit.ts";
7
+ import { openbao } from "./openbao.ts";
7
8
  import { postgres } from "./postgres.ts";
8
9
  import { redis } from "./redis.ts";
9
10
  import { rustfs } from "./rustfs.ts";
10
11
 
11
12
  /** Default recipe catalogue. */
12
- export const builtinRecipes: readonly ImageRecipe[] = [postgres, redis, mailpit, rustfs];
13
+ export const builtinRecipes: readonly ImageRecipe[] = [postgres, redis, mailpit, rustfs, openbao];
13
14
 
14
- export { mailpit, postgres, redis, rustfs };
15
+ export { mailpit, openbao, postgres, redis, rustfs };
15
16
 
16
17
  /**
17
18
  * Resolve the recipe for an image reference.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * OpenBao image recipe — durable single-node secrets service (Raft storage).
3
+ *
4
+ * Never `-dev`: data lives on a named volume under `/openbao/data`; init +
5
+ * unseal is handled by the CLI bootstrap (keys stay on the host).
6
+ */
7
+
8
+ import type { ImageRecipe } from "../types.ts";
9
+
10
+ /**
11
+ * Server config passed via `BAO_LOCAL_CONFIG` (official image). Single-node
12
+ * Raft still requires `cluster_addr` / `api_addr` per the OpenBao docs.
13
+ */
14
+ const OPENBAO_LOCAL_CONFIG = JSON.stringify({
15
+ listener: { tcp: { address: "0.0.0.0:8200", tls_disable: true } },
16
+ // Raft under /openbao/file — the stock entrypoint chowns that dir for the
17
+ // `openbao` user; a root-owned fresh named volume stays writable. Raft does
18
+ // not create nested dirs, so the path must be the chowned dir itself.
19
+ storage: { raft: { path: "/openbao/file", node_id: "oke" } },
20
+ api_addr: "http://0.0.0.0:8200",
21
+ cluster_addr: "http://127.0.0.1:8201",
22
+ disable_mlock: true,
23
+ });
24
+
25
+ /** OpenBao secrets service. API on 8200. */
26
+ export const openbao: ImageRecipe = {
27
+ id: "openbao",
28
+ port: 8200,
29
+ match: (i) => /openbao/i.test(i),
30
+ apply: (s) => ({
31
+ environment: {
32
+ BAO_LOCAL_CONFIG: OPENBAO_LOCAL_CONFIG,
33
+ },
34
+ command: ["server"],
35
+ volumes: [`${s.serviceName}-data:/openbao/file`],
36
+ healthcheck: {
37
+ // `/sys/init` answers 200 even while sealed — only proves the server
38
+ // process is up; bootstrap owns the real unseal step.
39
+ test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8200/v1/sys/init >/dev/null 2>&1 || exit 1"],
40
+ interval: "5s",
41
+ timeout: "5s",
42
+ retries: 12,
43
+ start_period: "5s",
44
+ },
45
+ }),
46
+ url: (_s, c) => `http://${c.host}:${c.port}`,
47
+ };
@@ -11,7 +11,11 @@ export const redis: ImageRecipe = {
11
11
  port: 6379,
12
12
  match: (i) => /redis|valkey|dragonfly|keydb/i.test(i),
13
13
  apply: (s) => ({
14
- command: ["redis-server", "--requirepass", credEnv(s, "PASSWORD")],
14
+ command: [
15
+ "sh",
16
+ "-c",
17
+ 'exec redis-server --requirepass "$$OKE_STORE_KV_PASSWORD" --maxmemory "$${OKE_STORE_KV_MAXMEMORY:-0}" --maxmemory-policy "$${OKE_STORE_KV_MAXMEMORY_POLICY:-noeviction}"',
18
+ ],
15
19
  healthcheck: {
16
20
  test: ["CMD", "redis-cli", "-a", credEnv(s, "PASSWORD"), "ping"],
17
21
  interval: "5s",
@@ -2,7 +2,6 @@
2
2
  * RustFS image recipe — S3-compatible object store (Apache 2.0 MinIO alternative).
3
3
  */
4
4
 
5
- import { credEnv } from "../helpers.ts";
6
5
  import type { ImageRecipe } from "../types.ts";
7
6
 
8
7
  /** S3-protocol object storage (RustFS). Console on 9001. */
@@ -12,8 +11,8 @@ export const rustfs: ImageRecipe = {
12
11
  match: (i) => /rustfs/i.test(i),
13
12
  apply: (s) => ({
14
13
  environment: {
15
- RUSTFS_ACCESS_KEY: credEnv(s, "USER"),
16
- RUSTFS_SECRET_KEY: credEnv(s, "PASSWORD"),
14
+ RUSTFS_ACCESS_KEY: "${S3_ACCESS_KEY_ID}",
15
+ RUSTFS_SECRET_KEY: "${S3_SECRET_ACCESS_KEY}",
17
16
  RUSTFS_CONSOLE_ENABLE: "true",
18
17
  RUSTFS_ADDRESS: ":9000",
19
18
  },
@@ -4,8 +4,10 @@
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import {
7
+ extraHostPortForInstance,
7
8
  hostPortForInstance,
8
9
  instancePortOffset,
10
+ parseStackControls,
9
11
  parseStackCredentials,
10
12
  stackAppSlug,
11
13
  stackInstanceId,
@@ -29,14 +31,23 @@ describe("stackInstanceId", () => {
29
31
  });
30
32
 
31
33
  describe("hostPortForInstance", () => {
32
- test("offsets sql and kv into separate ranges", () => {
34
+ test("assigns built-in roles disjoint per-project ranges", () => {
33
35
  const id = "abcd12";
36
+ const n = instancePortOffset(id);
34
37
  const sql = hostPortForInstance("store.sql", 5432, id);
35
38
  const kv = hostPortForInstance("store.kv", 6379, id);
36
- expect(sql).toBeGreaterThanOrEqual(15_000);
37
- expect(sql).toBeLessThan(16_000);
38
- expect(kv).toBeGreaterThanOrEqual(16_000);
39
- expect(kv).toBeLessThan(17_000);
39
+ const files = hostPortForInstance("store.files", 9000, id);
40
+ const filesConsole = extraHostPortForInstance("store.files", 9001, id);
41
+ const email = hostPortForInstance("channel.email", 1025, id);
42
+ const emailUi = extraHostPortForInstance("channel.email", 8025, id);
43
+ expect([sql, kv, files, filesConsole, email, emailUi]).toEqual([
44
+ 15_000 + n,
45
+ 16_000 + n,
46
+ 18_000 + n,
47
+ 19_000 + n,
48
+ 20_000 + n,
49
+ 21_000 + n,
50
+ ]);
40
51
  });
41
52
  });
42
53
 
@@ -54,6 +65,26 @@ OKE_STORE_KV_DB=oke
54
65
  expect(creds["store.sql"]?.password).toBe("s3cret");
55
66
  expect(creds["store.kv"]?.password).toBe("kvpass");
56
67
  });
68
+
69
+ test("reads service-specific S3 credentials and optional controls", () => {
70
+ const text = `
71
+ S3_ACCESS_KEY_ID=files-key
72
+ S3_SECRET_ACCESS_KEY=files-secret
73
+ S3_REGION=eu-central-1
74
+ OKE_STORE_KV_MAXMEMORY=512mb
75
+ # SMTP_USER=commented-out
76
+ `;
77
+ const creds = parseStackCredentials(text, ["store.files"]);
78
+ expect(creds["store.files"]).toEqual({
79
+ user: "files-key",
80
+ password: "files-secret",
81
+ database: "oke",
82
+ });
83
+ expect(parseStackControls(text)).toEqual({
84
+ OKE_STORE_KV_MAXMEMORY: "512mb",
85
+ S3_REGION: "eu-central-1",
86
+ });
87
+ });
57
88
  });
58
89
 
59
90
  describe("deriveInfrastructure instanceId", () => {
@@ -86,7 +117,8 @@ describe("deriveInfrastructure instanceId", () => {
86
117
  });
87
118
 
88
119
  test("offsets Mailpit UI and RustFS console so stacks do not share 8025/9001", () => {
89
- const id = "abcd12";
120
+ // Regression: offset 975 previously mapped Mailpit's 8025 UI onto RustFS's 9000 API.
121
+ const id = "a3f791";
90
122
  const n = instancePortOffset(id);
91
123
  const result = deriveInfrastructure({
92
124
  images: {
@@ -111,9 +143,12 @@ describe("deriveInfrastructure instanceId", () => {
111
143
  });
112
144
  const mailYml = result.files.find((f) => f.path === "compose.channel.email.yml")!.content;
113
145
  const filesYml = result.files.find((f) => f.path === "compose.store.files.yml")!.content;
114
- expect(mailYml).toContain(`${8025 + n}:8025`);
146
+ expect(mailYml).toContain(`${20_000 + n}:1025`);
147
+ expect(mailYml).toContain(`${21_000 + n}:8025`);
115
148
  expect(mailYml).not.toContain("8025:8025");
116
- expect(filesYml).toContain(`${9001 + n}:9001`);
149
+ expect(filesYml).toContain(`${18_000 + n}:9000`);
150
+ expect(filesYml).toContain(`${19_000 + n}:9001`);
117
151
  expect(filesYml).not.toContain("9001:9001");
152
+ expect(mailYml).not.toContain("9000:8025");
118
153
  });
119
154
  });