@promptowl/contextnest-community 1.0.0 → 1.1.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
@@ -48,11 +48,13 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
48
48
  | `DATABASE_PATH` | `$DATA_ROOT/community.db` | Override the SQLite file location explicitly. |
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
- | `PROMPTOWL_KEY` | `""` | Your PromptOwl Community Server license key (`pk_...`). Unlicensed instances still run but some features may be limited. |
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
53
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
53
54
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
54
55
  | `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). |
55
56
  | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. |
57
+ | `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. |
56
58
 
57
59
  ---
58
60
 
@@ -61,10 +63,10 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
61
63
  ### Local dev / single user
62
64
 
63
65
  ```bash
64
- AUTH_MODE=open DATA_ROOT=./my-data pnpm dev
66
+ AUTH_MODE=open DATA_ROOT=./my-data npm run dev
65
67
  ```
66
68
 
67
- Or the default dev mode if `pnpm dev` already sets `AUTH_MODE=open` in your scripts.
69
+ Or the default dev mode if `npm run dev` already sets `AUTH_MODE=open` in your scripts.
68
70
 
69
71
  ### Team / multi-user behind a reverse proxy
70
72
 
@@ -73,7 +75,7 @@ AUTH_MODE=key \
73
75
  CORS_ORIGINS="https://team.example.com,https://admin.example.com" \
74
76
  DATA_ROOT=/var/lib/contextnest \
75
77
  PROMPTOWL_KEY=pk_... \
76
- pnpm start
78
+ npm start
77
79
  ```
78
80
 
79
81
  Terminate TLS at the proxy, forward `X-Forwarded-For` and `X-Real-IP` headers (the rate limiter reads them), and bind the server to `127.0.0.1` so only the proxy can reach it.
package/README.md CHANGED
@@ -11,7 +11,9 @@
11
11
  ContextNest Community Edition is a self-hosted server that lets you:
12
12
 
13
13
  - Store, version, and govern markdown-based context documents ("nests")
14
+ - Import an existing folder or vault of markdown files in one step
14
15
  - Apply stewardship workflows — draft, pending review, approved
16
+ - Share nests with collaborators or publish them read-only to the public
15
17
  - Serve approved context to AI agents via MCP, HTTP, or CLI
16
18
  - Sync with the PromptOwl hosted platform for multi-user collaboration
17
19
 
@@ -20,17 +22,60 @@ The server runs locally or on your own infrastructure. Your PromptOwl account ha
20
22
  ## Quickstart
21
23
 
22
24
  ```bash
23
- # 1. Get a license key — sign up free at https://app.promptowl.ai
24
- # Settings → License Keys → Create a Community Server key
25
+ # 1. Run the community server
26
+ npx @promptowl/contextnest-community
25
27
 
26
- # 2. (Optional) Scaffold a local nest with the open-source CLI
27
- npx @promptowl/contextnest-cli init
28
+ # 2. Open the server in your browser
29
+ # http://localhost:3838
30
+ # On first boot with no license, it lands on the License Setup Page.
28
31
 
29
- # 3. Run the community server
32
+ # 3. Paste your PromptOwl license key (pk_...) — see "License setup" below
33
+ ```
34
+
35
+ The server listens on `http://localhost:3838` by default. Without a valid license the server still runs and serves reads, but write actions return `503` until you activate. See [CONFIGURATION.md](./CONFIGURATION.md) for all environment variables (port, auth mode, storage, telemetry).
36
+
37
+ > **Optional:** scaffold a local nest with the open-source CLI before connecting:
38
+ > ```bash
39
+ > npx @promptowl/contextnest-cli init
40
+ > ```
41
+
42
+ ## License setup
43
+
44
+ ContextNest Community Edition requires a PromptOwl Community License key (`pk_...`). Getting and activating one:
45
+
46
+ ### 1. Create the key (free)
47
+
48
+ 1. Sign up or log in at <https://app.promptowl.ai>
49
+ 2. Open the **Overview** menu → **Community License**
50
+ 3. Click **Create a Community License key**
51
+ 4. Copy the generated key — it starts with `pk_`
52
+
53
+ ### 2. Activate the server
54
+
55
+ Pick **one** of two ways:
56
+
57
+ **A. Browser setup page (recommended for first run)**
58
+
59
+ 1. Start the server: `npx @promptowl/contextnest-community`
60
+ 2. Open <http://localhost:3838> — with no license installed, the server boots into **setup mode** and shows the **License Setup Page**
61
+ 3. Paste your `pk_...` key and submit
62
+ 4. The server validates it against PromptOwl, writes it to your `.env`, and exits setup mode — no restart needed
63
+
64
+ **B. Environment variable (recommended for Docker / CI / scripted deploys)**
65
+
66
+ ```bash
30
67
  PROMPTOWL_KEY=pk_... npx @promptowl/contextnest-community
31
68
  ```
