@promptowl/contextnest-community 1.9.0 → 1.10.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.
package/CONFIGURATION.md CHANGED
@@ -56,13 +56,15 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
56
56
  | `DB_SSL_CA` | `""` | Path to a CA certificate (PEM) for verify-ca/verify-full TLS when `DB_SSL=true`. |
57
57
  | `AUTH_MODE` | `key` | `key` or `open`. See above. |
58
58
  | `PROMPTOWL_API_URL` | `https://app.promptowl.ai` | PromptOwl's API origin — used for device auth, license validation, telemetry. Override for air-gapped or test setups. |
59
- | `PROMPTOWL_KEY` | `""` | Your PromptOwl Community License key (`pk_...`). Unlicensed instances still run and serve reads, but every write returns `503` until a valid key is installed. Can also be set via the browser License Setup Page, which persists it to `ENV_FILE_PATH`. |
59
+ | `PROMPTOWL_KEY` | `""` | Your PromptOwl Community License key (`pk_...`). Unlicensed instances still run and serve reads, but every write returns `503` until a valid key is installed. Can also be set via the browser License Setup Page, which persists it to the database (`server_settings` table) so it survives a rebuild and reaches every instance — see [Runtime settings persistence](#runtime-settings-persistence). Setting it here in the deploy environment takes precedence on the next boot. |
60
60
  | `PROMPTOWL_SIGN_IN_GATE` | `open` | Restrict "Sign in with PromptOwl". `open` = anyone may; `admin-only` = only the license owner (admin) may, everyone else uses email/password (admin opens the login page with `?admin=1`); `disabled` = nobody may. Enforced server-side at `POST /auth/promptowl` and surfaced on the health endpoint. Unknown values fall back to `open`. |
61
+ | `MANUAL_SIGN_IN` | `open` | Email + password sign-in mode. `open` = anyone may log in and self-register a new account; `invite-only` = existing/invited users may log in but brand-new self-registration returns `403` (the admin provisions accounts via invite/share/steward and shares the password — there is no self-service "set password", which would be account takeover without email verification); `disabled` = no email/password sign-in at all (`POST /auth/login` and `POST /auth/register` return `403`). Independent of `PROMPTOWL_SIGN_IN_GATE`, so the two methods are controlled separately (e.g. `invite-only` manual + `admin-only` PromptOwl). Also settable from Settings → General. The server refuses `disabled` while PromptOwl sign-in is also `disabled` (that would leave no way to log in). Unknown values fall back to `open`. |
61
62
  | `OFFICIAL_COMMUNITY_SSO_SECRET` | `""` | **Official deployment only — leave unset on self-hosted.** Shared HMAC secret enabling the one-click "Open Community" SSO auto-login from PromptOwl. Must exactly match the same-named var on PromptOwl. When unset, `GET /auth/sso` returns `404` and the feature is disabled; self-hosted users keep using the manual device-code flow. |
62
63
  | `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`). Checked against the SSO ticket's `aud` claim so a ticket minted for this server can't be replayed against another. Only relevant when `OFFICIAL_COMMUNITY_SSO_SECRET` is set; when unset, the audience check is skipped. |
