@promptowl/contextnest-community 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONFIGURATION.md CHANGED
@@ -49,7 +49,8 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
49
49
  | `AUTH_MODE` | `key` | `key` or `open`. See above. |
50
50
  | `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. |
51
51
  | `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`. |
52
- | `ENV_FILE_PATH` | `$cwd/.env` | Path to the `.env` file the license install flow writes `PROMPTOWL_KEY` into (alongside existing vars). Override when your `.env` lives outside the working directory. |
52
+ | `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`. |
53
+ | `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. |
53
54
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
54
55
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
55
56
  | `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). |
@@ -99,7 +100,7 @@ allowed_users:
99
100
  - "*.acme.com" # email wildcard — anyone @acme.com
100
101
  - "partner@vendor.com" # exact match
101
102
  super_admins:
102
- - "ceo@acme.com" # always allowed on every nest
103
+ - "ceo@acme.com" # admin on every nest: visibility, collaborators, stewards (not owner-only delete/transfer)
103
104
  groups:
104
105
  engineering:
105
106
  default_permission: write
package/README.md CHANGED
@@ -97,6 +97,10 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
97
97
  | Per-nest sharing + collaborators | ✅ | ✅ |
98
98
  | Public read-only nests | ✅ | ✅ |
99
99
  | Custom logo / branding | ✅ | ✅ |
100
+ | Admin password reset + user removal (in-platform) | ✅ | ✅ |
101
+ | Wiki backlinks, outline, hover-preview, link health | ✅ | ✅ |
102
+ | Rich editor — tables, callouts, toggles, code highlight, find/replace | ✅ | ✅ |
103
+ | Steward version revert | ✅ | ✅ |
100
104
  | MCP server for AI agents | ✅ | ✅ |
101
105
  | Centralized multi-tenant admin console | — | ✅ |
102
106
  | SSO / SAML / SCIM | — | ✅ |
@@ -106,6 +110,35 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
106
110
 
107
111
  For Enterprise pricing and features, contact **hoot@promptowl.ai** or visit <https://promptowl.ai/contextnest/>.
108
112
 
113
+ ## What's new in 1.3.0
114
+
115
+ - **Server super-admins** — emails listed in `access.yaml: super_admins` administer every nest (change visibility, manage collaborators and stewards) without being added per-nest. Owner-only operations (delete, transfer) still require the nest owner. See [STEWARDSHIP.md](./STEWARDSHIP.md).
116
+ - **License persistence under `DATA_ROOT`** — `PROMPTOWL_KEY` now writes to `$DATA_ROOT/.env` (was `$cwd/.env`, which is root-owned `/app` in the official Docker image and silently lost the key on container recreate). The License Setup Page also surfaces write failures instead of failing quietly. Override via `ENV_FILE_PATH`. See [CONFIGURATION.md](./CONFIGURATION.md).
117
+ - **Case-insensitive email** across registration, login, invite, and access checks. Migration dedupes existing accounts that differ only in case; fixes "I can't log in because I capitalized my email" and access-guard / lockout mismatches.
118
+ - **Document version history + inline diff** — every saved version is browsable in the document detail view, and an inline diff highlights what changed between any two versions.
119
+ - **Nest Overview / index landing** — every nest opens on a grouped table-of-contents with a "recently edited" strip instead of a flat list.
120
+ - **Public/private toggle on the nest header** — visibility is set directly from the header, not buried in a dialog.
121
+ - **Review queue surfaces submitted-for-review edits** — reviewers see the pending versions a contributor staged, not just net-new documents.
122
+ - **Internal-folder sync over MCP** — agents can list a nest's unsynced filesystem folders and pull them into the nest on demand. New `unsynced-service.ts` + MCP tools, covered by `test/unsynced-folder.test.ts` and `test/mcp-unsynced.test.ts`.
123
+ - **Telemetry `batch_id` + user email** — usage events carry a stable `batch_id` (de-duped on the receiver) and the authenticated user email alongside the user id, so the PromptOwl ingest bridge can meter credits to the right account.
124
+ - **Hyperlink navigation fix** — hyperlinks in the editor and viewer navigate to their configured target URL correctly.
125
+ - **Repo / DX** — `CLAUDE.md` at the repo root with project-specific guidance for Claude Code; `claude.yml` (PR assistant) and `claude-code-review.yml` (auto review on PRs) GitHub Actions workflows.
126
+
127
+ Full history in [CHANGELOG.md](./CHANGELOG.md).
128
+
129
+ ## What's new in 1.2.0
130
+
131
+ - **Admin user management (no email server needed)** — `POST /auth/admin/reset-password/:userId` sets a user's password or returns a one-time temp password; `DELETE /auth/users/:userId` removes a user and revokes their API keys, sessions, and role rows. New self-service **Change password** in the user menu blocks reusing the current password.
132
+ - **`PROMPTOWL_SIGN_IN_GATE`** — `open` / `admin-only` / `disabled` restricts "Sign in with PromptOwl" (admin reaches it via `?admin=1`). See [CONFIGURATION.md](./CONFIGURATION.md).
133
+ - **Plain-text agent view** — `?format=markdown` on a node returns frontmatter + body as `text/markdown` for LLM-friendly consumption.
134
+ - **Steward version revert** — `POST .../revert` restores an earlier version as a new one; pending-review docs are now editable (saving withdraws the review).
135
+ - **Editor — always-editable surface** — Notion-style buttery headline, selection bubble toolbar (format-on-highlight), turn-into block conversion, Cmd+F find & replace, rich blocks (tables, callouts, toggles, syntax-highlighted code, columns), and wiki/code-safety fixes.
136
+ - **Wiki usability** — backlinks panel ("Linked from N documents"), outline/TOC for 3+-heading docs, hover-preview on `[[wiki links]]`, multi-tag AND filtering with clickable chips, per-doc copy link, rename-safety warning for title-form links, link-health report (broken links + orphans), recently-edited strip.
137
+ - **Sharing clarity** — share button reads "Share nest"; dialog clarifies nest-wide scope; deep links open documents directly.
138
+ - **Security** — hardened scanner gate (PolinRider + Shai-Hulud preflight) in `bin/`.
139
+
140
+ Full history in [CHANGELOG.md](./CHANGELOG.md).
141
+
109
142
  ## What's new in 1.1.0
110
143
 
111
144
  - **Vault import** — import an existing folder of markdown files into a new nest in one step, from the dashboard ("Import folder") or via the API. Frontmatter, wiki links, and folder structure are preserved.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getDb
3
- } from "./chunk-TDAX3JOT.js";
3
+ } from "./chunk-UEHFNBNR.js";
4
4
 
5
5
  // src/governance/version-service.ts
6
6
  import { createHash } from "crypto";
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  createVersion,
3
3
  setApprovedVersion
4
- } from "./chunk-7UTMBL6Z.js";
4
+ } from "./chunk-6AXBB65N.js";
5
5
  import {
6
6
  buildTitleMap,
7
7
  canUserApprove,
8
8
  engineCache,
9
9
  resolveStewardsForNode
10
- } from "./chunk-WCOUCBDJ.js";
10
+ } from "./chunk-SO74PQWI.js";
11
11
  import {
12
12
  getDb
13
- } from "./chunk-TDAX3JOT.js";
13
+ } from "./chunk-UEHFNBNR.js";
14
14
 
15
15
  // src/governance/review-service.ts
16
16
  import { v4 as uuid } from "uuid";
@@ -2,7 +2,7 @@ import {
2
2
  ANON_USER_ID,
3
3
  config,
4
4
  getDb
5
- } from "./chunk-TDAX3JOT.js";
5
+ } from "./chunk-UEHFNBNR.js";
6
6
 
7
7
  // src/governance/stewardship-service.ts
8
8
  import { v4 as uuid2 } from "uuid";
@@ -26,9 +26,6 @@ function loadAccessConfig() {
26
26
  accessConfig = null;
27
27
  return null;
28
28
  }
29
- function getAccessConfig() {
30
- return accessConfig;
31
- }
32
29
  function isSuperAdmin(email) {
33
30
  if (!accessConfig?.super_admins) return false;
34
31
  return accessConfig.super_admins.map((e) => e.toLowerCase()).includes(email.toLowerCase());
@@ -136,6 +133,12 @@ var ConflictError = class extends AppError {
136
133
  this.name = "ConflictError";
137
134
  }
138
135
  };
136
+ var LockedError = class extends AppError {
137
+ constructor(message = "Locked") {
138
+ super(423, message);
139
+ this.name = "LockedError";
140
+ }
141
+ };
139
142
 
140
143
  // src/nodes/engine.ts
141
144
  import {
@@ -186,6 +189,11 @@ function discardImportDir(dest) {
186
189
  }
187
190
 
188
191
  // src/telemetry/tracker.ts
192
+ import { createHash } from "crypto";
193
+ function computeBatchId(ids) {
194
+ const key = ids.slice().sort((a, b) => a - b).join(",");
195
+ return createHash("sha256").update(key).digest("hex").slice(0, 32);
196
+ }
189
197
  function trackEvent(event, data) {
190
198
  if (!config.TELEMETRY_ENABLED) return;
191
199
  try {
@@ -204,31 +212,61 @@ async function flushTelemetry() {
204
212
  const events = db.prepare(
205
213
  "SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
206
214
  ).all();
207
- if (events.length === 0 && userCount === 0) return;
215
+ if (events.length === 0 && userCount === 0) {
216
+ console.log("[telemetry] skip flush: no events and no users");
217
+ return;
218
+ }
219
+ const emailStmt = db.prepare("SELECT email FROM users WHERE id = ?");
220
+ const emailCache = /* @__PURE__ */ new Map();
221
+ const resolveEmail = (userId) => {
222
+ if (typeof userId !== "string" || !userId) return null;
223
+ if (emailCache.has(userId)) return emailCache.get(userId) ?? null;
224
+ const row = emailStmt.get(userId);
225
+ const email = row?.email ?? null;
226
+ emailCache.set(userId, email);
227
+ return email;
228
+ };
229
+ const batchId = events.length > 0 ? computeBatchId(events.map((e) => e.id)) : null;
208
230
  const payload = {
209
231
  server_key: config.PROMPTOWL_KEY,
232
+ batch_id: batchId,
210
233
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
211
234
  stats: { users: userCount, nests: nestCount },
212
- events: events.map((e) => ({
213
- event: e.event,
214
- data: e.data_json ? JSON.parse(e.data_json) : null,
215
- at: e.created_at
216
- }))
235
+ events: events.map((e) => {
236
+ const data = e.data_json ? JSON.parse(e.data_json) : null;
237
+ if (data && data.userId && !data.userEmail) {
238
+ const email = resolveEmail(data.userId);
239
+ if (email) data.userEmail = email;
240
+ }
241
+ return {
242
+ event: e.event,
243
+ data,
244
+ at: e.created_at
245
+ };
246
+ })
217
247
  };
248
+ const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
249
+ const url = `${promptowlUrl}/api/telemetry/ingest`;
250
+ console.log(
251
+ `[telemetry] POST ${url} events=${events.length} users=${userCount} nests=${nestCount}`
252
+ );
253
+ console.log("[telemetry] payload:", JSON.stringify(payload, null, 2));
218
254
  try {
219
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
220
- const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
255
+ const res = await fetch(url, {
221
256
  method: "POST",
222
257
  headers: { "Content-Type": "application/json" },
223
258
  body: JSON.stringify(payload)
224
259
  });
260
+ console.log(`[telemetry] response: ${res.status} ${res.statusText}`);
225
261
  if (res.ok && events.length > 0) {
226
262
  const ids = events.map((e) => e.id);
227
263
  db.prepare(
228
264
  `UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
229
265
  ).run(...ids);
266
+ console.log(`[telemetry] marked ${ids.length} events as sent`);
230
267
  }
231
- } catch {
268
+ } catch (err) {
269
+ console.error("[telemetry] flush failed:", err);
232
270
  }
233
271
  }
234
272
  var telemetryTimer = null;
@@ -289,14 +327,18 @@ async function installLicenseKey(key) {
289
327
  if (previousKey) {
290
328
  await validateLicense({ forceFresh: true });
291
329
  }
292
- return info;
330
+ return { ...info, persisted: false };
293
331
  }
294
332
  try {
295
333
  upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
334
+ return { ...info, persisted: true };
296
335
  } catch (err) {
297
- console.warn("[license] failed to write .env:", err);
336
+ const persistError = err instanceof Error ? err.message : String(err);
337
+ console.warn(
338
+ `[license] validated but FAILED to persist to ${config.ENV_FILE_PATH}: ${persistError}`
339
+ );
340
+ return { ...info, persisted: false, persistError };
298
341
  }
299
- return info;
300
342
  }
301
343
  var safetyPollHandle = null;
302
344
  var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
@@ -542,6 +584,22 @@ function isImportedNest(nestId) {
542
584
  function toSlug(name) {
543
585
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
544
586
  }
587
+ function uniqueNestName(userId, baseName) {
588
+ const db = getDb();
589
+ const slugTaken = db.prepare(
590
+ "SELECT 1 FROM nests WHERE user_id = ? AND slug = ? LIMIT 1"
591
+ );
592
+ let candidate = baseName;
593
+ let n = 0;
594
+ while (n < 1e3) {
595
+ const slug = toSlug(candidate);
596
+ const hit = slugTaken.get(userId, slug);
597
+ if (!hit) return candidate;
598
+ n++;
599
+ candidate = `${baseName} (${n})`;
600
+ }
601
+ return candidate;
602
+ }
545
603
  function isStewardshipEnabled(nestId) {
546
604
  const db = getDb();
547
605
  const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
@@ -578,6 +636,43 @@ function disableStewardshipAndWipeGovernance(nestId) {
578
636
  });
579
637
  return wipe(nestId);
580
638
  }
639
+ function renameNest(nestId, patch) {
640
+ const db = getDb();
641
+ const existing = db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId);
642
+ if (!existing) {
643
+ throw new ValidationError("Nest not found");
644
+ }
645
+ const updates = [];
646
+ const params = [];
647
+ if (patch.name !== void 0) {
648
+ const trimmed = patch.name.trim();
649
+ if (!trimmed) throw new ValidationError("Name cannot be empty");
650
+ const slug = toSlug(trimmed);
651
+ if (!slug) throw new ValidationError("Name must contain at least one alphanumeric character");
652
+ const conflict = db.prepare(
653
+ "SELECT id FROM nests WHERE user_id = ? AND slug = ? AND id != ? LIMIT 1"
654
+ ).get(existing.user_id, slug, nestId);
655
+ if (conflict) {
656
+ throw new ValidationError(
657
+ `Another nest already uses this name. Pick a different one.`
658
+ );
659
+ }
660
+ updates.push("name = ?", "slug = ?");
661
+ params.push(trimmed, slug);
662
+ }
663
+ if (patch.description !== void 0) {
664
+ updates.push("description = ?");
665
+ params.push(patch.description || null);
666
+ }
667
+ if (updates.length === 0) {
668
+ return existing;
669
+ }
670
+ params.push(nestId);
671
+ db.prepare(`UPDATE nests SET ${updates.join(", ")} WHERE id = ?`).run(
672
+ ...params
673
+ );
674
+ return db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId);
675
+ }
581
676
  async function createNest(userId, name, description) {
582
677
  const id = uuid();
583
678
  const slug = toSlug(name);
@@ -635,6 +730,13 @@ function listNests(userId) {
635
730
  }
636
731
  function listSharedNests(userId) {
637
732
  const db = getDb();
733
+ const caller = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
734
+ const isServerAdmin = isLicenseAdminUserId(userId) || (caller?.email ? isSuperAdmin(caller.email) : false);
735
+ if (isServerAdmin) {
736
+ return db.prepare(
737
+ "SELECT * FROM nests WHERE user_id != ? ORDER BY created_at DESC"
738
+ ).all(userId);
739
+ }
638
740
  return db.prepare(
639
741
  `SELECT DISTINCT n.* FROM nests n
640
742
  LEFT JOIN nest_collaborators nc
@@ -783,6 +885,11 @@ function resolveNestPermission(nestId, userId) {
783
885
  if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
784
886
  return "owner";
785
887
  }
888
+ if (isLicenseAdminUserId(userId)) return "admin";
889
+ const caller = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
890
+ if (caller?.email && isSuperAdmin(caller.email)) {
891
+ return "admin";
892
+ }
786
893
  const directGrant = db.prepare(
787
894
  "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
788
895
  ).get(nestId, userId);
@@ -811,6 +918,9 @@ function isPublicReader(nestId, userId) {
811
918
  "SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
812
919
  ).get(nestId, userId);
813
920
  if (directGrant) return false;
921
+ if (isLicenseAdminUserId(userId)) return false;
922
+ const caller = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
923
+ if (caller?.email && isSuperAdmin(caller.email)) return false;
814
924
  const stewardGrant = db.prepare(
815
925
  `SELECT 1 FROM stewards s
816
926
  JOIN users u ON u.id = ?
@@ -1147,7 +1257,7 @@ function getCollaboratorRole(nestId, userEmail) {
1147
1257
  }
1148
1258
  function resolveUserRoles(nestId, userEmail, opts) {
1149
1259
  const roles = /* @__PURE__ */ new Set();
1150
- if (isSuperAdmin2(userEmail)) roles.add("admin");
1260
+ if (isSuperAdmin(userEmail)) roles.add("admin");
1151
1261
  const owner = getNestOwnerEmail(nestId);
1152
1262
  if (owner && owner.toLowerCase() === userEmail.toLowerCase()) {
1153
1263
  roles.add("owner");
@@ -1163,16 +1273,12 @@ function resolveUserRoles(nestId, userEmail, opts) {
1163
1273
  for (const role of stewardRoles) roles.add(role);
1164
1274
  return [...roles];
1165
1275
  }
1166
- function isSuperAdmin2(userEmail) {
1167
- const cfg = getAccessConfig();
1168
- return !!cfg?.super_admins?.includes(userEmail);
1169
- }
1170
1276
  function canManageStewards(userEmail) {
1171
1277
  if (config.AUTH_MODE === "open") return true;
1172
- return isLicenseAdminEmail(userEmail) || isSuperAdmin2(userEmail);
1278
+ return isLicenseAdminEmail(userEmail) || isSuperAdmin(userEmail);
1173
1279
  }
1174
1280
  function canCreateInNest(nestId, userEmail) {
1175
- if (config.AUTH_MODE === "open" || isSuperAdmin2(userEmail)) return true;
1281
+ if (config.AUTH_MODE === "open" || isSuperAdmin(userEmail)) return true;
1176
1282
  const userId = userIdForEmail(userEmail);
1177
1283
  if (userId) {
1178
1284
  const perm = resolveNestPermission(nestId, userId);
@@ -1196,7 +1302,7 @@ function canUserEdit(nestId, nodeId, userEmail) {
1196
1302
  if (roles.includes("owner")) {
1197
1303
  return { allowed: true, reason: "nest owner", role: "owner" };
1198
1304
  }
1199
- if (isSuperAdmin2(userEmail)) {
1305
+ if (isSuperAdmin(userEmail)) {
1200
1306
  return { allowed: true, reason: "super admin", role: "super_admin" };
1201
1307
  }
1202
1308
  if (canEditWith(roles)) {
@@ -1229,7 +1335,7 @@ function getPendingReviewRequester(nestId, nodeId) {
1229
1335
  function canUserApprove(nestId, nodeId, userEmail) {
1230
1336
  const roles = resolveUserRoles(nestId, userEmail, { nodeId });
1231
1337
  const isOwner = roles.includes("owner");
1232
- const isSuper = isSuperAdmin2(userEmail);
1338
+ const isSuper = isSuperAdmin(userEmail);
1233
1339
  const allowSelf = nestAllowsSelfApprove(nestId);
1234
1340
  const hasStewardApprove = roles.includes("admin") || roles.includes("reviewer");
1235
1341
  const actor = getPendingReviewRequester(nestId, nodeId) ?? getCurrentVersionAuthor(nestId, nodeId);
@@ -1274,7 +1380,7 @@ function canUserAccess(nestId, nodeId, userEmail) {
1274
1380
  if (roles.includes("owner")) {
1275
1381
  return { allowed: true, reason: "nest owner", role: "owner" };
1276
1382
  }
1277
- if (isSuperAdmin2(userEmail)) {
1383
+ if (isSuperAdmin(userEmail)) {
1278
1384
  return { allowed: true, reason: "super admin", role: "super_admin" };
1279
1385
  }
1280
1386
  if (canViewWith(roles)) {
@@ -1352,6 +1458,7 @@ export {
1352
1458
  ForbiddenError,
1353
1459
  ValidationError,
1354
1460
  ConflictError,
1461
+ LockedError,
1355
1462
  trackEvent,
1356
1463
  startTelemetryLoop,
1357
1464
  getCurrentLicense,
@@ -1362,14 +1469,18 @@ export {
1362
1469
  isSuspended,
1363
1470
  getSuspensionReason,
1364
1471
  validateLicense,
1472
+ loadAccessConfig,
1473
+ isSuperAdmin,
1365
1474
  resolveNestPermission,
1366
1475
  permissionLevel,
1367
1476
  isPublicReader,
1477
+ uniqueNestName,
1368
1478
  isStewardshipEnabled,
1369
1479
  setStewardshipEnabled,
1370
1480
  nestAllowsSelfApprove,
1371
1481
  setAllowSelfApprove,
1372
1482
  disableStewardshipAndWipeGovernance,
1483
+ renameNest,
1373
1484
  createNest,
1374
1485
  importNest,
1375
1486
  listNests,
@@ -1378,8 +1489,6 @@ export {
1378
1489
  getNest,
1379
1490
  deleteNest,
1380
1491
  engineCache,
1381
- loadAccessConfig,
1382
- isSuperAdmin,
1383
1492
  buildTitleMap,
1384
1493
  canManageWith,
1385
1494
  assignSteward,
@@ -14,9 +14,15 @@ var isTestRun = !!process.env.VITEST;
14
14
  if (envFileLoaded && !isTestRun) {
15
15
  dotenv.config({ path: envFileLoaded, override: true });
16
16
  }
17
+ var canonicalEnvFile = process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
18
+ var canonicalEnvLoaded = null;
19
+ if (!isTestRun && canonicalEnvFile !== envFileLoaded && existsSync(canonicalEnvFile)) {
20
+ dotenv.config({ path: canonicalEnvFile, override: true });
21
+ canonicalEnvLoaded = canonicalEnvFile;
22
+ }
17
23
  if (!isTestRun) {
18
24
  console.log(
19
- `[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}`
25
+ `[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}` + (canonicalEnvLoaded ? ` + ${canonicalEnvLoaded}` : "")
20
26
  );
21
27
  }
22
28
  function dataRoot() {
@@ -39,12 +45,29 @@ var config = {
39
45
  return process.env.PROMPTOWL_KEY || "";
40
46
  },
41
47
  /**
42
- * Path to the .env file the server reads its config from. Used by
43
- * the license install flow to persist PROMPTOWL_KEY alongside any
44
- * existing env vars, instead of a separate sidecar file.
48
+ * Restrict "Sign in with PromptOwl":
49
+ * "open" — anyone may sign in with PromptOwl (default)
50
+ * "admin-only" only the license owner (admin) may; everyone else
51
+ * uses email/password. Admin reaches it via the login
52
+ * page's admin route (?admin=1).
53
+ * "disabled" — nobody may sign in with PromptOwl.
54
+ * Enforced server-side at the device entry points (POST /auth/device,
55
+ * GET /auth/device/poll) and the identity point (POST /auth/promptowl).
56
+ */
57
+ get PROMPTOWL_SIGN_IN_GATE() {
58
+ const v = (process.env.PROMPTOWL_SIGN_IN_GATE || "open").trim().toLowerCase();
59
+ return v === "admin-only" || v === "disabled" ? v : "open";
60
+ },
61
+ /**
62
+ * Path to the .env file the server reads its config from and the license
63
+ * install flow persists PROMPTOWL_KEY into. Defaults UNDER DATA_ROOT (not
64
+ * $cwd) so it lands on the writable, volume-mounted, restart-surviving path
65
+ * in containers — $cwd is /app in the official image: root-owned and
66
+ * discarded on container recreate, which silently lost the key. The boot
67
+ * dotenv loader reads this same path (see top of file).
45
68
  */
46
69
  get ENV_FILE_PATH() {
47
- return process.env.ENV_FILE_PATH || join(process.cwd(), ".env");
70
+ return process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
48
71
  },
49
72
  get TELEMETRY_ENABLED() {
50
73
  return process.env.TELEMETRY_ENABLED !== "false";
@@ -545,6 +568,41 @@ function runMigrations(db2) {
545
568
  recordMigration("008_drop_steward_folder_scope");
546
569
  })();
547
570
  }
571
+ if (!hasMigration("009_lowercase_emails")) {
572
+ db2.transaction(() => {
573
+ const collisions = db2.prepare(
574
+ `SELECT GROUP_CONCAT(email, ', ') AS emails
575
+ FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1`
576
+ ).all();
577
+ for (const c of collisions) {
578
+ console.warn(
579
+ `[migration 009] case-collision user rows NOT auto-merged: ${c.emails} \u2014 reconcile manually (pick the row the person logs into; reset its password; ensure collaborator/steward grants point at that user_id).`
580
+ );
581
+ }
582
+ db2.exec(
583
+ `UPDATE users SET email = LOWER(email)
584
+ WHERE email <> LOWER(email)
585
+ AND LOWER(email) NOT IN (
586
+ SELECT LOWER(email) FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1
587
+ )`
588
+ );
589
+ const remaining = db2.prepare(
590
+ `SELECT COUNT(*) AS c FROM (
591
+ SELECT 1 FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1
592
+ )`
593
+ ).get().c;
594
+ if (remaining === 0) {
595
+ db2.exec(
596
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nocase ON users(email COLLATE NOCASE)"
597
+ );
598
+ } else {
599
+ console.warn(
600
+ `[migration 009] ${remaining} case-collision group(s) remain \u2014 skipping the NOCASE unique index until reconciled.`
601
+ );
602
+ }
603
+ })();
604
+ recordMigration("009_lowercase_emails");
605
+ }
548
606
  }
549
607
 
550
608
  // src/db/client.ts