@promptowl/contextnest-community 1.6.0 → 1.8.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
@@ -66,6 +66,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
66
66
  | `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
67
  | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. |
68
68
  | `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. |
69
+ | `SLACK_WEBHOOK_URL` | _(unset — connector off)_ | Slack incoming-webhook URL for governance-event notifications (review submitted/approved/rejected, collaborator added). `https://` only — the URL embeds a secret. Also editable from Settings in the UI. |
69
70
 
70
71
  ---
71
72
 
@@ -3,7 +3,7 @@ import {
3
3
  getDb,
4
4
  insertOrReplace,
5
5
  nowExpr
6
- } from "./chunk-7V33Z6CS.js";
6
+ } from "./chunk-PLAHCDQG.js";
7
7
  import {
8
8
  ANON_USER_ID
9
9
  } from "./chunk-SLTQACJW.js";
@@ -921,6 +921,15 @@ async function buildTitleMap(nestId) {
921
921
  return /* @__PURE__ */ new Map();
922
922
  }
923
923
  }
924
+ function folderForNode(nodeId) {
925
+ return nodeId.replace(/^nodes\//, "").split("/").slice(0, -1).join("/");
926
+ }
927
+ async function describeNode(nestId, nodeId) {
928
+ const titles = await buildTitleMap(nestId);
929
+ const title = titles.get(nodeId) || nodeId;
930
+ const folder = folderForNode(nodeId);
931
+ return folder ? `${title} (in ${folder})` : title;
932
+ }
924
933
 
925
934
  // src/shared/access.ts
926
935
  var PERMISSION_LEVELS = {
@@ -1007,7 +1016,6 @@ function collabPermToRole(permission) {
1007
1016
  var includesAny = (roles, wanted) => roles.some((r) => wanted.includes(r));
1008
1017
  var canViewWith = (roles) => roles.length > 0;
1009
1018
  var canEditWith = (roles) => includesAny(roles, ["owner", "admin", "editor"]);
1010
- var canManageWith = (roles) => includesAny(roles, ["owner", "admin"]);
1011
1019
  var PRECEDENCE = [
1012
1020
  "owner",
1013
1021
  "admin",
@@ -1352,9 +1360,10 @@ async function resolveUserRoles(nestId, userEmail, opts) {
1352
1360
  for (const role of stewardRoles) roles.add(role);
1353
1361
  return [...roles];
1354
1362
  }
1355
- function canManageStewards(userEmail) {
1363
+ async function canManageStewards(nestId, userId) {
1356
1364
  if (config.AUTH_MODE === "open") return true;
1357
- return isLicenseAdminEmail(userEmail) || isSuperAdmin(userEmail);
1365
+ const perm = await resolveNestPermission(nestId, userId);
1366
+ return perm === "owner" || perm === "admin";
1358
1367
  }
1359
1368
  async function canCreateInNest(nestId, userEmail) {
1360
1369
  if (config.AUTH_MODE === "open" || isSuperAdmin(userEmail)) return true;
@@ -1580,7 +1589,8 @@ export {
1580
1589
  deleteNest,
1581
1590
  engineCache,
1582
1591
  buildTitleMap,
1583
- canManageWith,
1592
+ describeNode,
1593
+ collabPermToRole,
1584
1594
  assignSteward,
1585
1595
  removeSteward,
1586
1596
  updateSteward,
@@ -20,6 +20,7 @@ if (envFileLoaded && !isTestRun) {
20
20
  }
21
21
  var canonicalEnvFile = process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
22
22
  var canonicalEnvLoaded = null;
23
+ var slackUrlWarned = false;
23
24
  if (!isTestRun && canonicalEnvFile !== envFileLoaded && existsSync(canonicalEnvFile)) {
24
25
  dotenv.config({ path: canonicalEnvFile, override: true });
25
26
  canonicalEnvLoaded = canonicalEnvFile;
@@ -189,6 +190,26 @@ var config = {
189
190
  get AUTH_MODE() {
190
191
  return process.env.AUTH_MODE || "key";
191
192
  },
193
+ /**
194
+ * Optional Slack incoming-webhook URL for team notifications (review
195
+ * submitted/approved/rejected, nest shared). Empty/unset = connector off.
196
+ * https only — a webhook carries an implicit secret in its path, so it
197
+ * never travels plaintext.
198
+ */
199
+ get SLACK_WEBHOOK_URL() {
200
+ const raw = process.env.SLACK_WEBHOOK_URL?.trim();
201
+ if (!raw) return null;
202
+ if (!/^https:\/\//i.test(raw)) {
203
+ if (!slackUrlWarned) {
204
+ slackUrlWarned = true;
205
+ console.warn(
206
+ "[config] SLACK_WEBHOOK_URL rejected: must be an https:// URL. Slack notifications disabled."
207
+ );
208
+ }
209
+ return null;
210
+ }
211
+ return raw;
212
+ },
192
213
  /**
193
214
  * CORS origin allowlist. Comma-separated list of origins or "*".
194
215
  * Defaults to "*" in open mode (read-heavy, no credentials to steal)
@@ -764,6 +785,168 @@ function runMigrations(db) {
764
785
  })();
765
786
  recordMigration("011_comments");
766
787
  }
788
+ if (!hasMigration("012_merge_case_colliding_users")) {
789
+ db.transaction(() => {
790
+ const { groups, merged } = mergeCaseCollidingUsers(db);
791
+ if (merged > 0) {
792
+ console.warn(
793
+ `[migration 012] merged ${merged} duplicate user row(s) across ${groups} case-collision group(s).`
794
+ );
795
+ }
796
+ db.exec(
797
+ "UPDATE users SET email = LOWER(email) WHERE email <> LOWER(email)"
798
+ );
799
+ db.exec(
800
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nocase ON users(email COLLATE NOCASE)"
801
+ );
802
+ })();
803
+ recordMigration("012_merge_case_colliding_users");
804
+ }
805
+ if (!hasMigration("013_node_deletion_tombstones")) {
806
+ db.transaction(() => {
807
+ db.exec(`
808
+ CREATE TABLE IF NOT EXISTS node_deletions (
809
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
810
+ node_id TEXT NOT NULL,
811
+ deleted_by TEXT,
812
+ deleted_at TEXT NOT NULL,
813
+ PRIMARY KEY (nest_id, node_id)
814
+ );
815
+ CREATE INDEX IF NOT EXISTS idx_node_deletions_time
816
+ ON node_deletions(nest_id, deleted_at);
817
+ `);
818
+ })();
819
+ recordMigration("013_node_deletion_tombstones");
820
+ }
821
+ if (!hasMigration("014_api_events")) {
822
+ db.transaction(() => {
823
+ db.exec(`
824
+ CREATE TABLE IF NOT EXISTS api_events (
825
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
826
+ ts TEXT NOT NULL,
827
+ kind TEXT NOT NULL CHECK(kind IN ('api', 'mcp')),
828
+ method TEXT, -- HTTP method (api rows)
829
+ path TEXT, -- request path (api rows)
830
+ tool TEXT, -- tool name (mcp rows)
831
+ nest_id TEXT,
832
+ user_id TEXT,
833
+ user_email TEXT,
834
+ status INTEGER, -- HTTP status (api rows)
835
+ duration_ms INTEGER
836
+ );
837
+ CREATE INDEX IF NOT EXISTS idx_api_events_ts ON api_events(ts);
838
+ `);
839
+ })();
840
+ recordMigration("014_api_events");
841
+ }
842
+ }
843
+ function mergeCaseCollidingUsers(db) {
844
+ const groups = db.prepare(
845
+ `SELECT LOWER(email) AS le
846
+ FROM users
847
+ WHERE id != ?
848
+ GROUP BY LOWER(email)
849
+ HAVING COUNT(*) > 1`
850
+ ).all(ANON_USER_ID);
851
+ const emailCols = [
852
+ ["annotation_threads", "created_by"],
853
+ ["annotation_threads", "resolved_by"],
854
+ ["annotation_comments", "author"],
855
+ ["review_requests", "requested_by"],
856
+ ["review_requests", "resolved_by"],
857
+ ["node_versions", "author"],
858
+ ["approved_versions", "approved_by"],
859
+ ["comments", "author"],
860
+ ["comments", "resolved_by"],
861
+ ["license_cache", "owner_email"],
862
+ ["stewards", "assigned_by"]
863
+ ];
864
+ let merged = 0;
865
+ for (const { le } of groups) {
866
+ const rows = db.prepare(
867
+ `SELECT u.id, u.email, u.is_invited, u.is_admin, u.name, u.created_at,
868
+ (SELECT COUNT(*) FROM api_keys k WHERE k.user_id = u.id) AS keys,
869
+ (SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id) AS sessions
870
+ FROM users u
871
+ WHERE LOWER(u.email) = ? AND u.id != ?`
872
+ ).all(le, ANON_USER_ID);
873
+ if (rows.length < 2) continue;
874
+ rows.sort((a, b) => {
875
+ if (a.is_invited !== b.is_invited) return a.is_invited - b.is_invited;
876
+ const aCred = a.keys + a.sessions > 0 ? 1 : 0;
877
+ const bCred = b.keys + b.sessions > 0 ? 1 : 0;
878
+ if (aCred !== bCred) return bCred - aCred;
879
+ return b.created_at.localeCompare(a.created_at);
880
+ });
881
+ const winner = rows[0];
882
+ const losers = rows.slice(1);
883
+ const anyAdmin = rows.some((r) => r.is_admin) ? 1 : 0;
884
+ const winnerName = winner.name ?? rows.find((r) => r.name)?.name ?? null;
885
+ for (const loser of losers) {
886
+ const w = winner.id;
887
+ const l = loser.id;
888
+ const loserLower = loser.email.toLowerCase();
889
+ db.prepare("UPDATE sessions SET user_id = ? WHERE user_id = ?").run(w, l);
890
+ db.prepare(
891
+ "UPDATE nest_collaborators SET granted_by = ? WHERE granted_by = ?"
892
+ ).run(w, l);
893
+ db.prepare("UPDATE stewards SET user_id = ? WHERE user_id = ?").run(w, l);
894
+ db.prepare(
895
+ `UPDATE nests SET user_id = ?
896
+ WHERE user_id = ?
897
+ AND slug NOT IN (SELECT slug FROM nests WHERE user_id = ?)`
898
+ ).run(w, l, w);
899
+ db.prepare(
900
+ `UPDATE nests
901
+ SET slug = slug || '-merged-' || substr(id, 1, 8), user_id = ?
902
+ WHERE user_id = ?`
903
+ ).run(w, l);
904
+ db.prepare(
905
+ `DELETE FROM api_keys
906
+ WHERE user_id = ?
907
+ AND EXISTS (SELECT 1 FROM api_keys WHERE user_id = ?)`
908
+ ).run(l, w);
909
+ db.prepare("UPDATE api_keys SET user_id = ? WHERE user_id = ?").run(w, l);
910
+ db.prepare(
911
+ `DELETE FROM nest_collaborators
912
+ WHERE user_id = ?
913
+ AND nest_id IN (SELECT nest_id FROM nest_collaborators WHERE user_id = ?)`
914
+ ).run(l, w);
915
+ db.prepare(
916
+ "UPDATE nest_collaborators SET user_id = ? WHERE user_id = ?"
917
+ ).run(w, l);
918
+ db.prepare(
919
+ `DELETE FROM nest_collaborators
920
+ WHERE user_id = ?
921
+ AND nest_id IN (SELECT id FROM nests WHERE user_id = ?)`
922
+ ).run(w, w);
923
+ for (const [table, col] of emailCols) {
924
+ db.prepare(
925
+ `UPDATE ${table} SET ${col} = ? WHERE LOWER(${col}) = ?`
926
+ ).run(le, loserLower);
927
+ }
928
+ db.prepare(
929
+ `DELETE FROM stewards
930
+ WHERE is_active = 1
931
+ AND LOWER(user_email) = ?
932
+ AND id NOT IN (
933
+ SELECT MIN(id) FROM stewards
934
+ WHERE is_active = 1 AND LOWER(user_email) = ?
935
+ GROUP BY nest_id, scope,
936
+ IFNULL(node_pattern, ''), IFNULL(tag_name, '')
937
+ )`
938
+ ).run(le, le);
939
+ db.prepare(
940
+ "UPDATE stewards SET user_email = ?, user_id = ? WHERE LOWER(user_email) = ?"
941
+ ).run(le, w, le);
942
+ db.prepare("DELETE FROM users WHERE id = ?").run(l);
943
+ merged++;
944
+ }
945
+ db.prepare(
946
+ "UPDATE users SET email = ?, is_admin = ?, name = ? WHERE id = ?"
947
+ ).run(le, anyAdmin, winnerName, winner.id);
948
+ }
949
+ return { groups: groups.length, merged };
767
950
  }
768
951
 
769
952
  // src/db/adapter.sqlite.ts
@@ -873,7 +1056,7 @@ async function initDb() {
873
1056
  if (config.DB_DRIVER === "postgres") {
874
1057
  const { Pool } = await import("pg");
875
1058
  const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
876
- const { runPostgresMigrations } = await import("./migrations.postgres-NVJAGBSF.js");
1059
+ const { runPostgresMigrations } = await import("./migrations.postgres-VCG45LMK.js");
877
1060
  const pool = new Pool(buildPgConfig());
878
1061
  adapter = new PostgresAdapter(pool);
879
1062
  await runPostgresMigrations(adapter);
@@ -2,7 +2,7 @@ import {
2
2
  getDb,
3
3
  insertOrReplace,
4
4
  nowExpr
5
- } from "./chunk-7V33Z6CS.js";
5
+ } from "./chunk-PLAHCDQG.js";
6
6
 
7
7
  // src/governance/version-service.ts
8
8
  import { createHash } from "crypto";
@@ -1,22 +1,68 @@
1
1
  import {
2
2
  createVersion,
3
3
  setApprovedVersion
4
- } from "./chunk-T5L4LYU4.js";
4
+ } from "./chunk-XNGOO6RI.js";
5
5
  import {
6
6
  buildTitleMap,
7
7
  canUserApprove,
8
+ describeNode,
8
9
  engineCache,
9
10
  resolveStewardsForNode
10
- } from "./chunk-2TUMMVBG.js";
11
+ } from "./chunk-PJTAUVD7.js";
11
12
  import {
13
+ config,
12
14
  getDb,
13
15
  insertOrReplace,
14
16
  nowExpr
15
- } from "./chunk-7V33Z6CS.js";
17
+ } from "./chunk-PLAHCDQG.js";
16
18
 
17
19
  // src/governance/review-service.ts
18
20
  import { v4 as uuid } from "uuid";
19
21
 
22
+ // src/notify/slack.ts
23
+ var warnedOnce = false;
24
+ async function notifySlackForNest(nestId, message) {
25
+ if (!config.SLACK_WEBHOOK_URL) return;
26
+ let nestName = nestId;
27
+ try {
28
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
29
+ nestId
30
+ ]);
31
+ if (row?.name) nestName = row.name;
32
+ } catch {
33
+ }
34
+ return notifySlack(`*[${nestName}]* ${message}`);
35
+ }
36
+ function escapeMrkdwn(s) {
37
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
38
+ }
39
+ async function notifySlack(text) {
40
+ const url = config.SLACK_WEBHOOK_URL;
41
+ if (!url) return;
42
+ try {
43
+ const ctrl = new AbortController();
44
+ const timer = setTimeout(() => ctrl.abort(), 3e3);
45
+ try {
46
+ await fetch(url, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({ text: escapeMrkdwn(text) }),
50
+ signal: ctrl.signal
51
+ });
52
+ } finally {
53
+ clearTimeout(timer);
54
+ }
55
+ } catch (err) {
56
+ if (!warnedOnce) {
57
+ warnedOnce = true;
58
+ console.warn(
59
+ "[slack] notification failed (suppressing further warnings):",
60
+ err instanceof Error ? err.message : err
61
+ );
62
+ }
63
+ }
64
+ }
65
+
20
66
  // src/governance/safe-publish.ts
21
67
  import {
22
68
  publishDocument,
@@ -73,6 +119,10 @@ async function submitForReview(params) {
73
119
  "UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
74
120
  [params.nestId, params.nodeId, params.version]
75
121
  );
122
+ void notifySlackForNest(
123
+ params.nestId,
124
+ `:memo: Review requested on *${await describeNode(params.nestId, params.nodeId)}* (v${params.version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
125
+ );
76
126
  return await getReviewRequest(id);
77
127
  }
78
128
  async function approve(params) {
@@ -148,6 +198,10 @@ async function approve(params) {
148
198
  err
149
199
  );
150
200
  }
201
+ void notifySlackForNest(
202
+ params.nestId,
203
+ `:white_check_mark: *${await describeNode(params.nestId, params.nodeId)}* v${params.version} approved by ${params.approvedBy}`
204
+ );
151
205
  return await getReviewRequest(pending.id);
152
206
  }
153
207
  async function reject(params) {
@@ -177,6 +231,10 @@ async function reject(params) {
177
231
  "UPDATE node_versions SET status = 'rejected' WHERE nest_id = ? AND node_id = ? AND version = ?",
178
232
  [params.nestId, params.nodeId, params.version]
179
233
  );
234
+ void notifySlackForNest(
235
+ params.nestId,
236
+ `:x: *${await describeNode(params.nestId, params.nodeId)}* v${params.version} rejected by ${params.rejectedBy} \u2014 "${params.note}"`
237
+ );
180
238
  return await getReviewRequest(pending.id);
181
239
  }
182
240
  async function cancelReview(params) {
@@ -293,6 +351,7 @@ function rowToReviewRequest(row) {
293
351
 
294
352
  export {
295
353
  safePublishDocument,
354
+ notifySlackForNest,
296
355
  submitForReview,
297
356
  approve,
298
357
  reject,