32
69
 
33
- The server listens on `http://localhost:3000` by default. Without a valid `PROMPTOWL_KEY` the server still runs but some features are limited. See [CONFIGURATION.md](./CONFIGURATION.md) for all environment variables (port, auth mode, storage, telemetry).
70
+ The key is read at boot. The server validates against PromptOwl on startup; if valid, it goes straight into licensed mode.
71
+
72
+ ### 3. How licensing behaves at runtime
73
+
74
+ - **Unlicensed / setup mode** — reads work; every non-GET (write) request returns `503` until a valid key is installed.
75
+ - **Live revocation** — a long-poll watcher tracks license state against PromptOwl. If your key is revoked, the server blocks writes within seconds (no restart required) and returns to setup mode.
76
+ - **Admin identity follows the license** — the admin user is whichever PromptOwl account owns the installed key, resolved live per request. Transferring the license to another account immediately promotes the new owner and demotes the old one.
77
+
78
+ For redistribution, hosted-service, OEM, or regulated-industry licensing, contact **hoot@promptowl.ai**.
34
79
 
35
80
  ## System requirements
36
81
 
@@ -45,9 +90,15 @@ The server listens on `http://localhost:3000` by default. Without a valid `PROMP
45
90
  |---|:---:|:---:|
46
91
  | Self-hosted context server | ✅ | ✅ |
47
92
  | Markdown + YAML frontmatter vaults | ✅ | ✅ |
93
+ | Import existing folder / vault | ✅ | ✅ |
94
+ | Markdown rendering + wiki cross-linking | ✅ | ✅ |
95
+ | External-edit detection + version diff | ✅ | ✅ |
48
96
  | Stewardship workflow (draft/review/approve) | ✅ | ✅ |
97
+ | Per-nest sharing + collaborators | ✅ | ✅ |
98
+ | Public read-only nests | ✅ | ✅ |
99
+ | Custom logo / branding | ✅ | ✅ |
49
100
  | MCP server for AI agents | ✅ | ✅ |
50
- | Multi-user governance UI | — | ✅ |
101
+ | Centralized multi-tenant admin console | — | ✅ |
51
102
  | SSO / SAML / SCIM | — | ✅ |
52
103
  | Audit log streaming | — | ✅ |
53
104
  | Policy transforms (redaction, summarization) | — | ✅ |
@@ -55,6 +106,28 @@ The server listens on `http://localhost:3000` by default. Without a valid `PROMP
55
106
 
56
107
  For Enterprise pricing and features, contact **hoot@promptowl.ai** or visit <https://promptowl.ai/contextnest/>.
57
108
 
109
+ ## What's new in 1.1.0
110
+
111
+ - **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.
112
+ - **Nest sharing + collaborators** — set per-nest visibility and add collaborators with read or write access. A Share affordance is now inline in the document view.
113
+ - **Public read-only nests** — flip a nest to `visibility=public` to serve it to unauthenticated readers (bypasses auth for GETs only); writes still require a key.
114
+ - **Custom logo / branding** — set `LOGO_URL` to show your own logo in the UI header and login screen. See [CONFIGURATION.md](./CONFIGURATION.md).
115
+ - **Governance** — nest owners can self-approve their own changes; new per-nest auto-approve toggle; role-based action gating (buttons reflect the viewer's role); stewards can edit scope during a role change.
116
+ - **Editor** — `[[wikilink]]` autocomplete, broken-link click creates a new doc with the title pre-filled, tag chips filter the nest list, Notion-style H1 title, click-to-edit, and an unsaved-changes warning.
117
+ - **Stability** — added an error boundary so a render error in one view no longer blanks the whole app; bumped `uuid` to 14.
118
+
119
+ Full history in [CHANGELOG.md](./CHANGELOG.md).
120
+
121
+ ## What's new in 1.0.1
122
+
123
+ - **Document hashing pipeline** — external-edit detection, conflict-aware safe-publish, and inline version diffs powered by `@promptowl/contextnest-engine`. When a file is edited outside the UI, the editor shows an "External edit detected" banner with a side-by-side diff and an adopt / keep choice.
124
+ - **Markdown rendering** — new `DocumentViewer` (react-markdown + remark-gfm + wikilink support) renders CLI/MCP-authored markdown correctly, with a view/edit toggle.
125
+ - **License revocation now blocks writes synchronously** — revoke flips an in-process "writes blocked" flag immediately; the next write returns `503`.
126
+ - **Dashboard stats** — new `GET /stats` endpoint surfaces nest / document / user counts.
127
+ - **Docker fix** — Dockerfile now installs via npm against the shipped `package-lock.json`; base image bumped to `node:22-slim`.
128
+
129
+ Full history in [CHANGELOG.md](./CHANGELOG.md).
130
+
58
131
  ## Licensing
59
132
 
60
133
  ContextNest Community Edition is **commercial software**. It is **not open source**.
@@ -1,12 +1,16 @@
1
1
  import {
2
2
  getDb
3
- } from "./chunk-2TW25QEA.js";
3
+ } from "./chunk-TDAX3JOT.js";
4
4
 
5
5
  // src/governance/version-service.ts
6
6
  import { createHash } from "crypto";
7
7
  function hashContent(content) {
8
8
  return createHash("sha256").update(content).digest("hex");
9
9
  }
10
+ var SYSTEM_AUTHOR_PREFIX = "system:auto-publish:";
11
+ function systemAuthor(email) {
12
+ return `${SYSTEM_AUTHOR_PREFIX}${email}`;
13
+ }
10
14
  function createVersion(params) {
11
15
  const db = getDb();
12
16
  const contentHash = hashContent(params.content);
@@ -49,8 +53,10 @@ function getVersion(nestId, nodeId, version) {
49
53
  function getCurrentVersion(nestId, nodeId) {
50
54
  const db = getDb();
51
55
  const row = db.prepare(
52
- "SELECT MAX(version) as v FROM node_versions WHERE nest_id = ? AND node_id = ?"
53
- ).get(nestId, nodeId);
56
+ `SELECT MAX(version) as v FROM node_versions
57
+ WHERE nest_id = ? AND node_id = ?
58
+ AND author NOT LIKE ?`
59
+ ).get(nestId, nodeId, `${SYSTEM_AUTHOR_PREFIX}%`);
54
60
  return row?.v || 0;
55
61
  }
56
62
  function getApprovedVersion(nestId, nodeId) {
@@ -70,8 +76,13 @@ function setApprovedVersion(nestId, nodeId, version, approvedBy) {
70
76
  function checkConflict(nestId, nodeId, baseVersion) {
71
77
  const db = getDb();
72
78
  const current = db.prepare(
73
- "SELECT version, content_hash, author, created_at FROM node_versions WHERE nest_id = ? AND node_id = ? ORDER BY version DESC LIMIT 1"
74
- ).get(nestId, nodeId);
79
+ `SELECT version, content_hash, author, created_at
80
+ FROM node_versions
81
+ WHERE nest_id = ? AND node_id = ?
82
+ AND author NOT LIKE ?
83
+ ORDER BY version DESC
84
+ LIMIT 1`
85
+ ).get(nestId, nodeId, `${SYSTEM_AUTHOR_PREFIX}%`);
75
86
  if (!current) {
76
87
  return { conflict: false, currentVersion: 0, currentHash: "" };
77
88
  }
@@ -114,11 +125,13 @@ function getDisplayStatus(nestId, nodeId) {
114
125
  ORDER BY version DESC LIMIT 1`
115
126
  ).get(nestId, nodeId);
116
127
  if (!current) return "draft";
117
- if (current.status === "approved") {
128
+ if (current.status === "published" || current.status === "approved") {
118
129
  const approved = db.prepare(
119
130
  "SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?"
120
131
  ).get(nestId, nodeId);
121
- if (approved?.approved_version === current.version) return "approved";
132
+ if (approved?.approved_version === current.version) {
133
+ return current.status === "published" ? "published" : "approved";
134
+ }
122
135
  return "draft";
123
136
  }
124
137
  if (current.status === "rejected") return "rejected";
@@ -138,6 +151,8 @@ function rowToVersion(row) {
138
151
 
139
152
  export {
140
153
  hashContent,
154
+ SYSTEM_AUTHOR_PREFIX,
155
+ systemAuthor,
141
156
  createVersion,
142
157
  getVersions,
143
158
  getVersion,
@@ -1,13 +1,48 @@
1
1
  import {
2
+ createVersion,
3
+ setApprovedVersion
4
+ } from "./chunk-7UTMBL6Z.js";
5
+ import {
6
+ buildTitleMap,
2
7
  canUserApprove,
8
+ engineCache,
3
9
  resolveStewardsForNode
4
- } from "./chunk-2FXVMVZJ.js";
10
+ } from "./chunk-WCOUCBDJ.js";
5
11
  import {
6
12
  getDb
7
- } from "./chunk-2TW25QEA.js";
13
+ } from "./chunk-TDAX3JOT.js";
8
14
 
9
15
  // src/governance/review-service.ts
10
16
  import { v4 as uuid } from "uuid";
17
+
18
+ // src/governance/safe-publish.ts
19
+ import {
20
+ publishDocument,
21
+ serializeDocument
22
+ } from "@promptowl/contextnest-engine";
23
+ async function safePublishDocument(storage, docId, options) {
24
+ const node = await storage.readDocument(docId);
25
+ const cleanedFrontmatter = stripUndefinedDeep(node.frontmatter);
26
+ const cleanedNode = { ...node, frontmatter: cleanedFrontmatter };
27
+ await storage.writeDocument(docId, serializeDocument(cleanedNode));
28
+ return publishDocument(storage, docId, options);
29
+ }
30
+ function stripUndefinedDeep(value) {
31
+ if (Array.isArray(value)) {
32
+ return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
33
+ }
34
+ if (value && typeof value === "object") {
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(value)) {
37
+ if (v === void 0) continue;
38
+ out[k] = stripUndefinedDeep(v);
39
+ }
40
+ return out;
41
+ }
42
+ return value;
43
+ }
44
+
45
+ // src/governance/review-service.ts
11
46
  function submitForReview(params) {
12
47
  const db = getDb();
13
48
  const existing = db.prepare(
@@ -35,7 +70,7 @@ function submitForReview(params) {
35
70
  ).run(params.nestId, params.nodeId, params.version);
36
71
  return getReviewRequest(id);
37
72
  }
38
- function approve(params) {
73
+ async function approve(params) {
39
74
  const db = getDb();
40
75
  const pending = db.prepare(
41
76
  "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1"
@@ -65,12 +100,45 @@ function approve(params) {
65
100
  pending.id
66
101
  );
67
102
  db.prepare(
68
- "UPDATE node_versions SET status = 'approved' WHERE nest_id = ? AND node_id = ? AND version = ?"
103
+ "UPDATE node_versions SET status = 'published' WHERE nest_id = ? AND node_id = ? AND version = ?"
69
104
  ).run(params.nestId, params.nodeId, params.version);
70
105
  db.prepare(
71
106
  `INSERT OR REPLACE INTO approved_versions (nest_id, node_id, approved_version, approved_by)
72
107
  VALUES (?, ?, ?, ?)`
73
108
  ).run(params.nestId, params.nodeId, params.version, params.approvedBy);
109
+ try {
110
+ const { storage } = engineCache.get(params.nestId);
111
+ const result = await safePublishDocument(storage, params.nodeId, {
112
+ editedBy: params.approvedBy,
113
+ note: params.note || `Approved review request ${pending.id}`
114
+ });
115
+ const engineVersion = result.versionEntry.version;
116
+ if (engineVersion !== params.version) {
117
+ const node = result.node;
118
+ const tags = node.frontmatter.tags || [];
119
+ createVersion({
120
+ nestId: params.nestId,
121
+ nodeId: params.nodeId,
122
+ version: engineVersion,
123
+ content: node.body || "",
124
+ author: params.approvedBy,
125
+ status: "published",
126
+ tags,
127
+ changeNote: params.note || `Approved review request ${pending.id}`
128
+ });
129
+ setApprovedVersion(
130
+ params.nestId,
131
+ params.nodeId,
132
+ engineVersion,
133
+ params.approvedBy
134
+ );
135
+ }
136
+ } catch (err) {
137
+ console.error(
138
+ `publishDocument failed for ${params.nestId}/${params.nodeId} on approve:`,
139
+ err
140
+ );
141
+ }
74
142
  return getReviewRequest(pending.id);
75
143
  }
76
144
  function reject(params) {
@@ -115,7 +183,7 @@ function cancelReview(params) {
115
183
  ).run(params.nestId, params.nodeId, pending.version);
116
184
  return getReviewRequest(pending.id);
117
185
  }
118
- function getReviewQueue(params) {
186
+ async function getReviewQueue(params) {
119
187
  const db = getDb();
120
188
  let whereClauses = [];
121
189
  const args = [];
@@ -150,6 +218,15 @@ function getReviewQueue(params) {
150
218
  );
151
219
  });
152
220
  }
221
+ const titleMapsByNest = /* @__PURE__ */ new Map();
222
+ const nestIds = new Set(requests.map((r) => r.nestId));
223
+ for (const nid of nestIds) {
224
+ titleMapsByNest.set(nid, await buildTitleMap(nid));
225
+ }
226
+ requests = requests.map((r) => ({
227
+ ...r,
228
+ title: titleMapsByNest.get(r.nestId)?.get(r.nodeId)
229
+ }));
153
230
  return { requests, total };
154
231
  }
155
232
  function getReviewHistory(nestId, nodeId) {
@@ -189,6 +266,7 @@ function rowToReviewRequest(row) {
189
266
  }
190
267
 
191
268
  export {
269
+ safePublishDocument,
192
270
  submitForReview,
193
271
  approve,
194
272
  reject,
@@ -10,12 +10,15 @@ var envCandidates = [
10
10
  join(__dirname, "..", ".env")
11
11
  ];
12
12
  var envFileLoaded = envCandidates.find((p) => existsSync(p)) || null;
13
- if (envFileLoaded) {
13
+ var isTestRun = !!process.env.VITEST;
14
+ if (envFileLoaded && !isTestRun) {
14
15
  dotenv.config({ path: envFileLoaded, override: true });
15
16
  }
16
- console.log(
17
- `[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}`
18
- );
17
+ if (!isTestRun) {
18
+ console.log(
19
+ `[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}`
20
+ );
21
+ }
19
22
  function dataRoot() {
20
23
  return process.env.DATA_ROOT || join(process.cwd(), "data");
21
24
  }
@@ -49,6 +52,25 @@ var config = {
49
52
  get TELEMETRY_INTERVAL_MS() {
50
53
  return parseInt(process.env.TELEMETRY_INTERVAL_MS || "3600000", 10);
51
54
  },
55
+ /**
56
+ * Optional custom logo URL shown in UI header + login screen.
57
+ * Must be an absolute https://, http://, or data:image/… URL. Other
58
+ * schemes (file://, javascript:, relative paths) are rejected with a
59
+ * warning so an operator typo doesn't silently break the favicon.
60
+ * When unset or invalid, the UI falls back to the bundled icon.
61
+ */
62
+ get LOGO_URL() {
63
+ const raw = process.env.LOGO_URL?.trim();
64
+ if (!raw) return null;
65
+ const ok = /^(https?:\/\/|data:image\/)/i.test(raw);
66
+ if (!ok) {
67
+ console.warn(
68
+ `[config] LOGO_URL rejected: must start with https://, http://, or data:image/ (got "${raw}"). Falling back to default logo.`
69
+ );
70
+ return null;
71
+ }
72
+ return raw;
73
+ },
52
74
  get AUTH_MODE() {
53
75
  return process.env.AUTH_MODE || "key";
54
76
  },
@@ -78,6 +100,10 @@ import Database from "better-sqlite3";
78
100
  import { mkdirSync } from "fs";
79
101
  import { dirname as dirname2 } from "path";
80
102
 
103
+ // src/shared/constants.ts
104
+ var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
105
+ var ANON_EMAIL = "admin@localhost";
106
+
81
107
  // src/db/migrations.ts
82
108
  function runMigrations(db2) {
83
109
  db2.exec(`
@@ -148,19 +174,17 @@ function runMigrations(db2) {
148
174
  db2.exec(`
149
175
  -- Steward assignments (mirrors PromptOwl ContextSteward model)
150
176
  -- scope+target combination determines what the steward governs
151
- -- Resolution priority: document(1) > folder(2) > tag(3) > nest(4)
177
+ -- Resolution priority: document(1) > tag(2) > nest(3)
152
178
  CREATE TABLE IF NOT EXISTS stewards (
153
179
  id TEXT PRIMARY KEY,
154
180
  nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
155
- scope TEXT NOT NULL CHECK(scope IN ('document', 'folder', 'tag', 'nest')),
156
- node_pattern TEXT, -- glob for document/folder scope (e.g. "nodes/api-*")
181
+ scope TEXT NOT NULL CHECK(scope IN ('document', 'tag', 'nest')),
182
+ node_pattern TEXT, -- exact node id for document scope
157
183
  tag_name TEXT, -- for tag scope
158
184
  user_email TEXT NOT NULL,
159
185
  user_id TEXT REFERENCES users(id),
160
186
  role TEXT NOT NULL DEFAULT 'reviewer'
161
187
  CHECK(role IN ('editor', 'reviewer', 'admin')),
162
- can_approve INTEGER NOT NULL DEFAULT 1,
163
- can_reject INTEGER NOT NULL DEFAULT 1,
164
188
  assigned_by TEXT NOT NULL,
165
189
  assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
166
190
  is_active INTEGER NOT NULL DEFAULT 1
@@ -224,6 +248,12 @@ function runMigrations(db2) {
224
248
  if (!nestCols.includes("stewardship_enabled")) {
225
249
  db2.exec("ALTER TABLE nests ADD COLUMN stewardship_enabled INTEGER NOT NULL DEFAULT 0");
226
250
  }
251
+ if (!nestCols.includes("is_imported")) {
252
+ db2.exec("ALTER TABLE nests ADD COLUMN is_imported INTEGER NOT NULL DEFAULT 0");
253
+ }
254
+ if (!nestCols.includes("allow_self_approve")) {
255
+ db2.exec("ALTER TABLE nests ADD COLUMN allow_self_approve INTEGER NOT NULL DEFAULT 0");
256
+ }
227
257
  const userCols = db2.prepare("PRAGMA table_info(users)").all().map((c) => c.name);
228
258
  if (!userCols.includes("is_admin")) {
229
259
  db2.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0");
@@ -231,6 +261,19 @@ function runMigrations(db2) {
231
261
  if (!userCols.includes("is_invited")) {
232
262
  db2.exec("ALTER TABLE users ADD COLUMN is_invited INTEGER NOT NULL DEFAULT 0");
233
263
  }
264
+ const stewardCols = db2.prepare("PRAGMA table_info(stewards)").all().map((c) => c.name);
265
+ if (stewardCols.length > 0) {
266
+ if (!stewardCols.includes("can_approve")) {
267
+ db2.exec(
268
+ "ALTER TABLE stewards ADD COLUMN can_approve INTEGER NOT NULL DEFAULT 1"
269
+ );
270
+ }
271
+ if (!stewardCols.includes("can_reject")) {
272
+ db2.exec(
273
+ "ALTER TABLE stewards ADD COLUMN can_reject INTEGER NOT NULL DEFAULT 1"
274
+ );
275
+ }
276
+ }
234
277
  db2.exec(`
235
278
  CREATE TABLE IF NOT EXISTS schema_migrations (
236
279
  id TEXT PRIMARY KEY,
@@ -245,24 +288,23 @@ function runMigrations(db2) {
245
288
  CREATE TABLE stewards_new (
246
289
  id TEXT PRIMARY KEY,
247
290
  nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
248
- scope TEXT NOT NULL CHECK(scope IN ('document', 'folder', 'tag', 'nest')),
291
+ scope TEXT NOT NULL CHECK(scope IN ('document', 'tag', 'nest')),
249
292
  node_pattern TEXT,
250
293
  tag_name TEXT,
251
294
  user_email TEXT NOT NULL,
252
295
  user_id TEXT REFERENCES users(id),
253
296
  role TEXT NOT NULL DEFAULT 'reviewer'
254
297
  CHECK(role IN ('editor', 'reviewer', 'viewer')),
255
- can_approve INTEGER NOT NULL DEFAULT 1,
256
- can_reject INTEGER NOT NULL DEFAULT 1,
257
298
  assigned_by TEXT NOT NULL,
258
299
  assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
259
300
  is_active INTEGER NOT NULL DEFAULT 1
260
301
  );
261
302
 
262
- -- Copy rows; map legacy 'admin' role to 'reviewer', normalize tag_name + email
303
+ -- Copy rows; map legacy 'admin' role to 'reviewer', normalize tag_name + email.
304
+ -- Legacy can_approve/can_reject columns are dropped here (role is now the sole signal).
263
305
  INSERT INTO stewards_new
264
306
  (id, nest_id, scope, node_pattern, tag_name, user_email, user_id,
265
- role, can_approve, can_reject, assigned_by, assigned_at, is_active)
307
+ role, assigned_by, assigned_at, is_active)
266
308
  SELECT
267
309
  id, nest_id, scope, node_pattern,
268
310
  CASE
@@ -272,7 +314,7 @@ function runMigrations(db2) {
272
314
  lower(user_email),
273
315
  user_id,
274
316
  CASE role WHEN 'admin' THEN 'reviewer' ELSE role END,
275
- can_approve, can_reject, assigned_by, assigned_at, is_active
317
+ assigned_by, assigned_at, is_active
276
318
  FROM stewards;
277
319
 
278
320
  DROP TABLE stewards;
@@ -291,10 +333,6 @@ function runMigrations(db2) {
291
333
  ON stewards(nest_id, node_pattern, user_email)
292
334
  WHERE scope = 'document' AND node_pattern IS NOT NULL AND is_active = 1;
293
335
 
294
- CREATE UNIQUE INDEX idx_stewards_uniq_folder
295
- ON stewards(nest_id, node_pattern, user_email)
296
- WHERE scope = 'folder' AND node_pattern IS NOT NULL AND is_active = 1;
297
-
298
336
  CREATE UNIQUE INDEX idx_stewards_uniq_tag
299
337
  ON stewards(nest_id, tag_name, user_email)
300
338
  WHERE scope = 'tag' AND tag_name IS NOT NULL AND is_active = 1;
@@ -303,9 +341,6 @@ function runMigrations(db2) {
303
341
  CREATE INDEX idx_stewards_tag_lookup
304
342
  ON stewards(nest_id, tag_name)
305
343
  WHERE scope = 'tag' AND is_active = 1;
306
- CREATE INDEX idx_stewards_folder_lookup
307
- ON stewards(nest_id, node_pattern)
308
- WHERE scope = 'folder' AND is_active = 1;
309
344
  CREATE INDEX idx_stewards_doc_lookup
310
345
  ON stewards(nest_id, node_pattern)
311
346
  WHERE scope = 'document' AND is_active = 1;
@@ -401,6 +436,115 @@ function runMigrations(db2) {
401
436
  recordMigration("004_license_cache_owner_email");
402
437
  })();
403
438
  }
439
+ if (!hasMigration("005_anon_nest_public_default")) {
440
+ db2.transaction(() => {
441
+ db2.prepare(
442
+ "UPDATE nests SET visibility = 'public' WHERE user_id = ? AND visibility = 'private'"
443
+ ).run(ANON_USER_ID);
444
+ recordMigration("005_anon_nest_public_default");
445
+ })();
446
+ }
447
+ if (!hasMigration("006_node_versions_published_status")) {
448
+ db2.transaction(() => {
449
+ db2.exec(`
450
+ CREATE TABLE node_versions_new (
451
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
452
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
453
+ node_id TEXT NOT NULL,
454
+ version INTEGER NOT NULL,
455
+ content_hash TEXT NOT NULL,
456
+ author TEXT NOT NULL,
457
+ status TEXT NOT NULL DEFAULT 'draft'
458
+ CHECK(status IN ('draft', 'pending_review', 'approved', 'published', 'rejected')),
459
+ change_note TEXT,
460
+ tags_json TEXT,
461
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
462
+ UNIQUE(nest_id, node_id, version)
463
+ );
464
+ INSERT INTO node_versions_new
465
+ (id, nest_id, node_id, version, content_hash, author, status, change_note, tags_json, created_at)
466
+ SELECT
467
+ id, nest_id, node_id, version, content_hash, author, status, change_note, tags_json, created_at
468
+ FROM node_versions;
469
+ DROP TABLE node_versions;
470
+ ALTER TABLE node_versions_new RENAME TO node_versions;
471
+ CREATE INDEX idx_versions_node ON node_versions(nest_id, node_id);
472
+ `);
473
+ recordMigration("006_node_versions_published_status");
474
+ })();
475
+ }
476
+ if (!hasMigration("007_drop_steward_capability_flags")) {
477
+ const stewardCols2 = db2.prepare("PRAGMA table_info(stewards)").all().map((c) => c.name);
478
+ const hasLegacyCols = stewardCols2.includes("can_approve") || stewardCols2.includes("can_reject");
479
+ if (hasLegacyCols) {
480
+ db2.transaction(() => {
481
+ if (stewardCols2.includes("can_approve")) {
482
+ db2.exec("ALTER TABLE stewards DROP COLUMN can_approve");
483
+ }
484
+ if (stewardCols2.includes("can_reject")) {
485
+ db2.exec("ALTER TABLE stewards DROP COLUMN can_reject");
486
+ }
487
+ recordMigration("007_drop_steward_capability_flags");
488
+ })();
489
+ } else {
490
+ recordMigration("007_drop_steward_capability_flags");
491
+ }
492
+ }
493
+ if (!hasMigration("008_drop_steward_folder_scope")) {
494
+ db2.transaction(() => {
495
+ db2.exec("DELETE FROM stewards WHERE scope = 'folder'");
496
+ db2.exec("DROP INDEX IF EXISTS idx_stewards_uniq_folder");
497
+ db2.exec("DROP INDEX IF EXISTS idx_stewards_folder_lookup");
498
+ const tbl = db2.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='stewards'").get();
499
+ if (tbl?.sql && tbl.sql.includes("'folder'")) {
500
+ db2.exec(`
501
+ CREATE TABLE stewards_new (
502
+ id TEXT PRIMARY KEY,
503
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
504
+ scope TEXT NOT NULL CHECK(scope IN ('document', 'tag', 'nest')),
505
+ node_pattern TEXT,
506
+ tag_name TEXT,
507
+ user_email TEXT NOT NULL,
508
+ user_id TEXT REFERENCES users(id),
509
+ role TEXT NOT NULL DEFAULT 'reviewer'
510
+ CHECK(role IN ('editor', 'reviewer', 'viewer')),
511
+ assigned_by TEXT NOT NULL,
512
+ assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
513
+ is_active INTEGER NOT NULL DEFAULT 1
514
+ );
515
+ INSERT INTO stewards_new
516
+ (id, nest_id, scope, node_pattern, tag_name, user_email, user_id,
517
+ role, assigned_by, assigned_at, is_active)
518
+ SELECT
519
+ id, nest_id, scope, node_pattern, tag_name, user_email, user_id,
520
+ role, assigned_by, assigned_at, is_active
521
+ FROM stewards;
522
+ DROP TABLE stewards;
523
+ ALTER TABLE stewards_new RENAME TO stewards;
524
+
525
+ CREATE INDEX idx_stewards_nest ON stewards(nest_id);
526
+ CREATE INDEX idx_stewards_email ON stewards(user_email);
527
+ CREATE INDEX idx_stewards_scope ON stewards(nest_id, scope);
528
+ CREATE UNIQUE INDEX idx_stewards_uniq_nest
529
+ ON stewards(nest_id, user_email)
530
+ WHERE scope = 'nest' AND is_active = 1;
531
+ CREATE UNIQUE INDEX idx_stewards_uniq_document
532
+ ON stewards(nest_id, node_pattern, user_email)
533
+ WHERE scope = 'document' AND node_pattern IS NOT NULL AND is_active = 1;
534
+ CREATE UNIQUE INDEX idx_stewards_uniq_tag
535
+ ON stewards(nest_id, tag_name, user_email)
536
+ WHERE scope = 'tag' AND tag_name IS NOT NULL AND is_active = 1;
537
+ CREATE INDEX idx_stewards_tag_lookup
538
+ ON stewards(nest_id, tag_name)
539
+ WHERE scope = 'tag' AND is_active = 1;
540
+ CREATE INDEX idx_stewards_doc_lookup
541
+ ON stewards(nest_id, node_pattern)
542
+ WHERE scope = 'document' AND is_active = 1;
543
+ `);
544
+ }
545
+ recordMigration("008_drop_steward_folder_scope");
546
+ })();
547
+ }
404
548
  }
405
549
 
406
550
  // src/db/client.ts
@@ -419,5 +563,7 @@ function getDb() {
419
563
 
420
564
  export {
421
565
  config,
566
+ ANON_USER_ID,
567
+ ANON_EMAIL,
422
568
  getDb
423
569
  };