63
- | `ENV_FILE_PATH` | `$DATA_ROOT/.env` | Path to the `.env` file the license install flow writes `PROMPTOWL_KEY` into (alongside existing vars), and which the server also reads at boot. Defaults **under `DATA_ROOT`** so the browser License Setup Page persists durably in containers `$cwd` is `/app` in the official image (root-owned, discarded on container recreate), which silently lost the key. Override only if your writable, persisted `.env` lives elsewhere. In containers, providing `PROMPTOWL_KEY` directly via the environment also works and is read at boot. |
64
+ | `ENV_FILE_PATH` | `$DATA_ROOT/.env` | Path to an optional `.env` file the server reads at boot (in addition to `$cwd/.env`). **No longer used for persistence** the License Setup Page and Settings page now write to the database, not this file (see [Runtime settings persistence](#runtime-settings-persistence)). Kept for operators who bootstrap config from a mounted `.env`. |
64
65
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
65
66
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
67
+ | `TRACE_RETENTION_DAYS` | `14` | Activity-trace retention window in days (the `api_events` rows behind `GET /admin/trace` and `GET /nests/:id/trace`). Rows older than this are pruned opportunistically (every ~500 inserts). `0` = keep forever (pruning is skipped entirely). Capped at `3650`; invalid/negative values fall back to `14`. Also editable from Settings → Advanced. |
66
68
  | `CORS_ORIGINS` | `*` in open mode; `http://localhost:5173,http://localhost:3838` in key mode | Comma-separated allowlist. Set to `*` to allow any origin (**only** safe in open mode — in key mode with Bearer tokens this enables CSRF). |
67
69
  | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. |
68
70
  | `LOGO_URL` | _(unset)_ | Custom logo shown in the UI header + login screen. Must start with `https://`, `http://`, or `data:image/` — other schemes (`file://`, relative, `javascript:`) are rejected with a warning and the bundled icon is used. |
@@ -103,6 +105,31 @@ Postgres backend starts empty.
103
105
  > shared storage — e.g. mount a GCS bucket via Cloud Storage FUSE, or use a
104
106
  > persistent volume — otherwise documents will disappear on redeploy/scale-in.
105
107
 
108
+ ### Runtime settings persistence
109
+
110
+ Settings you change at runtime — everything on the **Settings page** (`/admin/settings`:
111
+ sign-in gate, logo, base URL, upload limit, feature flags, Slack/SMTP connectors)
112
+ plus the **installed license key** — are stored in the database (`server_settings`
113
+ table), **not** in a `.env` file. This is deliberate: on Cloud Run the container
114
+ filesystem is ephemeral, so a file-based value was wiped by every rebuild, and each
115
+ horizontally-scaled instance had its own filesystem, so a change on one never
116
+ reached the others. The database (Cloud SQL in production) is durable **and** shared,
117
+ so a UI change persists across rebuilds and is seen by every instance. Rows are
118
+ loaded into the process environment at boot.
119
+
120
+ **Precedence between the deploy environment and a UI change.** Each stored row
121
+ remembers the deploy-env value in effect when it was written. At boot:
122
+
123
+ - If the deploy env value for that key is **unchanged**, the stored UI change wins
124
+ (your Settings-page edit survives an identical rebuild).
125
+ - If the deploy env value **changed** (you redeployed with a new value for that
126
+ variable), the deploy env wins and the stored row is re-synced to it.
127
+
128
+ So you can always override any setting from the deploy config, while UI edits made
129
+ to keys you don't set in the environment stay put. Clearing a setting in the UI
130
+ writes a tombstone, so a value you removed is not resurrected from the environment
131
+ on the next boot.
132
+
106
133
  ### Cloud Run + Cloud SQL (PostgreSQL)
107
134
 
108
135
  Attach the Cloud SQL instance to the service (`--add-cloudsql-instances`) so the
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-3JTODC3Y.js";
4
4
  import {
5
5
  getDb
6
- } from "./chunk-DPHV6Q26.js";
6
+ } from "./chunk-BC6KFUZH.js";
7
7
 
8
8
  // src/governance/grants-service.ts
9
9
  import { v4 as uuid } from "uuid";
@@ -29,7 +29,7 @@ var slackUrlWarned = false;
29
29
  var emailFromWarned = false;
30
30
  var emailToWarned = false;
31
31
  function isEmailish(v) {
32
- return /^[^\s@]+@[^\s@]+$/.test(v) && !/[\r\n]/.test(v);
32
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && !/[\r\n]/.test(v);
33
33
  }
34
34
  function isEmailListish(v) {
35
35
  return v.split(",").every((e) => isEmailish(e.trim()));
@@ -124,6 +124,18 @@ var config = {
124
124
  get PROMPTOWL_KEY() {
125
125
  return process.env.PROMPTOWL_KEY || "";
126
126
  },
127
+ /** Vercel automation-bypass secret for PO calls; unset = no bypass. */
128
+ get PROMPTOWL_BYPASS_SECRET() {
129
+ return process.env.PROMPTOWL_BYPASS_SECRET || "";
130
+ },
131
+ /** Outbound headers for PO fetches; bypass header only when secret set. */
132
+ get PROMPTOWL_FETCH_HEADERS() {
133
+ const h = { "Content-Type": "application/json" };
134
+ if (this.PROMPTOWL_BYPASS_SECRET) {
135
+ h["x-vercel-protection-bypass"] = this.PROMPTOWL_BYPASS_SECRET;
136
+ }
137
+ return h;
138
+ },
127
139
  /**
128
140
  * Shared secret for one-click SSO auto-login from PromptOwl (PO).
129
141
  *
@@ -164,6 +176,24 @@ var config = {
164
176
  const v = (process.env.PROMPTOWL_SIGN_IN_GATE || "open").trim().toLowerCase();
165
177
  return v === "admin-only" || v === "disabled" ? v : "open";
166
178
  },
179
+ /**
180
+ * Manual (email + password) sign-in. Three modes, independent of
181
+ * `PROMPTOWL_SIGN_IN_GATE` (the two methods are controlled separately):
182
+ * "open" — anyone may log in and self-register (default).
183
+ * "invite-only" — existing + admin-invited users may log in and an invited
184
+ * placeholder may set its password ("claim"), but brand-new
185
+ * self-registration is refused. Use this when the admin
186
+ * provisions accounts (invite / share / steward) and users
187
+ * shouldn't be able to create their own.
188
+ * "disabled" — no email/password sign-in at all (PromptOwl only).
189
+ * Enforced at POST /auth/login and POST /auth/register. The /admin/settings
190
+ * validator refuses "disabled" while PromptOwl is also disabled, so a server
191
+ * can never be left with no way to sign in.
192
+ */
193
+ get MANUAL_SIGN_IN() {
194
+ const v = (process.env.MANUAL_SIGN_IN || "open").trim().toLowerCase();
195
+ return v === "invite-only" || v === "disabled" ? v : "open";
196
+ },
167
197
  /**
168
198
  * Path to the .env file the server reads its config from and the license
169
199
  * install flow persists PROMPTOWL_KEY into. Defaults UNDER DATA_ROOT (not
@@ -181,6 +211,18 @@ var config = {
181
211
  get TELEMETRY_INTERVAL_MS() {
182
212
  return parseInt(process.env.TELEMETRY_INTERVAL_MS || "3600000", 10);
183
213
  },
214
+ /**
215
+ * Activity-trace retention window in days (the api_events table behind
216
+ * GET /admin/trace and GET /nests/:id/trace). 0 = keep forever (pruning is
217
+ * skipped entirely). Invalid / negative → default 14; capped at 3650 (ten
218
+ * years) so a typo can't schedule a prune cutoff in the distant past.
219
+ * Editable at runtime from the admin Settings page (/admin/settings).
220
+ */
221
+ get TRACE_RETENTION_DAYS() {
222
+ const n = parseInt(process.env.TRACE_RETENTION_DAYS || "14", 10);
223
+ if (!Number.isFinite(n) || n < 0) return 14;
224
+ return Math.min(n, 3650);
225
+ },
184
226
  /**
185
227
  * Optional custom logo URL shown in UI header + login screen.
186
228
  * Must be an absolute https://, http://, or data:image/… URL. Other
@@ -206,6 +248,26 @@ var config = {
206
248
  * This flag is also the future license-tier hook: gating a paid tier here
207
249
  * is a one-line change because every plane route checks it per request.
208
250
  */
251
+ /**
252
+ * Optional Slack incoming-webhook URL for team notifications (review
253
+ * submitted/approved/rejected). Empty/unset = connector off. https only —
254
+ * a webhook carries an implicit secret in its path, so it never travels
255
+ * plaintext. (Ported from development PR #105.)
256
+ */
257
+ get SLACK_WEBHOOK_URL() {
258
+ const raw = process.env.SLACK_WEBHOOK_URL?.trim();
259
+ if (!raw) return null;
260
+ if (!/^https:\/\//i.test(raw)) {
261
+ if (!slackUrlWarned) {
262
+ slackUrlWarned = true;
263
+ console.warn(
264
+ "[config] SLACK_WEBHOOK_URL rejected: must be an https:// URL. Slack notifications disabled."
265
+ );
266
+ }
267
+ return null;
268
+ }
269
+ return raw;
270
+ },
209
271
  get FEATURE_WORKFLOW_PLANE() {
210
272
  return process.env.FEATURE_WORKFLOW_PLANE === "true";
211
273
  },
@@ -238,6 +300,13 @@ var config = {
238
300
  const n = parseInt(process.env.RUN_MAX_STEPS || "10000", 10);
239
301
  return Number.isFinite(n) ? Math.min(1e5, Math.max(10, n)) : 1e4;
240
302
  },
303
+ /** Max concurrently-RUNNING root (depth-0) runs per nest. The subagent
304
+ * depth/fan-out caps bound a single tree; this bounds how many trees a
305
+ * caller can start at once, so root triggers can't flood the nest. */
306
+ get RUN_MAX_CONCURRENT_ROOTS() {
307
+ const n = parseInt(process.env.RUN_MAX_CONCURRENT_ROOTS || "50", 10);
308
+ return Number.isFinite(n) ? Math.min(1e3, Math.max(1, n)) : 50;
309
+ },
241
310
  get AUTH_MODE() {
242
311
  return process.env.AUTH_MODE || "key";
243
312
  },
@@ -295,26 +364,6 @@ var config = {
295
364
  }
296
365
  return raw;
297
366
  },
298
- /**
299
- * Optional Slack incoming-webhook URL for team notifications (review
300
- * submitted/approved/rejected, nest shared). Empty/unset = connector off.
301
- * https only — a webhook carries an implicit secret in its path, so it
302
- * never travels plaintext.
303
- */
304
- get SLACK_WEBHOOK_URL() {
305
- const raw = process.env.SLACK_WEBHOOK_URL?.trim();
306
- if (!raw) return null;
307
- if (!/^https:\/\//i.test(raw)) {
308
- if (!slackUrlWarned) {
309
- slackUrlWarned = true;
310
- console.warn(
311
- "[config] SLACK_WEBHOOK_URL rejected: must be an https:// URL. Slack notifications disabled."
312
- );
313
- }
314
- return null;
315
- }
316
- return raw;
317
- },
318
367
  /**
319
368
  * Per-nest notification digest window in milliseconds. A burst of governance
320
369
  * events on one nest inside this window collapses into a single digest
@@ -1078,6 +1127,140 @@ function runMigrations(db) {
1078
1127
  })();
1079
1128
  recordMigration("018_subagent_runs");
1080
1129
  }
1130
+ if (!hasMigration("020_server_settings")) {
1131
+ db.transaction(() => {
1132
+ db.exec(`
1133
+ CREATE TABLE IF NOT EXISTS server_settings (
1134
+ key TEXT PRIMARY KEY,
1135
+ value TEXT,
1136
+ env_at_write TEXT,
1137
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1138
+ );
1139
+ `);
1140
+ recordMigration("020_server_settings");
1141
+ })();
1142
+ }
1143
+ if (!hasMigration("020_schedules")) {
1144
+ db.transaction(() => {
1145
+ db.exec(`
1146
+ CREATE TABLE IF NOT EXISTS schedules (
1147
+ id TEXT PRIMARY KEY,
1148
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1149
+ agent_node TEXT NOT NULL,
1150
+ every_minutes INTEGER NOT NULL CHECK(every_minutes >= 5),
1151
+ enabled INTEGER NOT NULL DEFAULT 1,
1152
+ last_run_at TEXT,
1153
+ created_by TEXT NOT NULL,
1154
+ created_at TEXT NOT NULL,
1155
+ updated_at TEXT NOT NULL
1156
+ );
1157
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_agent
1158
+ ON schedules(nest_id, agent_node);
1159
+ `);
1160
+ })();
1161
+ recordMigration("020_schedules");
1162
+ }
1163
+ if (!hasMigration("021_schedule_bounds")) {
1164
+ db.transaction(() => {
1165
+ db.exec(`
1166
+ ALTER TABLE schedules ADD COLUMN max_runs INTEGER;
1167
+ ALTER TABLE schedules ADD COLUMN runs_created INTEGER NOT NULL DEFAULT 0;
1168
+ `);
1169
+ })();
1170
+ recordMigration("021_schedule_bounds");
1171
+ }
1172
+ if (!hasMigration("022_tools_and_watchers")) {
1173
+ db.transaction(() => {
1174
+ db.exec(`
1175
+ CREATE TABLE IF NOT EXISTS nest_env (
1176
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1177
+ key TEXT NOT NULL,
1178
+ value TEXT NOT NULL,
1179
+ updated_by TEXT NOT NULL,
1180
+ updated_at TEXT NOT NULL,
1181
+ PRIMARY KEY (nest_id, key)
1182
+ );
1183
+ CREATE TABLE IF NOT EXISTS watchers (
1184
+ id TEXT PRIMARY KEY,
1185
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1186
+ node_id TEXT NOT NULL,
1187
+ user_email TEXT NOT NULL,
1188
+ created_by TEXT NOT NULL,
1189
+ created_at TEXT NOT NULL
1190
+ );
1191
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_unique
1192
+ ON watchers(nest_id, node_id, user_email);
1193
+ CREATE TABLE IF NOT EXISTS notifications (
1194
+ id TEXT PRIMARY KEY,
1195
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1196
+ user_email TEXT NOT NULL,
1197
+ kind TEXT NOT NULL,
1198
+ subject_id TEXT,
1199
+ message TEXT NOT NULL,
1200
+ created_at TEXT NOT NULL,
1201
+ read_at TEXT
1202
+ );
1203
+ CREATE INDEX IF NOT EXISTS idx_notifications_user
1204
+ ON notifications(user_email, read_at, created_at);
1205
+ `);
1206
+ })();
1207
+ recordMigration("022_tools_and_watchers");
1208
+ }
1209
+ if (!hasMigration("023_connectors")) {
1210
+ db.transaction(() => {
1211
+ db.exec(`
1212
+ CREATE TABLE IF NOT EXISTS connectors (
1213
+ id TEXT PRIMARY KEY,
1214
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1215
+ channel TEXT NOT NULL CHECK(channel IN ('slack', 'teams', 'webhook')),
1216
+ url TEXT NOT NULL, -- https://\u2026 or env:KEY
1217
+ events TEXT NOT NULL, -- JSON array of kinds, or ["*"]
1218
+ enabled INTEGER NOT NULL DEFAULT 1,
1219
+ created_by TEXT NOT NULL,
1220
+ created_at TEXT NOT NULL,
1221
+ updated_at TEXT NOT NULL
1222
+ );
1223
+ CREATE INDEX IF NOT EXISTS idx_connectors_nest ON connectors(nest_id);
1224
+ `);
1225
+ })();
1226
+ recordMigration("023_connectors");
1227
+ }
1228
+ if (!hasMigration("024_trigger_hooks")) {
1229
+ db.transaction(() => {
1230
+ db.exec(`
1231
+ CREATE TABLE IF NOT EXISTS trigger_hooks (
1232
+ id TEXT PRIMARY KEY, -- the token (hk_\u2026), capability auth
1233
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1234
+ agent_node TEXT NOT NULL,
1235
+ preset TEXT NOT NULL CHECK(preset IN ('slack', 'teams', 'webhook')),
1236
+ enabled INTEGER NOT NULL DEFAULT 1,
1237
+ fire_count INTEGER NOT NULL DEFAULT 0,
1238
+ last_fired_at TEXT,
1239
+ created_by TEXT NOT NULL,
1240
+ created_at TEXT NOT NULL
1241
+ );
1242
+ CREATE INDEX IF NOT EXISTS idx_hooks_nest ON trigger_hooks(nest_id);
1243
+ `);
1244
+ })();
1245
+ recordMigration("024_trigger_hooks");
1246
+ }
1247
+ if (!hasMigration("025_run_claims")) {
1248
+ db.transaction(() => {
1249
+ db.exec(`
1250
+ ALTER TABLE runs ADD COLUMN claimed_by TEXT;
1251
+ ALTER TABLE runs ADD COLUMN claimed_at TEXT;
1252
+ `);
1253
+ })();
1254
+ recordMigration("025_run_claims");
1255
+ }
1256
+ if (!hasMigration("026_api_events_nest_index")) {
1257
+ db.transaction(() => {
1258
+ db.exec(`
1259
+ CREATE INDEX IF NOT EXISTS idx_api_events_nest_ts ON api_events(nest_id, id);
1260
+ `);
1261
+ })();
1262
+ recordMigration("026_api_events_nest_index");
1263
+ }
1081
1264
  }
1082
1265
  function mergeCaseCollidingUsers(db) {
1083
1266
  const groups = db.prepare(
@@ -1295,7 +1478,7 @@ async function initDb() {
1295
1478
  if (config.DB_DRIVER === "postgres") {
1296
1479
  const { Pool } = await import("pg");
1297
1480
  const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
1298
- const { runPostgresMigrations } = await import("./migrations.postgres-ORSV7UFJ.js");
1481
+ const { runPostgresMigrations } = await import("./migrations.postgres-4XYY3CTF.js");
1299
1482
  const pool = new Pool(buildPgConfig());
1300
1483
  adapter = new PostgresAdapter(pool);
1301
1484
  await runPostgresMigrations(adapter);
@@ -9,7 +9,7 @@ import {
9
9
  import {
10
10
  config,
11
11
  getDb
12
- } from "./chunk-DPHV6Q26.js";
12
+ } from "./chunk-BC6KFUZH.js";
13
13
  import {
14
14
  ANON_USER_ID
15
15
  } from "./chunk-SLTQACJW.js";
@@ -235,7 +235,7 @@ async function flushTelemetry() {
235
235
  try {
236
236
  const res = await fetch(url, {
237
237
  method: "POST",
238
- headers: { "Content-Type": "application/json" },
238
+ headers: config.PROMPTOWL_FETCH_HEADERS,
239
239
  body: JSON.stringify(payload)
240
240
  });
241
241
  console.log(`[telemetry] response: ${res.status} ${res.statusText}`);
@@ -261,8 +261,53 @@ function startTelemetryLoop() {
261
261
  );
262
262
  }
263
263
 
264
+ // src/db/settings.ts
265
+ var deployBaseline = null;
266
+ function norm(v) {
267
+ return v === void 0 || v === null ? null : v;
268
+ }
269
+ function baseline() {
270
+ if (!deployBaseline) deployBaseline = { ...process.env };
271
+ return deployBaseline;
272
+ }
273
+ async function persistSetting(name, value) {
274
+ const db = getDb();
275
+ const envAtWrite = norm(baseline()[name]);
276
+ await db.run(
277
+ insertOrReplace(
278
+ db,
279
+ `INSERT INTO server_settings (key, value, env_at_write, updated_at)
280
+ VALUES (?, ?, ?, ${nowExpr(db)})`,
281
+ ["key"],
282
+ `value = excluded.value, env_at_write = excluded.env_at_write, updated_at = ${nowExpr(db)}`
283
+ ),
284
+ [name, value, envAtWrite]
285
+ );
286
+ }
287
+ async function loadServerSettings() {
288
+ deployBaseline = { ...process.env };
289
+ const base = deployBaseline;
290
+ const db = getDb();
291
+ const rows = await db.all(
292
+ "SELECT key, value, env_at_write FROM server_settings"
293
+ );
294
+ let applied = 0;
295
+ for (const row of rows) {
296
+ const envVal = norm(base[row.key]);
297
+ if (envVal !== norm(row.env_at_write)) {
298
+ await persistSetting(row.key, envVal);
299
+ if (envVal === null) delete process.env[row.key];
300
+ else process.env[row.key] = envVal;
301
+ } else {
302
+ if (row.value === null) delete process.env[row.key];
303
+ else process.env[row.key] = row.value;
304
+ }
305
+ applied++;
306
+ }
307
+ return applied;
308
+ }
309
+
264
310
  // src/auth/license.ts
265
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
266
311
  var currentLicense = null;
267
312
  function getCurrentLicense() {
268
313
  return currentLicense;
@@ -284,21 +329,6 @@ async function isLicenseAdminUserId(userId) {
284
329
  return false;
285
330
  }
286
331
  }
287
- function upsertEnvVar(filePath, varName, value) {
288
- const prefix = `${varName}=`;
289
- let lines = [];
290
- if (existsSync2(filePath)) {
291
- lines = readFileSync2(filePath, "utf8").split(/\r?\n/);
292
- }
293
- const filtered = lines.filter((line) => !line.trimStart().startsWith(prefix));
294
- if (value !== null) {
295
- filtered.push(`${prefix}${value}`);
296
- }
297
- while (filtered.length && filtered[filtered.length - 1] === "") {
298
- filtered.pop();
299
- }
300
- writeFileSync2(filePath, filtered.join("\n") + "\n", "utf8");
301
- }
302
332
  async function installLicenseKey(key) {
303
333
  const trimmed = key.trim();
304
334
  if (!trimmed.startsWith("pk_")) {
@@ -315,12 +345,12 @@ async function installLicenseKey(key) {
315
345
  return { ...info, persisted: false };
316
346
  }
317
347
  try {
318
- upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
348
+ await persistSetting("PROMPTOWL_KEY", trimmed);
319
349
  return { ...info, persisted: true };
320
350
  } catch (err) {
321
351
  const persistError = err instanceof Error ? err.message : String(err);
322
352
  console.warn(
323
- `[license] validated but FAILED to persist to ${config.ENV_FILE_PATH}: ${persistError}`
353
+ `[license] validated but FAILED to persist to the database: ${persistError}`
324
354
  );
325
355
  return { ...info, persisted: false, persistError };
326
356
  }
@@ -360,12 +390,12 @@ async function handleLicenseRevoked() {
360
390
  console.warn("[license] failed to wipe sessions:", err);
361
391
  }
362
392
  try {
363
- upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", null);
393
+ await persistSetting("PROMPTOWL_KEY", null);
364
394
  console.warn(
365
- `[license] revoked \u2014 removed PROMPTOWL_KEY from ${config.ENV_FILE_PATH}`
395
+ "[license] revoked \u2014 cleared persisted PROMPTOWL_KEY from the database"
366
396
  );
367
397
  } catch (err) {
368
- console.warn("[license] failed to strip key from .env:", err);
398
+ console.warn("[license] failed to clear persisted key:", err);
369
399
  }
370
400
  process.env.PROMPTOWL_KEY = "";
371
401
  currentLicense = {
@@ -430,7 +460,7 @@ async function _validateLicenseImpl(forceFresh) {
430
460
  const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
431
461
  const res = await fetch(`${promptowlUrl}/api/license/validate`, {
432
462
  method: "POST",
433
- headers: { "Content-Type": "application/json" },
463
+ headers: config.PROMPTOWL_FETCH_HEADERS,
434
464
  body: JSON.stringify({ key })
435
465
  });
436
466
  if (!res.ok) {
@@ -890,6 +920,12 @@ async function buildTitleMap(nestId) {
890
920
  function folderForNode(nodeId) {
891
921
  return nodeId.replace(/^nodes\//, "").split("/").slice(0, -1).join("/");
892
922
  }
923
+ async function describeNode(nestId, nodeId) {
924
+ const titles = await buildTitleMap(nestId);
925
+ const title = titles.get(nodeId) || nodeId;
926
+ const folder = folderForNode(nodeId);
927
+ return folder ? `${title} (in ${folder})` : title;
928
+ }
893
929
  async function nestName(nestId) {
894
930
  try {
895
931
  const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
@@ -1219,13 +1255,22 @@ async function createStewardRecord(params) {
1219
1255
  "SELECT id FROM users WHERE email = ?",
1220
1256
  [email]
1221
1257
  );
1258
+ let userId = userRow?.id;
1259
+ if (!userId) {
1260
+ const { hashPassword } = await import("./keys-73STFJJB.js");
1261
+ userId = uuid2();
1262
+ await db.run(
1263
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1264
+ [userId, email, null, await hashPassword(uuid2())]
1265
+ );
1266
+ }
1222
1267
  const created = await assignSteward({
1223
1268
  nestId: params.nestId,
1224
1269
  scope: params.scope,
1225
1270
  nodePattern,
1226
1271
  tagName,
1227
1272
  userEmail: email,
1228
- userId: userRow?.id,
1273
+ userId,
1229
1274
  role: user.role ?? "reviewer",
1230
1275
  assignedBy: params.assignedBy,
1231
1276
  assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1541,10 +1586,11 @@ function rowToSteward(row) {
1541
1586
  export {
1542
1587
  trackEvent,
1543
1588
  startTelemetryLoop,
1589
+ persistSetting,
1590
+ loadServerSettings,
1544
1591
  getCurrentLicense,
1545
1592
  isLicenseAdminEmail,
1546
1593
  isLicenseAdminUserId,
1547
- upsertEnvVar,
1548
1594
  installLicenseKey,
1549
1595
  startLicenseSafetyPoll,
1550
1596
  isSuspended,
@@ -1573,6 +1619,7 @@ export {
1573
1619
  deleteNest,
1574
1620
  engineCache,
1575
1621
  buildTitleMap,
1622
+ describeNode,
1576
1623
  nestName,
1577
1624
  docLink,
1578
1625
  buildDocContext,
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-XQ46F76G.js";
5
5
  import {
6
6
  getDb
7
- } from "./chunk-DPHV6Q26.js";
7
+ } from "./chunk-BC6KFUZH.js";
8
8
 
9
9
  // src/governance/version-service.ts
10
10
  import { createHash } from "crypto";