@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 +6 -4
- package/README.md +80 -7
- package/dist/{chunk-BLOPZDPL.js → chunk-7UTMBL6Z.js} +22 -7
- package/dist/{chunk-XDCW4HTW.js → chunk-S2EWN2VA.js} +83 -5
- package/dist/{chunk-2TW25QEA.js → chunk-TDAX3JOT.js} +168 -22
- package/dist/chunk-WCOUCBDJ.js +1406 -0
- package/dist/{chunk-7K2LLJXK.js → chunk-XRK6SQSC.js} +1 -1
- package/dist/index.js +1418 -1038
- package/dist/{keys-YV33AJK3.js → keys-73STFJJB.js} +1 -1
- package/dist/{review-service-2JHZHZWJ.js → review-service-3OJIPYNV.js} +4 -3
- package/dist/{stewardship-service-ZJATH6OM.js → stewardship-service-3XGX7QIN.js} +20 -4
- package/dist/{version-service-2MZJGE3H.js → version-service-UODXLAOJ.js} +8 -4
- package/dist/web3/assets/index-BLxRS7jD.js +673 -0
- package/dist/web3/assets/index-DszK6Vkc.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +136 -125
- package/dist/chunk-2FXVMVZJ.js +0 -540
- package/dist/web3/assets/index-BlGzOlFt.css +0 -1
- package/dist/web3/assets/index-C3W5d7fT.js +0 -591
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
|
|
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
|
|
66
|
+
AUTH_MODE=open DATA_ROOT=./my-data npm run dev
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
Or the default dev mode if `
|
|
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
|
-
|
|
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.
|
|
24
|
-
|
|
25
|
+
# 1. Run the community server
|
|
26
|
+
npx @promptowl/contextnest-community
|
|
25
27
|
|
|
26
|
-
# 2.
|
|
27
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
|
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-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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)
|
|
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-
|
|
10
|
+
} from "./chunk-WCOUCBDJ.js";
|
|
5
11
|
import {
|
|
6
12
|
getDb
|
|
7
|
-
} from "./chunk-
|
|
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 = '
|
|
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
|
-
|
|
13
|
+
var isTestRun = !!process.env.VITEST;
|
|
14
|
+
if (envFileLoaded && !isTestRun) {
|
|
14
15
|
dotenv.config({ path: envFileLoaded, override: true });
|
|
15
16
|
}
|
|
16
|
-
|
|
17
|
-
|
|
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) >
|
|
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', '
|
|
156
|
-
node_pattern TEXT, --
|
|
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', '
|
|
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,
|
|
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
|
-
|
|
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
|
};
|