@stacksjs/buddy 0.70.229 → 0.70.230

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.
@@ -96,7 +96,8 @@ export function build(buddy) {
96
96
  });
97
97
  buddy.command("build:components", "Automagically build component libraries for production use & npm/CDN distribution").alias("prod:components").option("-c, --components", descriptions.components, { default: !0 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
98
98
  log.debug("Running `buddy build:components` ...", options);
99
- await runAction(Action.BuildComponentLibs, options);
99
+ if (!await runBuildAction(Action.BuildComponentLibs, "component libraries"))
100
+ process.exit(ExitCode.FatalError);
100
101
  });
101
102
  buddy.command("build:cli", descriptions.cli).alias("prod:cli").option("-b, --buddy", descriptions.buddy, { default: !0 }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
102
103
  log.debug("Running `buddy build:cli` ...", options);
@@ -114,7 +115,8 @@ export function build(buddy) {
114
115
  default: !0
115
116
  }).option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
116
117
  log.debug("Running `buddy build:web-components` ...", options);
117
- await runAction(Action.BuildWebComponentLib, options);
118
+ if (!await runBuildAction(Action.BuildWebComponentLib, "web component library"))
119
+ process.exit(ExitCode.FatalError);
118
120
  });
119
121
  buddy.command("build:frontend", descriptions.pages).alias("build:pages").alias("build:views").alias("prod:frontend").option("-p, --project [project]", descriptions.project, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (options) => {
120
122
  log.debug("Running `buddy build:frontend` ...", options);
@@ -1,4 +1,4 @@
1
- import { chmodSync } from "node:fs";
1
+ import { chmodSync, existsSync, readdirSync } from "node:fs";
2
2
  import process from "node:process";
3
3
  import { runAction } from "@stacksjs/actions";
4
4
  import { bold, cyan, dim, intro, log, onUnknownSubcommand, runCommand } from "@stacksjs/cli";
@@ -24,9 +24,10 @@ export function create(buddy) {
24
24
  email: "Do you need email?",
25
25
  project: "Target a specific project",
26
26
  minimal: "Skip optional feature bundles (cms, commerce, dashboard, marketing, monitoring, realtime, queue) \u2014 bare-bones API/SPA starter that can re-add them later via `./buddy <feature>:install`.",
27
+ withCore: "Keep the framework vendored in `storage/framework/core` as a Bun workspace, for working ON Stacks. Apps that only work WITH Stacks want the default, which resolves every @stacksjs/* package from npm.",
27
28
  verbose: "Enable verbose output"
28
29
  };
29
- buddy.command("new [name]", descriptions.command).alias("create [name]").option("-n, --name [name]", descriptions.name, { default: !1 }).option("-u, --ui", descriptions.ui, { default: !0 }).option("-c, --components", descriptions.components, { default: !0 }).option("-w, --web-components", descriptions.webComponents, { default: !0 }).option("-p, --views", descriptions.views, { default: !0 }).option("-f, --functions", descriptions.functions, { default: !0 }).option("-a, --api", descriptions.api, { default: !0 }).option("-d, --database", descriptions.database, { default: !0 }).option("-ca, --cache", descriptions.cache, { default: !1 }).option("-e, --email", descriptions.email, { default: !1 }).option("-P, --project [project]", descriptions.project, { default: !1 }).option("-m, --minimal", descriptions.minimal, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (name, options) => {
30
+ buddy.command("new [name]", descriptions.command).alias("create [name]").option("-n, --name [name]", descriptions.name, { default: !1 }).option("-u, --ui", descriptions.ui, { default: !0 }).option("-c, --components", descriptions.components, { default: !0 }).option("-w, --web-components", descriptions.webComponents, { default: !0 }).option("-p, --views", descriptions.views, { default: !0 }).option("-f, --functions", descriptions.functions, { default: !0 }).option("-a, --api", descriptions.api, { default: !0 }).option("-d, --database", descriptions.database, { default: !0 }).option("-ca, --cache", descriptions.cache, { default: !1 }).option("-e, --email", descriptions.email, { default: !1 }).option("-P, --project [project]", descriptions.project, { default: !1 }).option("-m, --minimal", descriptions.minimal, { default: !1 }).option("--with-core", descriptions.withCore, { default: !1 }).option("--verbose", descriptions.verbose, { default: !1 }).action(async (name, options) => {
30
31
  log.debug("Running `buddy new <name>` ...", options);
31
32
  const startTime = await intro("buddy new");
32
33
  name = name ?? options.name;
@@ -41,6 +42,8 @@ export function create(buddy) {
41
42
  ensureExecutableScripts(path);
42
43
  await ensureEnv(path, options);
43
44
  await install(path, options);
45
+ if (!options.withCore)
46
+ await unvendorCore(path, options);
44
47
  if (options.minimal)
45
48
  await stripFeatures(path);
46
49
  if (startTime) {
@@ -56,10 +59,12 @@ export function create(buddy) {
56
59
  onUnknownSubcommand(buddy, "new");
57
60
  }
58
61
  function isFolderCheck(path) {
59
- if (isFolder(path)) {
60
- log.error(`Path ${path} already exists`);
61
- process.exit(ExitCode.FatalError);
62
- }
62
+ if (!isFolder(path))
63
+ return;
64
+ if (readdirSync(path).filter((entry) => entry !== ".git").length === 0)
65
+ return;
66
+ log.error(`Path ${path} already exists`);
67
+ process.exit(ExitCode.FatalError);
63
68
  }
64
69
  async function onlineCheck() {
65
70
  if (await isOnline())
@@ -82,7 +87,7 @@ async function download(name, path, _options) {
82
87
  log.info("Setting up your stack.");
83
88
  try {
84
89
  const { downloadTemplate } = await import("@stacksjs/gitit");
85
- await downloadTemplate("gh:stacksjs/stacks", { dir: name });
90
+ await downloadTemplate("gh:stacksjs/stacks", { dir: name, force: !0 });
86
91
  log.success(`Successfully scaffolded your project at ${cyan(path)}`);
87
92
  return { isErr: !1 };
88
93
  } catch (error) {
@@ -119,13 +124,26 @@ async function install(path, options) {
119
124
  log.error(keyResult.error);
120
125
  process.exit(ExitCode.FatalError);
121
126
  }
122
- log.info("Initializing git repository...");
123
- result = await runCommand("git init", { ...options, cwd: path });
127
+ if (existsSync(resolve(path, ".git")))
128
+ log.info("Existing git repository detected, skipping git init");
129
+ else {
130
+ log.info("Initializing git repository...");
131
+ result = await runCommand("git init", { ...options, cwd: path });
132
+ if (result.isErr) {
133
+ log.error(result.error);
134
+ process.exit(ExitCode.FatalError);
135
+ }
136
+ }
137
+ log.success("Installed & set-up \uD83D\uDE80");
138
+ }
139
+ async function unvendorCore(path, options) {
140
+ log.info("Resolving the framework from npm (pass --with-core to keep it vendored)...");
141
+ const result = await runCommand("./buddy unpublish:core --all --force", { ...options, cwd: path });
124
142
  if (result.isErr) {
125
143
  log.error(result.error);
126
144
  process.exit(ExitCode.FatalError);
127
145
  }
128
- log.success("Installed & set-up \uD83D\uDE80");
146
+ log.success("Framework resolved from npm");
129
147
  }
130
148
  async function stripFeatures(path) {
131
149
  log.info("Stripping optional feature bundles (--minimal)...");
@@ -1163,7 +1163,8 @@ if [ -n "$DOMAIN" ] && [ -f "$CERTFILE" ] && [ -d /opt/tlsx ]; then
1163
1163
  ;;
1164
1164
  esac
1165
1165
  fi
1166
- # 4) Merge auto-forward rules into forwards.json (live-reloaded; no restart).
1166
+ # 4) Merge auto-forward rules and compile the canonical RFC 5228 runtime script
1167
+ # (live-reloaded; no restart).
1167
1168
  if [ -n "$FWD_B64" ] && [ -x /usr/local/bin/bun ]; then
1168
1169
  echo "$FWD_B64" | base64 -d > /tmp/.mailtenant-fwd.json
1169
1170
  echo "$README_B64" | base64 -d > /tmp/.mailtenant-readme.txt
@@ -1190,6 +1191,12 @@ if [ -s /tmp/.mailtenant-err ]; then echo "FWDERR:$(head -c 200 /tmp/.mailtenant
1190
1191
  rm -f /tmp/.mailtenant-err
1191
1192
  chown mail-server:mail-server "$FJSON" 2>/dev/null || true
1192
1193
  chmod 644 "$FJSON" 2>/dev/null || true
1194
+ if [ -x /usr/local/sbin/mail-forward-compile ]; then
1195
+ if ! /usr/local/sbin/mail-forward-compile "$FJSON" /opt/mail/forwards.sieve 2>/tmp/.mailtenant-sieve-err; then
1196
+ echo "SIEVEERR:$(head -c 200 /tmp/.mailtenant-sieve-err | tr '\\n' ' ')"
1197
+ fi
1198
+ rm -f /tmp/.mailtenant-sieve-err
1199
+ fi
1193
1200
  rm -f /tmp/.mailtenant-fwd.json /tmp/.mailtenant-readme.txt
1194
1201
  fi
1195
1202
  FWD_STATE=nochange; grep -q FWDCHANGED /tmp/.mailtenant-res 2>/dev/null && FWD_STATE=updated; rm -f /tmp/.mailtenant-res
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
2
  import process from "node:process";
3
3
  import { email as emailConfig } from "@stacksjs/config";
4
+ import { extractEmailPreview } from "@stacksjs/email";
4
5
  import { ExitCode } from "@stacksjs/types";
5
6
  import { onUnknownSubcommand } from "@stacksjs/cli";
6
7
  import { getErrorMessage } from "@stacksjs/utils";
@@ -412,14 +413,7 @@ Configuration:`);
412
413
  const rawEmail = await withTimeout(s3.getObject(bucketName, obj.Key), 15000);
413
414
  if (!rawEmail)
414
415
  continue;
415
- const headers = parseRawEmailHeaders(rawEmail), messageId = obj.Key.split("/").pop() || obj.Key, from = extractEmailAddress(headers.from || ""), fromName = extractEmailName(headers.from || ""), toList = (headers.to || "").split(",").map((s) => s.trim()).filter(Boolean), subject = headers.subject || "No Subject", date = headers.date || (obj.LastModified ? new Date(obj.LastModified).toISOString() : new Date().toISOString());
416
- let preview = "";
417
- const bodyStart = rawEmail.indexOf(`\r
418
- \r
419
- `);
420
- if (bodyStart > 0)
421
- preview = rawEmail.substring(bodyStart + 4, bodyStart + 500).replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim().substring(0, 200);
422
- const recipients = toList.length > 0 ? toList : [`unknown@${domain}`];
416
+ const headers = parseRawEmailHeaders(rawEmail), messageId = obj.Key.split("/").pop() || obj.Key, from = extractEmailAddress(headers.from || ""), fromName = extractEmailName(headers.from || ""), toList = (headers.to || "").split(",").map((s) => s.trim()).filter(Boolean), subject = headers.subject || "No Subject", date = headers.date || (obj.LastModified ? new Date(obj.LastModified).toISOString() : new Date().toISOString()), preview = extractEmailPreview(rawEmail), recipients = toList.length > 0 ? toList : [`unknown@${domain}`];
423
417
  for (const recipient of recipients) {
424
418
  const recipientEmail = extractEmailAddress(recipient);
425
419
  if (!recipientEmail.endsWith(`@${domain}`))
@@ -163,10 +163,10 @@ export declare const FEATURE_FILES: Record<FeatureName, readonly string[]>;
163
163
  * @defaultValue
164
164
  * ```ts
165
165
  * {
166
- * cms: ['posts', 'pages', 'comments', 'tags', 'authors', 'categories'],
167
- * commerce: [ 'products', 'product_variants', 'product_units', 'manufacturers', 'orders', 'order_items', 'carts', 'cart_items', 'payments', 'payment_methods', 'payment_products', 'payment_transactions', 'customers', 'subscribers', 'subscriber_emails', 'subscriptions', 'gift_cards', 'coupons', 'transactions', 'reviews', 'drivers', 'delivery_routes', 'digital_deliveries', 'shipping_methods', 'shipping_rates', 'shipping_zones', 'license_keys', 'loyalty_points', 'loyalty_rewards', 'print_devices', 'receipts', 'tax_rates', 'waitlist_products', 'waitlist_restaurants', ],
166
+ * cms: [ 'posts', 'pages', 'comments', 'tags', 'authors', 'categories', 'taggable_models', 'categorizable_models', 'commentables', ],
167
+ * commerce: [ 'products', 'product_variants', 'product_units', 'manufacturers', 'orders', 'order_items', 'order_idempotency', 'carts', 'cart_items', 'payments', 'payment_methods', 'payment_products', 'payment_transactions', 'customers', 'subscribers', 'subscriber_emails', 'subscriptions', 'gift_cards', 'coupons', 'transactions', 'reviews', 'drivers', 'delivery_routes', 'digital_deliveries', 'shipping_methods', 'shipping_rates', 'shipping_zones', 'license_keys', 'loyalty_points', 'loyalty_rewards', 'print_devices', 'receipts', 'tax_rates', 'waitlist_products', 'waitlist_restaurants', ],
168
168
  * dashboard: [ 'boards', 'board_columns', 'cards', 'card_labels', 'card_assignees', 'card_comments', 'labels', 'ci_run_states', 'ci_runner_samples', 'ci_runner_alert_states', 'requests', 'logs', ],
169
- * marketing: [],
169
+ * marketing: [ 'campaigns', 'campaign_sends', 'email_lists', 'email_list_subscribers', 'social_posts', 'mail_preferences', ],
170
170
  * monitoring: ['errors'],
171
171
  * realtime: ['websockets'],
172
172
  * queue: ['jobs', 'failed_jobs']
@@ -72,7 +72,17 @@ export const FEATURE_NAMES = [
72
72
  "resources/views/dashboard/jobs/"
73
73
  ]
74
74
  }, FEATURE_TABLES = {
75
- cms: ["posts", "pages", "comments", "tags", "authors", "categories"],
75
+ cms: [
76
+ "posts",
77
+ "pages",
78
+ "comments",
79
+ "tags",
80
+ "authors",
81
+ "categories",
82
+ "taggable_models",
83
+ "categorizable_models",
84
+ "commentables"
85
+ ],
76
86
  commerce: [
77
87
  "products",
78
88
  "product_variants",
@@ -80,6 +90,7 @@ export const FEATURE_NAMES = [
80
90
  "manufacturers",
81
91
  "orders",
82
92
  "order_items",
93
+ "order_idempotency",
83
94
  "carts",
84
95
  "cart_items",
85
96
  "payments",
@@ -123,7 +134,14 @@ export const FEATURE_NAMES = [
123
134
  "requests",
124
135
  "logs"
125
136
  ],
126
- marketing: [],
137
+ marketing: [
138
+ "campaigns",
139
+ "campaign_sends",
140
+ "email_lists",
141
+ "email_list_subscribers",
142
+ "social_posts",
143
+ "mail_preferences"
144
+ ],
127
145
  monitoring: ["errors"],
128
146
  realtime: ["websockets"],
129
147
  queue: ["jobs", "failed_jobs"]
@@ -2,9 +2,9 @@ var {require}=import.meta;import { log, runCommand } from "@stacksjs/cli";
2
2
  import { ExitCode } from "@stacksjs/types";
3
3
  import { getErrorMessage } from "@stacksjs/utils";
4
4
  import { CLI } from "@stacksjs/clapp";
5
- import { createHash, createHmac, randomBytes } from "crypto";
5
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
6
6
  import { readFileSync, existsSync } from "node:fs";
7
- import { execSync } from "node:child_process";
7
+ import { execSync, spawnSync } from "node:child_process";
8
8
  export function resolveDirectMailHost(env = process.env) {
9
9
  let configured = env.MAIL_SERVER_HOST || (env.HCLOUD_TOKEN || env.HETZNER_API_TOKEN ? env.MAIL_HOST : void 0);
10
10
  if (configured && ["127.0.0.1", "localhost", "mailpit"].includes(configured))
@@ -40,6 +40,410 @@ async function resolveOperationalMailHost() {
40
40
  const { email } = await import("@stacksjs/config");
41
41
  return email?.domain ? `mail.${email.domain}` : void 0;
42
42
  }
43
+ const mailStorageRemote = "/usr/local/sbin/mail-storage-external";
44
+ function loadMailStorageAws(options) {
45
+ const profile = options.profile || process.env.AWS_PROFILE || "default", region = options.region || process.env.AWS_REGION || "us-east-1";
46
+ process.env.AWS_PROFILE = profile;
47
+ loadEnvFiles();
48
+ if (existsSync(".env.production"))
49
+ try {
50
+ const { loadEnv } = require("@stacksjs/env");
51
+ loadEnv({ path: ".env.production", env: "production", keysFile: ".env.keys", overload: !0, quiet: !0 });
52
+ } catch {}
53
+ const credentialsPath = `${process.env.HOME || process.env.USERPROFILE || ""}/.aws/credentials`;
54
+ if ((!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) && existsSync(credentialsPath)) {
55
+ const values = {};
56
+ let inProfile = !1;
57
+ for (const line of readFileSync(credentialsPath, "utf8").split(`
58
+ `)) {
59
+ const trimmed = line.trim();
60
+ if (trimmed.startsWith("[")) {
61
+ inProfile = trimmed === `[${profile}]`;
62
+ continue;
63
+ }
64
+ if (!inProfile)
65
+ continue;
66
+ const separator = trimmed.indexOf("=");
67
+ if (separator > 0)
68
+ values[trimmed.slice(0, separator).trim()] = trimmed.slice(separator + 1).trim();
69
+ }
70
+ if (values.aws_access_key_id && values.aws_secret_access_key) {
71
+ process.env.AWS_ACCESS_KEY_ID = values.aws_access_key_id;
72
+ process.env.AWS_SECRET_ACCESS_KEY = values.aws_secret_access_key;
73
+ if (values.aws_session_token)
74
+ process.env.AWS_SESSION_TOKEN = values.aws_session_token;
75
+ else
76
+ delete process.env.AWS_SESSION_TOKEN;
77
+ }
78
+ }
79
+ return { profile, region };
80
+ }
81
+ function mailStorageSecretId(options) {
82
+ if (options.secret)
83
+ return options.secret;
84
+ if (process.env.MAIL_STORAGE_SECRET_ID)
85
+ return process.env.MAIL_STORAGE_SECRET_ID;
86
+ const app = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-"), environment = (process.env.APP_ENV || "production").toLowerCase().replace(/[^a-z0-9-]/g, "-");
87
+ return `${app}/${environment}/mail-storage-key`;
88
+ }
89
+ async function mailStorageHost(options) {
90
+ const host = options.host || await resolveOperationalMailHost();
91
+ if (!host)
92
+ throw Error("Could not resolve the mail host. Pass --host <hostname-or-ip>.");
93
+ if (!/^[a-zA-Z0-9._:-]+$/.test(host))
94
+ throw Error(`Unsafe mail host: ${host}`);
95
+ return host;
96
+ }
97
+ function runMailStorageSsh(host, command, input) {
98
+ const result = spawnSync("ssh", [
99
+ "-o",
100
+ "BatchMode=yes",
101
+ "-o",
102
+ "ConnectTimeout=15",
103
+ `root@${host}`,
104
+ command
105
+ ], {
106
+ input,
107
+ maxBuffer: 33554432
108
+ });
109
+ if (result.status !== 0) {
110
+ const stderr = Buffer.from(result.stderr || []).toString("utf8").trim();
111
+ throw Error(stderr || `SSH command failed with status ${result.status}`);
112
+ }
113
+ return Buffer.from(result.stdout || []);
114
+ }
115
+ function readMailRecoveryKey() {
116
+ const result = spawnSync("/usr/bin/security", [
117
+ "find-generic-password",
118
+ "-a",
119
+ "mail-storage-recovery",
120
+ "-s",
121
+ "com.stacks.production.mail.luks",
122
+ "-w"
123
+ ], {
124
+ maxBuffer: 1048576,
125
+ timeout: 30000
126
+ });
127
+ if (result.status !== 0)
128
+ throw Error("macOS Keychain recovery-key read failed");
129
+ const raw = Buffer.from(result.stdout || []), text = raw.toString("ascii").trim(), key = /^[0-9a-f]{128}$/i.test(text) ? Buffer.from(text, "hex") : raw.length === 65 && raw[64] === 10 ? Buffer.from(raw.subarray(0, 64)) : Buffer.from(raw);
130
+ raw.fill(0);
131
+ if (key.length !== 64)
132
+ throw Error("macOS Keychain returned an invalid recovery key");
133
+ return key;
134
+ }
135
+ async function waitForHetznerAction(token, actionId) {
136
+ const deadline = Date.now() + 120000;
137
+ while (Date.now() < deadline) {
138
+ const response = await fetch(`https://api.hetzner.cloud/v1/actions/${actionId}`, {
139
+ headers: { Authorization: `Bearer ${token}` }
140
+ }), data = await response.json();
141
+ if (!response.ok)
142
+ throw Error(data?.error?.message || `Hetzner action ${actionId} lookup failed`);
143
+ if (data.action?.status === "success")
144
+ return;
145
+ if (data.action?.status === "error")
146
+ throw Error(data.action?.error?.message || `Hetzner action ${actionId} failed`);
147
+ await new Promise((resolve) => setTimeout(resolve, 1000));
148
+ }
149
+ throw Error(`Hetzner action ${actionId} timed out`);
150
+ }
151
+ async function ensureMailStorageVolume(options) {
152
+ loadEnvFiles();
153
+ const token = process.env.HCLOUD_TOKEN || process.env.HETZNER_API_TOKEN;
154
+ if (!token)
155
+ throw Error("HCLOUD_TOKEN or HETZNER_API_TOKEN is required.");
156
+ const headers = {
157
+ Authorization: `Bearer ${token}`,
158
+ "Content-Type": "application/json"
159
+ }, host = await mailStorageHost(options), server = (await (await fetch(`https://api.hetzner.cloud/v1/servers?name=${encodeURIComponent("stacks-production-app")}`, { headers })).json()).servers?.[0];
160
+ if (!server)
161
+ throw Error("Hetzner server stacks-production-app was not found.");
162
+ const app = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-"), name = `${app}-production-mail`, requestedSize = Number.parseInt(options.size || "20", 10);
163
+ if (!Number.isFinite(requestedSize) || requestedSize < 10 || requestedSize > 1024)
164
+ throw Error("Mail volume size must be between 10 and 1024 GiB.");
165
+ let volume = (await (await fetch(`https://api.hetzner.cloud/v1/volumes?name=${encodeURIComponent(name)}`, { headers })).json()).volumes?.[0];
166
+ if (!volume) {
167
+ const createResponse = await fetch("https://api.hetzner.cloud/v1/volumes", {
168
+ method: "POST",
169
+ headers,
170
+ body: JSON.stringify({
171
+ name,
172
+ size: requestedSize,
173
+ server: server.id,
174
+ automount: !1,
175
+ labels: {
176
+ application: app,
177
+ environment: "production",
178
+ purpose: "mail-storage"
179
+ }
180
+ })
181
+ }), createData = await createResponse.json();
182
+ if (!createResponse.ok)
183
+ throw Error(createData?.error?.message || "Hetzner volume creation failed.");
184
+ volume = createData.volume;
185
+ if (createData.action?.id)
186
+ await waitForHetznerAction(token, createData.action.id);
187
+ } else if (!volume.server) {
188
+ const attachResponse = await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}/actions/attach`, {
189
+ method: "POST",
190
+ headers,
191
+ body: JSON.stringify({ server: server.id, automount: !1 })
192
+ }), attachData = await attachResponse.json();
193
+ if (!attachResponse.ok)
194
+ throw Error(attachData?.error?.message || "Hetzner volume attach failed.");
195
+ await waitForHetznerAction(token, attachData.action.id);
196
+ } else if (volume.server !== server.id)
197
+ throw Error(`Hetzner volume ${name} is attached to a different server.`);
198
+ volume = (await (await fetch(`https://api.hetzner.cloud/v1/volumes/${volume.id}`, { headers })).json()).volume || volume;
199
+ if (!volume.linux_device)
200
+ throw Error(`Hetzner volume ${name} has no Linux device path yet.`);
201
+ return { device: volume.linux_device, host, id: volume.id, name, size: volume.size };
202
+ }
203
+ async function ensureMailKmsKey(options) {
204
+ const { region } = loadMailStorageAws(options), { AWSClient } = await import("../../../cloud/src/imap/client"), { S3Client } = await import("../../../cloud/src/imap/s3"), { SecretsManagerClient } = await import("../../../cloud/src/imap/secrets-manager"), client = new AWSClient, request = async (target, body) => client.request({
205
+ service: "kms",
206
+ region,
207
+ method: "POST",
208
+ path: "/",
209
+ headers: {
210
+ "Content-Type": "application/x-amz-json-1.1",
211
+ "X-Amz-Target": `TrentService.${target}`
212
+ },
213
+ body: JSON.stringify(body)
214
+ }), alias = "alias/stacks-production-mail";
215
+ let keyId = (await request("ListAliases", { Limit: 100 })).Aliases?.find((item) => item.AliasName === alias)?.TargetKeyId;
216
+ if (!keyId) {
217
+ keyId = (await request("CreateKey", {
218
+ Description: "Customer-managed key for production mail secrets and backups",
219
+ KeyUsage: "ENCRYPT_DECRYPT",
220
+ Origin: "AWS_KMS",
221
+ Tags: [
222
+ { TagKey: "Application", TagValue: "stacks" },
223
+ { TagKey: "Environment", TagValue: "production" },
224
+ { TagKey: "Purpose", TagValue: "mail-storage" }
225
+ ]
226
+ })).KeyMetadata?.KeyId;
227
+ if (!keyId)
228
+ throw Error("AWS KMS did not return a key ID.");
229
+ await request("CreateAlias", { AliasName: alias, TargetKeyId: keyId });
230
+ await request("EnableKeyRotation", { KeyId: keyId, RotationPeriodInDays: 90 });
231
+ }
232
+ const arn = (await request("DescribeKey", { KeyId: keyId })).KeyMetadata?.Arn;
233
+ if (!arn)
234
+ throw Error("AWS KMS did not return a key ARN.");
235
+ const secrets = new SecretsManagerClient(region);
236
+ for (const secretId of ["stacks/production/mail-storage-key", "stacks/production/mail-restic"])
237
+ try {
238
+ await secrets.updateSecret({ SecretId: secretId, KmsKeyId: arn });
239
+ } catch (error) {
240
+ if (error?.code !== "ResourceNotFoundException")
241
+ throw Error(`Could not re-encrypt secret ${secretId}: ${getErrorMessage(error)}`);
242
+ }
243
+ const s3 = new S3Client(region);
244
+ for (const bucket of ["stacks-production-s3-backups", "stacks-production-s3-email", "stacks-production-email"])
245
+ try {
246
+ await s3.putBucketEncryption(bucket, "aws:kms", arn);
247
+ } catch (error) {
248
+ throw Error(`Could not apply KMS encryption to ${bucket}: ${getErrorMessage(error)}`);
249
+ }
250
+ return { alias, arn };
251
+ }
252
+ async function configureMailRestic(options) {
253
+ const { region } = loadMailStorageAws(options), host = await mailStorageHost(options), bucket = options.bucket || "stacks-production-s3-backups";
254
+ if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))
255
+ throw Error(`Unsafe S3 bucket name: ${bucket}`);
256
+ const kms = await ensureMailKmsKey(options), { AWSClient } = await import("../../../cloud/src/imap/client"), { SecretsManagerClient } = await import("../../../cloud/src/imap/secrets-manager"), iam = new AWSClient, iamRequest = async (action, params = {}) => iam.request({
257
+ service: "iam",
258
+ region: "us-east-1",
259
+ method: "POST",
260
+ path: "/",
261
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
262
+ body: new URLSearchParams({
263
+ Action: action,
264
+ Version: "2010-05-08",
265
+ ...params
266
+ }).toString()
267
+ }), userName = "stacks-production-mail-backup";
268
+ try {
269
+ await iamRequest("GetUser", { UserName: userName });
270
+ } catch (error) {
271
+ if (error?.code !== "NoSuchEntity")
272
+ throw error;
273
+ await iamRequest("CreateUser", {
274
+ UserName: userName,
275
+ "Tags.member.1.Key": "Application",
276
+ "Tags.member.1.Value": "stacks",
277
+ "Tags.member.2.Key": "Environment",
278
+ "Tags.member.2.Value": "production",
279
+ "Tags.member.3.Key": "Purpose",
280
+ "Tags.member.3.Value": "mail-backup"
281
+ });
282
+ }
283
+ await iamRequest("PutUserPolicy", {
284
+ UserName: userName,
285
+ PolicyName: "mail-restic-backup",
286
+ PolicyDocument: JSON.stringify({
287
+ Version: "2012-10-17",
288
+ Statement: [
289
+ {
290
+ Effect: "Allow",
291
+ Action: ["s3:ListBucket", "s3:GetBucketLocation"],
292
+ Resource: `arn:aws:s3:::${bucket}`,
293
+ Condition: { StringLike: { "s3:prefix": ["restic-mail", "restic-mail/*"] } }
294
+ },
295
+ {
296
+ Effect: "Allow",
297
+ Action: [
298
+ "s3:GetObject",
299
+ "s3:PutObject",
300
+ "s3:DeleteObject",
301
+ "s3:AbortMultipartUpload",
302
+ "s3:ListMultipartUploadParts"
303
+ ],
304
+ Resource: `arn:aws:s3:::${bucket}/restic-mail/*`
305
+ },
306
+ {
307
+ Effect: "Allow",
308
+ Action: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
309
+ Resource: kms.arn
310
+ }
311
+ ]
312
+ })
313
+ });
314
+ const listedMembers = (await iamRequest("ListAccessKeys", { UserName: userName })).ListAccessKeysResult?.AccessKeyMetadata?.member, oldKeys = listedMembers ? Array.isArray(listedMembers) ? listedMembers : [listedMembers] : [];
315
+ if (oldKeys.length > 0 && !options.force)
316
+ throw Error(`IAM user ${userName} already has a key. Pass --force to rotate it.`);
317
+ if (oldKeys.length >= 2)
318
+ for (const old of oldKeys)
319
+ await iamRequest("DeleteAccessKey", { UserName: userName, AccessKeyId: old.AccessKeyId });
320
+ const access = (await iamRequest("CreateAccessKey", { UserName: userName })).CreateAccessKeyResult?.AccessKey;
321
+ if (!access?.AccessKeyId || !access?.SecretAccessKey)
322
+ throw Error("AWS IAM did not return the Restic access key.");
323
+ const password = randomBytes(48).toString("base64url"), repository = `s3:s3.amazonaws.com/${bucket}/restic-mail`, secretId = "stacks/production/mail-restic", secretPayload = JSON.stringify({
324
+ version: 1,
325
+ repository,
326
+ password,
327
+ accessKeyId: access.AccessKeyId,
328
+ secretAccessKey: access.SecretAccessKey,
329
+ kmsKeyArn: kms.arn,
330
+ createdAt: new Date().toISOString()
331
+ }), secrets = new SecretsManagerClient(region);
332
+ try {
333
+ await secrets.createSecret({
334
+ Name: secretId,
335
+ Description: "Restic credentials for production mail backups",
336
+ KmsKeyId: kms.arn,
337
+ SecretString: secretPayload,
338
+ Tags: [
339
+ { Key: "Application", Value: "stacks" },
340
+ { Key: "Environment", Value: "production" },
341
+ { Key: "Purpose", Value: "mail-backup" }
342
+ ]
343
+ });
344
+ } catch (error) {
345
+ if (error?.code !== "ResourceExistsException")
346
+ throw error;
347
+ await secrets.updateSecret({ SecretId: secretId, KmsKeyId: kms.arn, SecretString: secretPayload });
348
+ }
349
+ const configDir = "/var/lib/mail-storage/data/restic", passwordPath = `${configDir}/password`, envPayload = [
350
+ `RESTIC_REPOSITORY=${repository}`,
351
+ `RESTIC_PASSWORD_FILE=${passwordPath}`,
352
+ `AWS_ACCESS_KEY_ID=${access.AccessKeyId}`,
353
+ `AWS_SECRET_ACCESS_KEY=${access.SecretAccessKey}`,
354
+ `AWS_DEFAULT_REGION=${region}`,
355
+ ""
356
+ ].join(`
357
+ `);
358
+ runMailStorageSsh(host, `install -d -m 0700 ${configDir}; umask 077; dd of=${configDir}/restic.env status=none`, Buffer.from(envPayload));
359
+ runMailStorageSsh(host, `umask 077; dd of=${passwordPath} status=none`, Buffer.from(`${password}
360
+ `));
361
+ runMailStorageSsh(host, `set -a; . ${configDir}/restic.env; set +a; restic snapshots >/dev/null 2>&1 || restic init`);
362
+ runMailStorageSsh(host, "systemctl enable --now mail-restic-backup.timer mail-storage-health.timer");
363
+ runMailStorageSsh(host, "systemctl start mail-restic-backup.service");
364
+ runMailStorageSsh(host, "/usr/local/sbin/mail-restic-restore-check");
365
+ for (const old of oldKeys)
366
+ if (old.AccessKeyId !== access.AccessKeyId)
367
+ await iamRequest("DeleteAccessKey", { UserName: userName, AccessKeyId: old.AccessKeyId });
368
+ return { repository, secretId };
369
+ }
370
+ function decodeMailStorageSecret(secretString) {
371
+ if (!secretString)
372
+ throw Error("The mail storage secret has no SecretString value.");
373
+ let parsed;
374
+ try {
375
+ parsed = JSON.parse(secretString);
376
+ } catch {
377
+ throw Error("The mail storage secret is not valid JSON.");
378
+ }
379
+ if (parsed.version !== 1 || parsed.encoding !== "base64" || typeof parsed.key !== "string")
380
+ throw Error("The mail storage secret uses an unsupported format.");
381
+ const key = Buffer.from(parsed.key, "base64");
382
+ if (key.length < 32)
383
+ throw Error("The mail storage key is unexpectedly short.");
384
+ return key;
385
+ }
386
+ async function getMailStorageSecret(options) {
387
+ const { profile, region } = loadMailStorageAws(options), secretId = mailStorageSecretId(options), { SecretsManagerClient } = await import("../../../cloud/src/imap/secrets-manager"), value = await new SecretsManagerClient(region, profile).getSecretValue({ SecretId: secretId });
388
+ return { key: decodeMailStorageSecret(value.SecretString), secretId };
389
+ }
390
+ async function escrowMailStorageKey(options) {
391
+ const { profile, region } = loadMailStorageAws(options), host = await mailStorageHost(options), secretId = mailStorageSecretId(options), { SecretsManagerClient } = await import("../../../cloud/src/imap/secrets-manager"), secrets = new SecretsManagerClient(region, profile), key = runMailStorageSsh(host, "systemd-creds decrypt --name=mail-storage-key /etc/credstore.encrypted/mail-storage-key.cred -");
392
+ if (key.length < 32)
393
+ throw Error("The machine-bound mail storage key is unexpectedly short.");
394
+ let existing;
395
+ try {
396
+ const value = await secrets.getSecretValue({ SecretId: secretId });
397
+ existing = decodeMailStorageSecret(value.SecretString);
398
+ } catch (error) {
399
+ if (error?.code !== "ResourceNotFoundException")
400
+ throw error;
401
+ }
402
+ if (existing && !timingSafeEqual(existing, key)) {
403
+ existing.fill(0);
404
+ if (!options.force) {
405
+ key.fill(0);
406
+ throw Error(`Secret ${secretId} already contains a different key. Pass --force to replace it.`);
407
+ }
408
+ }
409
+ existing?.fill(0);
410
+ const payload = {
411
+ version: 1,
412
+ encoding: "base64",
413
+ key: key.toString("base64"),
414
+ image: "/var/lib/mail-storage.luks",
415
+ mapper: "mail-storage",
416
+ createdAt: new Date().toISOString()
417
+ };
418
+ if (existing)
419
+ await secrets.putSecretValue({ SecretId: secretId, SecretString: JSON.stringify(payload) });
420
+ else
421
+ try {
422
+ await secrets.createSecret({
423
+ Name: secretId,
424
+ Description: `Externally escrowed LUKS2 key for ${host}`,
425
+ SecretString: JSON.stringify(payload),
426
+ Tags: [
427
+ { Key: "Application", Value: process.env.APP_NAME || "stacks" },
428
+ { Key: "Environment", Value: process.env.APP_ENV || "production" },
429
+ { Key: "Purpose", Value: "mail-storage" }
430
+ ]
431
+ });
432
+ } catch (error) {
433
+ if (error?.code !== "ResourceExistsException")
434
+ throw error;
435
+ await secrets.putSecretValue({ SecretId: secretId, SecretString: JSON.stringify(payload) });
436
+ }
437
+ const verify = await secrets.getSecretValue({ SecretId: secretId }), verifiedKey = decodeMailStorageSecret(verify.SecretString), matches = verifiedKey.length === key.length && timingSafeEqual(verifiedKey, key);
438
+ verifiedKey.fill(0);
439
+ key.fill(0);
440
+ if (!matches)
441
+ throw Error(`Secret ${secretId} did not verify after writing.`);
442
+ return { secretId, host };
443
+ }
444
+ function addMailStorageOptions(command) {
445
+ return command.option("--host <host>", "Mail server hostname or IP").option("--secret <id>", "AWS Secrets Manager secret ID").option("--region <region>", "AWS region", { default: "us-east-1" }).option("--profile <profile>", "AWS credential profile", { default: "default" });
446
+ }
43
447
  export function mailCommands(buddy) {
44
448
  buddy.command("mail:provision", "Provision this app's mail from config/email.ts onto the shared mail server (domain + DKIM + mailboxes + MX/SPF/DKIM/DMARC DNS). Reusable + idempotent; the same reconcile buddy deploy runs.").option("--ip <ip>", "Mail server IP (defaults to the A record of config.email.domain)").action(async (options) => {
45
449
  const environment = options.env || process.env.APP_ENV || "production";
@@ -73,6 +477,366 @@ export function mailCommands(buddy) {
73
477
  log.success(`Mail provisioned for ${domain}. Add MAIL_PASSWORD_<LOCALPART> env vars to pin mailbox passwords.`);
74
478
  process.exit(ExitCode.Success);
75
479
  });
480
+ addMailStorageOptions(buddy.command("mail:storage:status", "Show encrypted mail storage and external-key status")).action(async (options) => {
481
+ loadEnvFiles();
482
+ try {
483
+ const host = await mailStorageHost(options), output = runMailStorageSsh(host, `${mailStorageRemote} status`).toString("utf8").trim();
484
+ console.log(output);
485
+ process.exit(ExitCode.Success);
486
+ } catch (error) {
487
+ console.error(`Failed to read mail storage status: ${getErrorMessage(error)}`);
488
+ process.exit(ExitCode.FatalError);
489
+ }
490
+ });
491
+ addMailStorageOptions(buddy.command("mail:storage:kms:ensure", "Create and apply the customer-managed production mail KMS key")).action(async (options) => {
492
+ try {
493
+ const kms = await ensureMailKmsKey(options);
494
+ console.log(`alias=${kms.alias}`);
495
+ console.log(`arn=${kms.arn}`);
496
+ process.exit(ExitCode.Success);
497
+ } catch (error) {
498
+ console.error(`Failed to configure mail KMS: ${getErrorMessage(error)}`);
499
+ process.exit(ExitCode.FatalError);
500
+ }
501
+ });
502
+ addMailStorageOptions(buddy.command("mail:storage:volume:migrate", "Create a dedicated Hetzner volume and migrate encrypted mail storage")).option("--size <gib>", "Hetzner volume size in GiB", { default: "20" }).action(async (options) => {
503
+ let key, prepareAttempted = !1;
504
+ try {
505
+ const volume = await ensureMailStorageVolume(options);
506
+ key = (await getMailStorageSecret(options)).key;
507
+ runMailStorageSsh(volume.host, `for i in $(seq 1 60); do test -b ${shellQuote(volume.device)} && exit 0; sleep 1; done; exit 1`);
508
+ prepareAttempted = !0;
509
+ runMailStorageSsh(volume.host, [
510
+ "umask 077",
511
+ "key_file=$(mktemp /run/mail-volume-migrate-key.XXXXXX)",
512
+ `trap 'shred -u "$key_file" 2>/dev/null || rm -f "$key_file"' EXIT`,
513
+ 'dd of="$key_file" bs=64 count=1 iflag=fullblock status=none',
514
+ `MAIL_ENCRYPTED_IMAGE_SIZE=16G /usr/local/sbin/mail-volume-migrate prepare ${shellQuote(volume.device)}`,
515
+ `${mailStorageRemote} unlock < "$key_file"`,
516
+ "/usr/local/sbin/mail-volume-migrate finalize"
517
+ ].join(" && "), key);
518
+ const status = runMailStorageSsh(volume.host, `${mailStorageRemote} status`).toString("utf8").trim();
519
+ console.log(`volume=${volume.name}`);
520
+ console.log(`volume_id=${volume.id}`);
521
+ console.log(`volume_size_gib=${volume.size}`);
522
+ console.log(status);
523
+ process.exit(ExitCode.Success);
524
+ } catch (error) {
525
+ if (prepareAttempted && key) {
526
+ const host = await mailStorageHost(options);
527
+ try {
528
+ runMailStorageSsh(host, "/usr/local/sbin/mail-volume-migrate rollback");
529
+ } catch (rollbackError) {
530
+ console.error(`Dedicated-volume rollback step failed: ${getErrorMessage(rollbackError)}`);
531
+ }
532
+ try {
533
+ runMailStorageSsh(host, `${mailStorageRemote} unlock`, key);
534
+ } catch (unlockError) {
535
+ console.error(`Mail storage re-unlock failed: ${getErrorMessage(unlockError)}`);
536
+ }
537
+ }
538
+ console.error(`Failed to migrate mail storage volume: ${getErrorMessage(error)}`);
539
+ process.exit(ExitCode.FatalError);
540
+ } finally {
541
+ key?.fill(0);
542
+ }
543
+ });
544
+ addMailStorageOptions(buddy.command("mail:storage:key:recovery:add", "Add an independent LUKS recovery key to macOS Keychain")).action(async (options) => {
545
+ let currentKey, recoveryKey;
546
+ try {
547
+ const host = await mailStorageHost(options);
548
+ currentKey = (await getMailStorageSecret(options)).key;
549
+ recoveryKey = randomBytes(64);
550
+ runMailStorageSsh(host, `${mailStorageRemote} add-recovery-key`, Buffer.concat([currentKey, recoveryKey]));
551
+ const account = "mail-storage-recovery", service = "com.stacks.production.mail.luks", encoded = recoveryKey.toString("base64"), keychainWriter = `
552
+ import Foundation
553
+ import Security
554
+
555
+ let input = FileHandle.standardInput.readDataToEndOfFile()
556
+ guard let encoded = String(data: input, encoding: .utf8)?
557
+ .trimmingCharacters(in: .whitespacesAndNewlines),
558
+ let secret = Data(base64Encoded: encoded),
559
+ secret.count == 64
560
+ else { exit(2) }
561
+
562
+ let query: [CFString: Any] = [
563
+ kSecClass: kSecClassGenericPassword,
564
+ kSecAttrAccount: "${account}",
565
+ kSecAttrService: "${service}",
566
+ ]
567
+ var trustedApplications: [SecTrustedApplication] = []
568
+ for path in ["/usr/bin/security", "/Library/Developer/CommandLineTools/usr/bin/swift-frontend"] {
569
+ var application: SecTrustedApplication?
570
+ guard SecTrustedApplicationCreateFromPath(path, &application) == errSecSuccess,
571
+ let application
572
+ else { exit(4) }
573
+ trustedApplications.append(application)
574
+ }
575
+ var access: SecAccess?
576
+ guard SecAccessCreate(
577
+ "Stacks production mail LUKS recovery key" as CFString,
578
+ trustedApplications as CFArray,
579
+ &access
580
+ ) == errSecSuccess, let access
581
+ else { exit(5) }
582
+
583
+ let attributes: [CFString: Any] = [
584
+ kSecValueData: secret,
585
+ kSecAttrLabel: "Stacks production mail LUKS recovery key",
586
+ kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
587
+ kSecAttrSynchronizable: false,
588
+ kSecAttrAccess: access,
589
+ ]
590
+ var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
591
+ if status == errSecItemNotFound {
592
+ var item = query
593
+ for (key, value) in attributes { item[key] = value }
594
+ status = SecItemAdd(item as CFDictionary, nil)
595
+ }
596
+ guard status == errSecSuccess else { exit(Int32(status)) }
597
+
598
+ var verificationQuery = query
599
+ verificationQuery[kSecReturnData] = true
600
+ verificationQuery[kSecMatchLimit] = kSecMatchLimitOne
601
+ var result: CFTypeRef?
602
+ status = SecItemCopyMatching(verificationQuery as CFDictionary, &result)
603
+ guard status == errSecSuccess, let stored = result as? Data, stored == secret
604
+ else { exit(3) }
605
+ `;
606
+ if (spawnSync("/usr/bin/swift", ["-e", keychainWriter], {
607
+ input: `${encoded}
608
+ `,
609
+ maxBuffer: 1048576
610
+ }).status !== 0)
611
+ throw Error("macOS Keychain write failed");
612
+ console.log(`keychain_service=${service}`);
613
+ console.log("recovery_keyslot=verified");
614
+ process.exit(ExitCode.Success);
615
+ } catch (error) {
616
+ console.error(`Failed to add recovery key: ${getErrorMessage(error)}`);
617
+ process.exit(ExitCode.FatalError);
618
+ } finally {
619
+ currentKey?.fill(0);
620
+ recoveryKey?.fill(0);
621
+ }
622
+ });
623
+ addMailStorageOptions(buddy.command("mail:storage:key:recovery:verify", "Verify the Keychain recovery key against the live LUKS header")).action(async (options) => {
624
+ let key;
625
+ try {
626
+ const host = await mailStorageHost(options);
627
+ key = readMailRecoveryKey();
628
+ runMailStorageSsh(host, "cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks", key);
629
+ console.log("Keychain recovery key unlocks the live mail volume.");
630
+ process.exit(ExitCode.Success);
631
+ } catch (error) {
632
+ console.error(`Recovery key verification failed: ${getErrorMessage(error)}`);
633
+ process.exit(ExitCode.FatalError);
634
+ } finally {
635
+ key?.fill(0);
636
+ }
637
+ });
638
+ addMailStorageOptions(buddy.command("mail:storage:key:slot:remove", "Remove an unusable non-primary LUKS keyslot")).option("--slot <number>", "LUKS keyslot number to remove").action(async (options) => {
639
+ let key;
640
+ try {
641
+ const slot = Number.parseInt(options.slot || "", 10);
642
+ if (!Number.isInteger(slot) || slot < 1 || slot > 31)
643
+ throw Error("Pass a non-primary keyslot from 1 through 31.");
644
+ const host = await mailStorageHost(options);
645
+ key = (await getMailStorageSecret(options)).key;
646
+ const output = runMailStorageSsh(host, `${mailStorageRemote} remove-keyslot ${slot}`, key).toString("utf8").trim();
647
+ console.log(output);
648
+ process.exit(ExitCode.Success);
649
+ } catch (error) {
650
+ console.error(`Failed to remove LUKS keyslot: ${getErrorMessage(error)}`);
651
+ process.exit(ExitCode.FatalError);
652
+ } finally {
653
+ key?.fill(0);
654
+ }
655
+ });
656
+ addMailStorageOptions(buddy.command("mail:storage:restic:configure", "Configure client-encrypted Restic backups to S3")).option("--force", "Rotate an existing dedicated IAM access key", { default: !1 }).action(async (options) => {
657
+ try {
658
+ const result = await configureMailRestic(options);
659
+ console.log(`repository=${result.repository}`);
660
+ console.log(`recovery_secret=${result.secretId}`);
661
+ console.log("restore_check=ok");
662
+ process.exit(ExitCode.Success);
663
+ } catch (error) {
664
+ console.error(`Failed to configure Restic mail backups: ${getErrorMessage(error)}`);
665
+ process.exit(ExitCode.FatalError);
666
+ }
667
+ });
668
+ addMailStorageOptions(buddy.command("mail:storage:restore:check", "Restore and validate the newest off-host mail snapshot")).action(async (options) => {
669
+ try {
670
+ const host = await mailStorageHost(options), output = runMailStorageSsh(host, "/usr/local/sbin/mail-restic-restore-check").toString("utf8").trim();
671
+ console.log(output);
672
+ process.exit(ExitCode.Success);
673
+ } catch (error) {
674
+ console.error(`Mail restore check failed: ${getErrorMessage(error)}`);
675
+ process.exit(ExitCode.FatalError);
676
+ }
677
+ });
678
+ addMailStorageOptions(buddy.command("mail:storage:maildir:migrate", "Migrate live mailboxes to canonical Maildir++ layout")).action(async (options) => {
679
+ try {
680
+ const host = await mailStorageHost(options), output = runMailStorageSsh(host, "/usr/local/sbin/maildir-migrate migrate").toString("utf8").trim();
681
+ console.log(output);
682
+ const status = runMailStorageSsh(host, "/usr/local/sbin/maildir-migrate status").toString("utf8").trim();
683
+ console.log(status);
684
+ process.exit(ExitCode.Success);
685
+ } catch (error) {
686
+ console.error(`Maildir++ migration failed: ${getErrorMessage(error)}`);
687
+ process.exit(ExitCode.FatalError);
688
+ }
689
+ });
690
+ addMailStorageOptions(buddy.command("mail:forward:compile", "Compile and validate the live RFC 5228 forwarding script")).action(async (options) => {
691
+ try {
692
+ const host = await mailStorageHost(options), output = runMailStorageSsh(host, '/usr/local/sbin/mail-forward-compile /opt/mail/forwards.json /opt/mail/forwards.sieve && sed -n "1,160p" /opt/mail/forwards.sieve').toString("utf8").trim();
693
+ console.log(output);
694
+ process.exit(ExitCode.Success);
695
+ } catch (error) {
696
+ console.error(`Forwarding script compilation failed: ${getErrorMessage(error)}`);
697
+ process.exit(ExitCode.FatalError);
698
+ }
699
+ });
700
+ addMailStorageOptions(buddy.command("mail:storage:escrow", "Copy the machine-bound LUKS key to AWS Secrets Manager")).option("--force", "Replace a different existing secret", { default: !1 }).action(async (options) => {
701
+ try {
702
+ const result = await escrowMailStorageKey(options);
703
+ console.log(`Mail storage key verified in AWS Secrets Manager: ${result.secretId}`);
704
+ console.log(`Host: ${result.host}`);
705
+ process.exit(ExitCode.Success);
706
+ } catch (error) {
707
+ console.error(`Failed to escrow mail storage key: ${getErrorMessage(error)}`);
708
+ process.exit(ExitCode.FatalError);
709
+ }
710
+ });
711
+ addMailStorageOptions(buddy.command("mail:storage:key:verify", "Verify the external key against the LUKS2 header")).action(async (options) => {
712
+ let key;
713
+ try {
714
+ const host = await mailStorageHost(options), secret = await getMailStorageSecret(options);
715
+ key = secret.key;
716
+ runMailStorageSsh(host, "cryptsetup open --test-passphrase --type luks2 --key-file - /var/lib/mail-storage.luks", key);
717
+ console.log(`External key ${secret.secretId} unlocks the mail volume.`);
718
+ process.exit(ExitCode.Success);
719
+ } catch (error) {
720
+ console.error(`Mail storage key verification failed: ${getErrorMessage(error)}`);
721
+ process.exit(ExitCode.FatalError);
722
+ } finally {
723
+ key?.fill(0);
724
+ }
725
+ });
726
+ addMailStorageOptions(buddy.command("mail:storage:unlock", "Unlock external mail storage and start mail")).action(async (options) => {
727
+ let key;
728
+ try {
729
+ const host = await mailStorageHost(options), secret = await getMailStorageSecret(options);
730
+ key = secret.key;
731
+ const output = runMailStorageSsh(host, `${mailStorageRemote} unlock`, key).toString("utf8").trim();
732
+ console.log(output);
733
+ console.log(`External key: ${secret.secretId}`);
734
+ process.exit(ExitCode.Success);
735
+ } catch (error) {
736
+ console.error(`Failed to unlock mail storage: ${getErrorMessage(error)}`);
737
+ process.exit(ExitCode.FatalError);
738
+ } finally {
739
+ key?.fill(0);
740
+ }
741
+ });
742
+ addMailStorageOptions(buddy.command("mail:storage:lock", "Stop mail and lock external mail storage")).action(async (options) => {
743
+ loadEnvFiles();
744
+ try {
745
+ const host = await mailStorageHost(options), output = runMailStorageSsh(host, `${mailStorageRemote} lock`).toString("utf8").trim();
746
+ console.log(output);
747
+ process.exit(ExitCode.Success);
748
+ } catch (error) {
749
+ console.error(`Failed to lock mail storage: ${getErrorMessage(error)}`);
750
+ process.exit(ExitCode.FatalError);
751
+ }
752
+ });
753
+ addMailStorageOptions(buddy.command("mail:storage:backup", "Create and verify an encrypted local mail backup")).action(async (options) => {
754
+ loadEnvFiles();
755
+ try {
756
+ const host = await mailStorageHost(options), output = runMailStorageSsh(host, [
757
+ "set -eu",
758
+ "systemctl reset-failed mail-backup.service 2>/dev/null || true",
759
+ "systemctl start mail-backup.service",
760
+ 'latest=$(find -L /opt/mail/backups -maxdepth 1 -type f -name "mail-backup-*.tgz" -printf "%T@ %p\\n" | sort -nr | head -1 | cut -d" " -f2-)',
761
+ 'test -n "$latest"',
762
+ 'tar -tzf "$latest" >/dev/null',
763
+ 'printf "backup=%s\\n" "$latest"',
764
+ 'stat -c "mode=%a owner=%U:%G size=%s" "$latest"'
765
+ ].join("; ")).toString("utf8").trim();
766
+ console.log(output);
767
+ process.exit(ExitCode.Success);
768
+ } catch (error) {
769
+ console.error(`Failed to create mail backup: ${getErrorMessage(error)}`);
770
+ process.exit(ExitCode.FatalError);
771
+ }
772
+ });
773
+ addMailStorageOptions(buddy.command("mail:storage:header:backup", "Stream a LUKS header backup to S3 with KMS encryption")).option("--bucket <bucket>", "Recovery bucket (defaults to <app>-<env>-s3-backups)").action(async (options) => {
774
+ try {
775
+ const { region } = loadMailStorageAws(options), host = await mailStorageHost(options), app = (process.env.APP_NAME || "stacks").toLowerCase().replace(/[^a-z0-9-]/g, "-"), environment = (process.env.APP_ENV || "production").toLowerCase().replace(/[^a-z0-9-]/g, "-"), bucket = options.bucket || `${app}-${environment}-s3-backups`;
776
+ if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket))
777
+ throw Error(`Unsafe S3 bucket name: ${bucket}`);
778
+ const objectKey = `luks-headers/mail-storage-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}.header`, { AWSClient } = await import("../../../cloud/src/imap/client"), { S3Client } = await import("../../../cloud/src/imap/s3"), kms = await ensureMailKmsKey(options), s3 = new S3Client(region);
779
+ await s3.putBucketEncryption(bucket, "aws:kms", kms.arn);
780
+ const uploadUrl = s3.generatePresignedPutUrl(bucket, objectKey, "application/octet-stream", 600), remoteCommand = [
781
+ "set -eu",
782
+ "tmp_dir=$(mktemp -d /run/mail-storage-header.XXXXXX)",
783
+ 'tmp="$tmp_dir/header"',
784
+ `trap 'shred -u "$tmp" 2>/dev/null || rm -f "$tmp"; rmdir "$tmp_dir"' EXIT`,
785
+ 'cryptsetup luksHeaderBackup /var/lib/mail-storage.luks --header-backup-file "$tmp"',
786
+ `curl --fail-with-body --silent --show-error -H 'Content-Type: application/octet-stream' --upload-file "$tmp" ${shellQuote(uploadUrl)} 1>&2`
787
+ ].join("; ");
788
+ runMailStorageSsh(host, remoteCommand);
789
+ const object = await s3.headObject(bucket, objectKey);
790
+ if (!object?.ContentLength || object.ContentLength < 1048576)
791
+ throw Error("The uploaded LUKS header size did not verify.");
792
+ const head = await new AWSClient().request({
793
+ service: "s3",
794
+ region,
795
+ method: "HEAD",
796
+ path: `/${bucket}/${objectKey}`,
797
+ returnHeaders: !0
798
+ }), encryption = head?.headers?.["x-amz-server-side-encryption"];
799
+ if (encryption !== "aws:kms")
800
+ throw Error(`The LUKS header object reported unexpected encryption: ${encryption || "none"}`);
801
+ const keyId = head?.headers?.["x-amz-server-side-encryption-aws-kms-key-id"];
802
+ if (keyId !== kms.arn)
803
+ throw Error(`The LUKS header object used an unexpected KMS key: ${keyId || "none"}`);
804
+ console.log(`header=s3://${bucket}/${objectKey}`);
805
+ console.log("encryption=aws:kms");
806
+ console.log(`kms_key=${kms.arn}`);
807
+ process.exit(ExitCode.Success);
808
+ } catch (error) {
809
+ console.error(`Failed to back up the LUKS header: ${getErrorMessage(error)}`);
810
+ process.exit(ExitCode.FatalError);
811
+ }
812
+ });
813
+ addMailStorageOptions(buddy.command("mail:storage:externalize", "Move the LUKS key off-host and require external unlocks")).option("--force", "Replace a different existing secret", { default: !1 }).action(async (options) => {
814
+ let configured = !1, key;
815
+ try {
816
+ const escrow = await escrowMailStorageKey(options);
817
+ key = (await getMailStorageSecret(options)).key;
818
+ runMailStorageSsh(escrow.host, `${mailStorageRemote} configure-external`);
819
+ configured = !0;
820
+ runMailStorageSsh(escrow.host, `${mailStorageRemote} unlock`, key);
821
+ runMailStorageSsh(escrow.host, `${mailStorageRemote} finalize-external`);
822
+ const status = runMailStorageSsh(escrow.host, `${mailStorageRemote} status`).toString("utf8").trim();
823
+ console.log(status);
824
+ console.log(`External key: ${escrow.secretId}`);
825
+ process.exit(ExitCode.Success);
826
+ } catch (error) {
827
+ if (configured)
828
+ try {
829
+ const host = await mailStorageHost(options);
830
+ runMailStorageSsh(host, `${mailStorageRemote} rollback-machine`);
831
+ } catch (rollbackError) {
832
+ console.error(`Automatic rollback also failed: ${getErrorMessage(rollbackError)}`);
833
+ }
834
+ console.error(`Failed to externalize mail storage: ${getErrorMessage(error)}`);
835
+ process.exit(ExitCode.FatalError);
836
+ } finally {
837
+ key?.fill(0);
838
+ }
839
+ });
76
840
  buddy.command("mail:user:add <email>", "Add a mail user").option("--password <password>", "User password (generated if not provided)").action(async (email, options) => {
77
841
  log.info(`Adding mail user: ${email}`);
78
842
  try {
@@ -18,4 +18,5 @@ export declare function ensureEnvIsSet(options: CliOptions): Promise<void>;
18
18
  declare interface DatabasePackage {
19
19
  name: string
20
20
  version: string
21
+ service?: string
21
22
  }
@@ -211,10 +211,13 @@ export function ensureIdeSettings(cwd) {
211
211
  log.success("Installed project VS Code settings");
212
212
  }
213
213
  const DB_CONNECTION_PACKAGES = {
214
- postgres: { name: "postgresql.org", version: "^17.10" },
215
- mysql: { name: "mysql.com", version: "*" },
214
+ postgres: { name: "postgresql.org", version: "^17.10", service: "postgres" },
215
+ mysql: { name: "mysql.com", version: "*", service: "mysql" },
216
216
  sqlite: { name: "sqlite.org", version: "^3.47.2" }
217
217
  };
218
+ function databaseAliases(connection, pkg) {
219
+ return [connection, pkg.name];
220
+ }
218
221
  export function pantryDatabasePackage(connection) {
219
222
  return DB_CONNECTION_PACKAGES[connection];
220
223
  }
@@ -234,21 +237,36 @@ export async function optimizePantryDeps() {
234
237
  log.debug("No config/deps.ts found, skipping dependency optimization");
235
238
  return;
236
239
  }
237
- let configDeps = {};
240
+ let configDeps = {}, configServices = [];
238
241
  try {
239
242
  const mod = await import(depsConfigPath), config = mod.config || mod.default;
240
243
  if (config?.dependencies)
241
244
  configDeps = { ...config.dependencies };
245
+ if (Array.isArray(config?.services?.autoStart))
246
+ configServices = config.services.autoStart.filter((name) => typeof name === "string");
242
247
  } catch (err) {
243
248
  log.debug("Could not load config/deps.ts, skipping dependency optimization");
244
249
  return;
245
250
  }
246
- const dbPackage = detectDbPackage(cwd);
251
+ const dbPackage = detectDbPackage(cwd), autoStart = [...configServices];
247
252
  if (dbPackage) {
248
- if (!Object.keys(configDeps).some((key) => key === dbPackage.name || key.startsWith(`${dbPackage.name}/`))) {
253
+ const selected = new Set(Object.entries(DB_CONNECTION_PACKAGES).filter(([, pkg]) => pkg.name === dbPackage.name).flatMap(([connection, pkg]) => databaseAliases(connection, pkg))), unused = new Set(Object.entries(DB_CONNECTION_PACKAGES).flatMap(([connection, pkg]) => databaseAliases(connection, pkg)).filter((alias) => !selected.has(alias)));
254
+ for (const pkg of Object.keys(configDeps)) {
255
+ const domain = pkg.split("/")[0];
256
+ if (unused.has(domain)) {
257
+ log.info(`DB_CONNECTION selects ${dbPackage.name}, dropping unused ${pkg}`);
258
+ delete configDeps[pkg];
259
+ }
260
+ }
261
+ if (!Object.keys(configDeps).some((key) => {
262
+ const domain = key.split("/")[0];
263
+ return selected.has(domain);
264
+ })) {
249
265
  log.info(`Detected DB_CONNECTION requires ${dbPackage.name}, adding to dependencies`);
250
266
  configDeps[dbPackage.name] = dbPackage.version;
251
267
  }
268
+ if (dbPackage.service && !autoStart.includes(dbPackage.service))
269
+ autoStart.push(dbPackage.service);
252
270
  }
253
271
  const lines = [
254
272
  "# Auto-generated from config/deps.ts and .env sniffing.",
@@ -261,6 +279,11 @@ export async function optimizePantryDeps() {
261
279
  ];
262
280
  for (const [pkg, version] of Object.entries(configDeps))
263
281
  lines.push(` ${pkg}: ${version}`);
282
+ if (autoStart.length > 0) {
283
+ lines.push("", "services:", " enabled: true", " autoStart:");
284
+ for (const service of autoStart)
285
+ lines.push(` - ${service}`);
286
+ }
264
287
  const depsYamlPath = join(cwd, "deps.yaml");
265
288
  writeFileSync(depsYamlPath, `${lines.join(`
266
289
  `)}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/buddy",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.229",
5
+ "version": "0.70.230",
6
6
  "description": "Meet Buddy. The Stacks runtime.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [