@promptowl/contextnest-community 1.2.0 → 1.4.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
@@ -50,7 +50,9 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
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
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` | `$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. |
53
+ | `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. |
54
+ | `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. |
55
+ | `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. |
54
56
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
55
57
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
56
58
  | `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). |
@@ -100,7 +102,7 @@ allowed_users:
100
102
  - "*.acme.com" # email wildcard — anyone @acme.com
101
103
  - "partner@vendor.com" # exact match
102
104
  super_admins:
103
- - "ceo@acme.com" # always allowed on every nest
105
+ - "ceo@acme.com" # admin on every nest: visibility, collaborators, stewards (not owner-only delete/transfer)
104
106
  groups:
105
107
  engineering:
106
108
  default_permission: write
package/README.md CHANGED
@@ -110,6 +110,22 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
110
110
 
111
111
  For Enterprise pricing and features, contact **hoot@promptowl.ai** or visit <https://promptowl.ai/contextnest/>.
112
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
+
113
129
  ## What's new in 1.2.0
114
130
 
115
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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getDb
3
- } from "./chunk-G62P54ET.js";
3
+ } from "./chunk-RMU3LOPH.js";
4
4
 
5
5
  // src/governance/version-service.ts
6
6
  import { createHash } from "crypto";
@@ -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() {
@@ -38,6 +44,32 @@ var config = {
38
44
  get PROMPTOWL_KEY() {
39
45
  return process.env.PROMPTOWL_KEY || "";
40
46
  },
47
+ /**
48
+ * Shared secret for one-click SSO auto-login from PromptOwl (PO).
49
+ *
50
+ * THIS IS THE DOMAIN LOCK. Only the ONE official community deployment is
51
+ * given this secret (it matches OFFICIAL_COMMUNITY_SSO_SECRET on PO). PO
52
+ * signs a short-lived JWT ticket with it; this server verifies the ticket
53
+ * with the same secret at GET /auth/sso.
54
+ *
55
+ * Self-hosted deployments leave this UNSET — GET /auth/sso then returns 404,
56
+ * so the auto-login feature is disabled for every domain except ours. Those
57
+ * users keep using the manual device-code flow (/auth/promptowl).
58
+ */
59
+ get OFFICIAL_COMMUNITY_SSO_SECRET() {
60
+ return process.env.OFFICIAL_COMMUNITY_SSO_SECRET || "";
61
+ },
62
+ /**
63
+ * This server's own canonical, externally-reachable base URL (e.g.
64
+ * "https://community.promptowl.ai"). Used to validate the `aud` claim on
65
+ * incoming SSO tickets — a ticket minted for a different audience is
66
+ * rejected, so a ticket can't be replayed against another server. Compared
67
+ * with the trailing slash stripped. When unset, the `aud` check is skipped
68
+ * (the shared-secret check is still the primary gate).
69
+ */
70
+ get PUBLIC_BASE_URL() {
71
+ return (process.env.PUBLIC_BASE_URL || "").replace(/\/$/, "");
72
+ },
41
73
  /**
42
74
  * Restrict "Sign in with PromptOwl":
43
75
  * "open" — anyone may sign in with PromptOwl (default)
@@ -53,12 +85,15 @@ var config = {
53
85
  return v === "admin-only" || v === "disabled" ? v : "open";
54
86
  },
55
87
  /**
56
- * Path to the .env file the server reads its config from. Used by
57
- * the license install flow to persist PROMPTOWL_KEY alongside any
58
- * existing env vars, instead of a separate sidecar file.
88
+ * Path to the .env file the server reads its config from and the license
89
+ * install flow persists PROMPTOWL_KEY into. Defaults UNDER DATA_ROOT (not
90
+ * $cwd) so it lands on the writable, volume-mounted, restart-surviving path
91
+ * in containers — $cwd is /app in the official image: root-owned and
92
+ * discarded on container recreate, which silently lost the key. The boot
93
+ * dotenv loader reads this same path (see top of file).
59
94
  */
60
95
  get ENV_FILE_PATH() {
61
- return process.env.ENV_FILE_PATH || join(process.cwd(), ".env");
96
+ return process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
62
97
  },
63
98
  get TELEMETRY_ENABLED() {
64
99
  return process.env.TELEMETRY_ENABLED !== "false";
@@ -167,6 +202,35 @@ function runMigrations(db2) {
167
202
  CREATE INDEX IF NOT EXISTS idx_nest_collab_nest ON nest_collaborators(nest_id);
168
203
  CREATE INDEX IF NOT EXISTS idx_nest_collab_user ON nest_collaborators(user_id);
169
204
 
205
+ -- Annotation threads on hosted artifacts. Deliberately kept OUT of the
206
+ -- versioned/hash-chained node layer: annotations are high-churn human
207
+ -- feedback, not canonical node content. They reference the node + the
208
+ -- snapshot (node version) they were anchored against.
209
+ CREATE TABLE IF NOT EXISTS annotation_threads (
210
+ id TEXT PRIMARY KEY,
211
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
212
+ node_id TEXT NOT NULL,
213
+ snapshot_version INTEGER,
214
+ anchor_json TEXT, -- JSON {line?,quote,before,after}; NULL = whole-artifact
215
+ status TEXT NOT NULL DEFAULT 'open'
216
+ CHECK(status IN ('open', 'resolved')),
217
+ created_by TEXT NOT NULL, -- email
218
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
219
+ resolved_by TEXT,
220
+ resolved_at TEXT
221
+ );
222
+ CREATE TABLE IF NOT EXISTS annotation_comments (
223
+ id TEXT PRIMARY KEY,
224
+ thread_id TEXT NOT NULL REFERENCES annotation_threads(id) ON DELETE CASCADE,
225
+ author TEXT NOT NULL, -- email
226
+ body TEXT NOT NULL,
227
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
228
+ );
229
+ CREATE INDEX IF NOT EXISTS idx_annot_threads_node
230
+ ON annotation_threads(nest_id, node_id);
231
+ CREATE INDEX IF NOT EXISTS idx_annot_comments_thread
232
+ ON annotation_comments(thread_id);
233
+
170
234
  -- License validation cache
171
235
  CREATE TABLE IF NOT EXISTS license_cache (
172
236
  key TEXT PRIMARY KEY,
@@ -559,6 +623,85 @@ function runMigrations(db2) {
559
623
  recordMigration("008_drop_steward_folder_scope");
560
624
  })();
561
625
  }
626
+ if (!hasMigration("009_lowercase_emails")) {
627
+ db2.transaction(() => {
628
+ const collisions = db2.prepare(
629
+ `SELECT GROUP_CONCAT(email, ', ') AS emails
630
+ FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1`
631
+ ).all();
632
+ for (const c of collisions) {
633
+ console.warn(
634
+ `[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).`
635
+ );
636
+ }
637
+ db2.exec(
638
+ `UPDATE users SET email = LOWER(email)
639
+ WHERE email <> LOWER(email)
640
+ AND LOWER(email) NOT IN (
641
+ SELECT LOWER(email) FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1
642
+ )`
643
+ );
644
+ const remaining = db2.prepare(
645
+ `SELECT COUNT(*) AS c FROM (
646
+ SELECT 1 FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1
647
+ )`
648
+ ).get().c;
649
+ if (remaining === 0) {
650
+ db2.exec(
651
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nocase ON users(email COLLATE NOCASE)"
652
+ );
653
+ } else {
654
+ console.warn(
655
+ `[migration 009] ${remaining} case-collision group(s) remain \u2014 skipping the NOCASE unique index until reconciled.`
656
+ );
657
+ }
658
+ })();
659
+ recordMigration("009_lowercase_emails");
660
+ }
661
+ if (!hasMigration("010_sso_used_jti")) {
662
+ db2.transaction(() => {
663
+ db2.exec(`
664
+ CREATE TABLE IF NOT EXISTS sso_used_jti (
665
+ jti TEXT PRIMARY KEY,
666
+ used_at TEXT NOT NULL DEFAULT (datetime('now')),
667
+ expires_at TEXT NOT NULL
668
+ );
669
+ CREATE INDEX IF NOT EXISTS idx_sso_used_jti_expires
670
+ ON sso_used_jti(expires_at);
671
+ `);
672
+ })();
673
+ recordMigration("010_sso_used_jti");
674
+ }
675
+ if (!hasMigration("011_comments")) {
676
+ db2.transaction(() => {
677
+ db2.exec(`
678
+ CREATE TABLE IF NOT EXISTS comments (
679
+ id TEXT PRIMARY KEY,
680
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
681
+ node_id TEXT NOT NULL,
682
+ version INTEGER, -- optional: comment pinned to a version
683
+ anchor_start INTEGER, -- optional: highlight span start (char offset)
684
+ anchor_end INTEGER, -- optional: highlight span end
685
+ anchor_text TEXT, -- optional: quoted highlighted text
686
+ parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE, -- threading
687
+ author TEXT NOT NULL, -- email
688
+ body TEXT NOT NULL,
689
+ status TEXT NOT NULL DEFAULT 'open'
690
+ CHECK(status IN ('open', 'resolved')),
691
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
692
+ resolved_by TEXT, -- email
693
+ resolved_at TEXT
694
+ );
695
+ CREATE INDEX IF NOT EXISTS idx_comments_node
696
+ ON comments(nest_id, node_id, created_at);
697
+ CREATE INDEX IF NOT EXISTS idx_comments_open
698
+ ON comments(nest_id, node_id, status);
699
+ CREATE INDEX IF NOT EXISTS idx_comments_thread
700
+ ON comments(parent_id);
701
+ `);
702
+ })();
703
+ recordMigration("011_comments");
704
+ }
562
705
  }
563
706
 
564
707
  // src/db/client.ts
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  createVersion,
3
3
  setApprovedVersion
4
- } from "./chunk-LO54V4AU.js";
4
+ } from "./chunk-HIH7I232.js";
5
5
  import {
6
6
  buildTitleMap,
7
7
  canUserApprove,
8
8
  engineCache,
9
9
  resolveStewardsForNode
10
- } from "./chunk-5MT4ZBVF.js";
10
+ } from "./chunk-XNPD2Q6E.js";
11
11
  import {
12
12
  getDb
13
- } from "./chunk-G62P54ET.js";
13
+ } from "./chunk-RMU3LOPH.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-G62P54ET.js";
5
+ } from "./chunk-RMU3LOPH.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());
@@ -192,6 +189,11 @@ function discardImportDir(dest) {
192
189
  }
193
190
 
194
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
+ }
195
197
  function trackEvent(event, data) {
196
198
  if (!config.TELEMETRY_ENABLED) return;
197
199
  try {
@@ -210,31 +212,61 @@ async function flushTelemetry() {
210
212
  const events = db.prepare(
211
213
  "SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
212
214
  ).all();
213
- 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;
214
230
  const payload = {
215
231
  server_key: config.PROMPTOWL_KEY,
232
+ batch_id: batchId,
216
233
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
217
234
  stats: { users: userCount, nests: nestCount },
218
- events: events.map((e) => ({
219
- event: e.event,
220
- data: e.data_json ? JSON.parse(e.data_json) : null,
221
- at: e.created_at
222
- }))
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
+ })
223
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));
224
254
  try {
225
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
226
- const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
255
+ const res = await fetch(url, {
227
256
  method: "POST",
228
257
  headers: { "Content-Type": "application/json" },
229
258
  body: JSON.stringify(payload)
230
259
  });
260
+ console.log(`[telemetry] response: ${res.status} ${res.statusText}`);
231
261
  if (res.ok && events.length > 0) {
232
262
  const ids = events.map((e) => e.id);
233
263
  db.prepare(
234
264
  `UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
235
265
  ).run(...ids);
266
+ console.log(`[telemetry] marked ${ids.length} events as sent`);
236
267
  }
237
- } catch {
268
+ } catch (err) {
269
+ console.error("[telemetry] flush failed:", err);
238
270
  }
239
271
  }
240
272
  var telemetryTimer = null;
@@ -295,14 +327,18 @@ async function installLicenseKey(key) {
295
327
  if (previousKey) {
296
328
  await validateLicense({ forceFresh: true });
297
329
  }
298
- return info;
330
+ return { ...info, persisted: false };
299
331
  }
300
332
  try {
301
333
  upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
334
+ return { ...info, persisted: true };
302
335
  } catch (err) {
303
- 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 };
304
341
  }
305
- return info;
306
342
  }
307
343
  var safetyPollHandle = null;
308
344
  var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
@@ -548,6 +584,22 @@ function isImportedNest(nestId) {
548
584
  function toSlug(name) {
549
585
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
550
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
+ }
551
603
  function isStewardshipEnabled(nestId) {
552
604
  const db = getDb();
553
605
  const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
@@ -584,6 +636,43 @@ function disableStewardshipAndWipeGovernance(nestId) {
584
636
  });
585
637
  return wipe(nestId);
586
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
+ }
587
676
  async function createNest(userId, name, description) {
588
677
  const id = uuid();
589
678
  const slug = toSlug(name);
@@ -641,6 +730,13 @@ function listNests(userId) {
641
730
  }
642
731
  function listSharedNests(userId) {
643
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
+ }
644
740
  return db.prepare(
645
741
  `SELECT DISTINCT n.* FROM nests n
646
742
  LEFT JOIN nest_collaborators nc
@@ -789,6 +885,11 @@ function resolveNestPermission(nestId, userId) {
789
885
  if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
790
886
  return "owner";
791
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
+ }
792
893
  const directGrant = db.prepare(
793
894
  "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
794
895
  ).get(nestId, userId);
@@ -817,6 +918,9 @@ function isPublicReader(nestId, userId) {
817
918
  "SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
818
919
  ).get(nestId, userId);
819
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;
820
924
  const stewardGrant = db.prepare(
821
925
  `SELECT 1 FROM stewards s
822
926
  JOIN users u ON u.id = ?
@@ -1153,7 +1257,7 @@ function getCollaboratorRole(nestId, userEmail) {
1153
1257
  }
1154
1258
  function resolveUserRoles(nestId, userEmail, opts) {
1155
1259
  const roles = /* @__PURE__ */ new Set();
1156
- if (isSuperAdmin2(userEmail)) roles.add("admin");
1260
+ if (isSuperAdmin(userEmail)) roles.add("admin");
1157
1261
  const owner = getNestOwnerEmail(nestId);
1158
1262
  if (owner && owner.toLowerCase() === userEmail.toLowerCase()) {
1159
1263
  roles.add("owner");
@@ -1169,16 +1273,12 @@ function resolveUserRoles(nestId, userEmail, opts) {
1169
1273
  for (const role of stewardRoles) roles.add(role);
1170
1274
  return [...roles];
1171
1275
  }
1172
- function isSuperAdmin2(userEmail) {
1173
- const cfg = getAccessConfig();
1174
- return !!cfg?.super_admins?.includes(userEmail);
1175
- }
1176
1276
  function canManageStewards(userEmail) {
1177
1277
  if (config.AUTH_MODE === "open") return true;
1178
- return isLicenseAdminEmail(userEmail) || isSuperAdmin2(userEmail);
1278
+ return isLicenseAdminEmail(userEmail) || isSuperAdmin(userEmail);
1179
1279
  }
1180
1280
  function canCreateInNest(nestId, userEmail) {
1181
- if (config.AUTH_MODE === "open" || isSuperAdmin2(userEmail)) return true;
1281
+ if (config.AUTH_MODE === "open" || isSuperAdmin(userEmail)) return true;
1182
1282
  const userId = userIdForEmail(userEmail);
1183
1283
  if (userId) {
1184
1284
  const perm = resolveNestPermission(nestId, userId);
@@ -1202,7 +1302,7 @@ function canUserEdit(nestId, nodeId, userEmail) {
1202
1302
  if (roles.includes("owner")) {
1203
1303
  return { allowed: true, reason: "nest owner", role: "owner" };
1204
1304
  }
1205
- if (isSuperAdmin2(userEmail)) {
1305
+ if (isSuperAdmin(userEmail)) {
1206
1306
  return { allowed: true, reason: "super admin", role: "super_admin" };
1207
1307
  }
1208
1308
  if (canEditWith(roles)) {
@@ -1235,7 +1335,7 @@ function getPendingReviewRequester(nestId, nodeId) {
1235
1335
  function canUserApprove(nestId, nodeId, userEmail) {
1236
1336
  const roles = resolveUserRoles(nestId, userEmail, { nodeId });
1237
1337
  const isOwner = roles.includes("owner");
1238
- const isSuper = isSuperAdmin2(userEmail);
1338
+ const isSuper = isSuperAdmin(userEmail);
1239
1339
  const allowSelf = nestAllowsSelfApprove(nestId);
1240
1340
  const hasStewardApprove = roles.includes("admin") || roles.includes("reviewer");
1241
1341
  const actor = getPendingReviewRequester(nestId, nodeId) ?? getCurrentVersionAuthor(nestId, nodeId);
@@ -1280,7 +1380,7 @@ function canUserAccess(nestId, nodeId, userEmail) {
1280
1380
  if (roles.includes("owner")) {
1281
1381
  return { allowed: true, reason: "nest owner", role: "owner" };
1282
1382
  }
1283
- if (isSuperAdmin2(userEmail)) {
1383
+ if (isSuperAdmin(userEmail)) {
1284
1384
  return { allowed: true, reason: "super admin", role: "super_admin" };
1285
1385
  }
1286
1386
  if (canViewWith(roles)) {
@@ -1369,14 +1469,18 @@ export {
1369
1469
  isSuspended,
1370
1470
  getSuspensionReason,
1371
1471
  validateLicense,
1472
+ loadAccessConfig,
1473
+ isSuperAdmin,
1372
1474
  resolveNestPermission,
1373
1475
  permissionLevel,
1374
1476
  isPublicReader,
1477
+ uniqueNestName,
1375
1478
  isStewardshipEnabled,
1376
1479
  setStewardshipEnabled,
1377
1480
  nestAllowsSelfApprove,
1378
1481
  setAllowSelfApprove,
1379
1482
  disableStewardshipAndWipeGovernance,
1483
+ renameNest,
1380
1484
  createNest,
1381
1485
  importNest,
1382
1486
  listNests,
@@ -1385,8 +1489,6 @@ export {
1385
1489
  getNest,
1386
1490
  deleteNest,
1387
1491
  engineCache,
1388
- loadAccessConfig,
1389
- isSuperAdmin,
1390
1492
  buildTitleMap,
1391
1493
  canManageWith,
1392
1494
  assignSteward,