@promptowl/contextnest-community 1.17.0 → 1.18.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/README.md +6 -2
- package/dist/{chunk-UXJSO54R.js → chunk-34UXRZXD.js} +59 -14
- package/dist/chunk-F66VCBLA.js +136 -0
- package/dist/{chunk-SNFGU5Q7.js → chunk-FUUJG6XP.js} +597 -326
- package/dist/{chunk-4YGJSUJC.js → chunk-HGN45I5P.js} +29 -21
- package/dist/{chunk-JMJLNEXE.js → chunk-HYSTFMFG.js} +43 -2
- package/dist/chunk-OOBZG6BW.js +379 -0
- package/dist/{chunk-HWDPCPYT.js → chunk-QUZXRLUA.js} +1 -1
- package/dist/{chunk-YF4OFT2V.js → chunk-TQACHVVA.js} +35 -4
- package/dist/{chunk-SLTQACJW.js → chunk-YB3LKF7U.js} +3 -1
- package/dist/{client-OBS5HOTA.js → client-SW5I6ZBK.js} +2 -2
- package/dist/engine-34E3YNX7.js +14 -0
- package/dist/external-edit-service-6Q6SHEOF.js +33 -0
- package/dist/{grants-service-4NXPBYWD.js → grants-service-ME7HIVHU.js} +3 -3
- package/dist/index.js +496 -470
- package/dist/{keys-73STFJJB.js → keys-CTOGAG3W.js} +5 -1
- package/dist/{migrations.postgres-CYKO5CFV.js → migrations.postgres-5VI4RDTS.js} +41 -4
- package/dist/{review-service-3CCKDO5N.js → review-service-H2M5ACPQ.js} +7 -7
- package/dist/{stewardship-service-OHTTZFND.js → stewardship-service-U4CKL7Y5.js} +8 -4
- package/dist/{version-service-EFZGGL3Y.js → version-service-JWEN5UUW.js} +6 -4
- package/dist/web3/assets/index-DONv5_Ni.css +1 -0
- package/dist/web3/assets/index-jSGrnboh.js +1065 -0
- package/dist/web3/index.html +2 -2
- package/package.json +3 -3
- package/dist/chunk-XRK6SQSC.js +0 -58
- package/dist/web3/assets/index-D1299AIf.css +0 -1
- package/dist/web3/assets/index-W6tEA3RK.js +0 -1046
package/README.md
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
ContextNest Community Edition is a self-hosted server that lets you:
|
|
12
12
|
|
|
13
|
-
- Store, version, and govern markdown-based context documents ("nests")
|
|
13
|
+
- Store, version, and govern markdown-based context documents ("nests"), filed in folders you can reorganize as the vault grows
|
|
14
14
|
- Import an existing folder or vault of markdown files in one step
|
|
15
15
|
- Export a nest as a portable bundle and re-import it on another self-hosted host
|
|
16
16
|
- Apply stewardship workflows — draft, pending review, approved
|
|
17
17
|
- Share nests with collaborators or publish them read-only to the public
|
|
18
|
-
- Serve approved context to AI agents via MCP or HTTP
|
|
18
|
+
- Serve approved context to AI agents via MCP or HTTP — connect one by pasting a generated setup prompt or downloading a ready `.env`
|
|
19
19
|
- Sync with the PromptOwl hosted platform for multi-user collaboration
|
|
20
20
|
|
|
21
21
|
The server runs locally or on your own infrastructure. Your PromptOwl account handles authentication, entitlement, and governance metadata.
|
|
@@ -99,8 +99,12 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
|
|
|
99
99
|
| Admin password reset + user removal (in-platform) | ✅ | ✅ |
|
|
100
100
|
| Wiki backlinks, outline, hover-preview, link health | ✅ | ✅ |
|
|
101
101
|
| Rich editor — tables, callouts, toggles, code highlight, find/replace, image & video upload | ✅ | ✅ |
|
|
102
|
+
| Folder organization — nested folders, move documents, lazy folder tree | ✅ | ✅ |
|
|
103
|
+
| Scales to large vaults — nest listings served from a document index, not a disk crawl | ✅ | ✅ |
|
|
102
104
|
| Steward version revert | ✅ | ✅ |
|
|
103
105
|
| MCP server for AI agents | ✅ | ✅ |
|
|
106
|
+
| One-shot agent setup — generated connect prompt + `.env` download | ✅ | ✅ |
|
|
107
|
+
| Several API keys per account — one credential per client, rotate one at a time | ✅ | ✅ |
|
|
104
108
|
| Centralized multi-tenant admin console | — | ✅ |
|
|
105
109
|
| Single sign-on (OIDC — Entra ID, Google, Okta, Keycloak) | ✅ | ✅ |
|
|
106
110
|
| SAML / SCIM provisioning | — | ✅ |
|
|
@@ -2,10 +2,10 @@ import {
|
|
|
2
2
|
buildTitleMap,
|
|
3
3
|
insertOrReplace,
|
|
4
4
|
nowExpr
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-FUUJG6XP.js";
|
|
6
6
|
import {
|
|
7
7
|
getDb
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-HYSTFMFG.js";
|
|
9
9
|
|
|
10
10
|
// src/governance/version-service.ts
|
|
11
11
|
import { createHash } from "crypto";
|
|
@@ -178,32 +178,76 @@ async function getNodeTags(nestId, nodeId) {
|
|
|
178
178
|
return [];
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
+
function decideDisplayStatus(hasPendingReview, current, approvedVersion) {
|
|
182
|
+
if (hasPendingReview) return "pending_review";
|
|
183
|
+
if (!current) return "draft";
|
|
184
|
+
if (current.status === "published" || current.status === "approved") {
|
|
185
|
+
if (approvedVersion === current.version) {
|
|
186
|
+
return current.status === "published" ? "published" : "approved";
|
|
187
|
+
}
|
|
188
|
+
return "draft";
|
|
189
|
+
}
|
|
190
|
+
if (current.status === "rejected") return "rejected";
|
|
191
|
+
return "draft";
|
|
192
|
+
}
|
|
181
193
|
async function getDisplayStatus(nestId, nodeId) {
|
|
182
194
|
const db = getDb();
|
|
183
195
|
const pending = await db.get(
|
|
184
196
|
"SELECT 1 FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' LIMIT 1",
|
|
185
197
|
[nestId, nodeId]
|
|
186
198
|
);
|
|
187
|
-
if (pending) return "pending_review";
|
|
188
199
|
const current = await db.get(
|
|
189
200
|
`SELECT version, status FROM node_versions
|
|
190
201
|
WHERE nest_id = ? AND node_id = ?
|
|
191
202
|
ORDER BY version DESC LIMIT 1`,
|
|
192
203
|
[nestId, nodeId]
|
|
193
204
|
);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
205
|
+
const approved = current && (current.status === "published" || current.status === "approved") ? await db.get(
|
|
206
|
+
"SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?",
|
|
207
|
+
[nestId, nodeId]
|
|
208
|
+
) : void 0;
|
|
209
|
+
return decideDisplayStatus(!!pending, current, approved?.approved_version);
|
|
210
|
+
}
|
|
211
|
+
async function getDisplayStatuses(nestId) {
|
|
212
|
+
const db = getDb();
|
|
213
|
+
const [pendingRows, currentRows, approvedRows] = await Promise.all([
|
|
214
|
+
db.all(
|
|
215
|
+
"SELECT DISTINCT node_id FROM review_requests WHERE nest_id = ? AND status = 'pending'",
|
|
216
|
+
[nestId]
|
|
217
|
+
),
|
|
218
|
+
// The latest version per node, via a grouped self-join — portable across
|
|
219
|
+
// the SQLite and Postgres adapters (no window functions, no row values).
|
|
220
|
+
db.all(
|
|
221
|
+
`SELECT nv.node_id, nv.version, nv.status
|
|
222
|
+
FROM node_versions nv
|
|
223
|
+
JOIN (SELECT node_id, MAX(version) AS max_version
|
|
224
|
+
FROM node_versions WHERE nest_id = ? GROUP BY node_id) latest
|
|
225
|
+
ON latest.node_id = nv.node_id AND latest.max_version = nv.version
|
|
226
|
+
WHERE nv.nest_id = ?`,
|
|
227
|
+
[nestId, nestId]
|
|
228
|
+
),
|
|
229
|
+
db.all(
|
|
230
|
+
"SELECT node_id, approved_version FROM approved_versions WHERE nest_id = ?",
|
|
231
|
+
[nestId]
|
|
232
|
+
)
|
|
233
|
+
]);
|
|
234
|
+
const pending = new Set(pendingRows.map((r) => r.node_id));
|
|
235
|
+
const approved = new Map(
|
|
236
|
+
approvedRows.map((r) => [r.node_id, r.approved_version])
|
|
237
|
+
);
|
|
238
|
+
const current = new Map(currentRows.map((r) => [r.node_id, r]));
|
|
239
|
+
const statuses = /* @__PURE__ */ new Map();
|
|
240
|
+
for (const nodeId of /* @__PURE__ */ new Set([...pending, ...current.keys()])) {
|
|
241
|
+
statuses.set(
|
|
242
|
+
nodeId,
|
|
243
|
+
decideDisplayStatus(
|
|
244
|
+
pending.has(nodeId),
|
|
245
|
+
current.get(nodeId),
|
|
246
|
+
approved.get(nodeId)
|
|
247
|
+
)
|
|
199
248
|
);
|
|
200
|
-
if (approved?.approved_version === current.version) {
|
|
201
|
-
return current.status === "published" ? "published" : "approved";
|
|
202
|
-
}
|
|
203
|
-
return "draft";
|
|
204
249
|
}
|
|
205
|
-
|
|
206
|
-
return "draft";
|
|
250
|
+
return statuses;
|
|
207
251
|
}
|
|
208
252
|
async function listMyDrafts(params) {
|
|
209
253
|
if (params.nestIds.length === 0) return { drafts: [], total: 0 };
|
|
@@ -286,5 +330,6 @@ export {
|
|
|
286
330
|
checkConflict,
|
|
287
331
|
getNodeTags,
|
|
288
332
|
getDisplayStatus,
|
|
333
|
+
getDisplayStatuses,
|
|
289
334
|
listMyDrafts
|
|
290
335
|
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// src/auth/keys.ts
|
|
2
|
+
import { randomBytes, createHash, timingSafeEqual } from "crypto";
|
|
3
|
+
import bcrypt from "bcryptjs";
|
|
4
|
+
var KEY_PREFIX = "cnst_";
|
|
5
|
+
var BCRYPT_ROUNDS = process.env.VITEST ? 4 : 12;
|
|
6
|
+
function generateApiKey() {
|
|
7
|
+
return KEY_PREFIX + randomBytes(32).toString("hex");
|
|
8
|
+
}
|
|
9
|
+
function hashApiKey(key) {
|
|
10
|
+
return createHash("sha256").update(key).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
function getKeyPrefix(key) {
|
|
13
|
+
return key.slice(0, 12) + "...";
|
|
14
|
+
}
|
|
15
|
+
function parseBearerToken(header) {
|
|
16
|
+
if (!header) return null;
|
|
17
|
+
const BEARER = "Bearer ";
|
|
18
|
+
if (!header.startsWith(BEARER)) return null;
|
|
19
|
+
const token = header.slice(BEARER.length);
|
|
20
|
+
if (!token.startsWith(KEY_PREFIX)) return null;
|
|
21
|
+
if (token.length < KEY_PREFIX.length + 32) return null;
|
|
22
|
+
return token;
|
|
23
|
+
}
|
|
24
|
+
async function hashPassword(password) {
|
|
25
|
+
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
26
|
+
}
|
|
27
|
+
async function verifyPassword(password, stored) {
|
|
28
|
+
if (isBcrypt(stored)) {
|
|
29
|
+
return { ok: await bcrypt.compare(password, stored), needsRehash: false };
|
|
30
|
+
}
|
|
31
|
+
const [salt, hash] = stored.split(":");
|
|
32
|
+
if (!salt || !hash) return { ok: false, needsRehash: false };
|
|
33
|
+
const attempt = createHash("sha256").update(salt + password).digest("hex");
|
|
34
|
+
return {
|
|
35
|
+
ok: timingSafeEqualHex(hash, attempt),
|
|
36
|
+
needsRehash: true
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function isBcrypt(stored) {
|
|
40
|
+
return stored.startsWith("$2a$") || stored.startsWith("$2b$") || stored.startsWith("$2y$");
|
|
41
|
+
}
|
|
42
|
+
function timingSafeEqualHex(a, b) {
|
|
43
|
+
if (a.length !== b.length) return false;
|
|
44
|
+
try {
|
|
45
|
+
return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
var MAX_API_KEYS_PER_USER = 20;
|
|
51
|
+
var MAX_LABEL_LENGTH = 60;
|
|
52
|
+
async function verifyKeyRequest(db, userId, opts) {
|
|
53
|
+
const label = normalizeKeyLabel(opts.label);
|
|
54
|
+
if (label === INVALID_LABEL) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
status: 400,
|
|
58
|
+
error: `Label must be a string of at most ${MAX_LABEL_LENGTH} characters.`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (opts.action === "fetch") return { ok: true, label, key: null };
|
|
62
|
+
const rows = await db.all(
|
|
63
|
+
"SELECT id, key_prefix, nest_id, label, created_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at ASC, id ASC",
|
|
64
|
+
[userId]
|
|
65
|
+
);
|
|
66
|
+
if (opts.action === "generate") {
|
|
67
|
+
if (rows.length >= MAX_API_KEYS_PER_USER) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
status: 409,
|
|
71
|
+
error: `You already have ${MAX_API_KEYS_PER_USER} API keys. Revoke one before creating another.`
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!label) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
status: 400,
|
|
78
|
+
error: "Name this key so you can tell it apart later."
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (rows.some((r) => r.label?.trim().toLowerCase() === label.toLowerCase())) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
status: 409,
|
|
85
|
+
error: `You already have a key named "${label}". Pick a different name.`
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, label, key: null };
|
|
89
|
+
}
|
|
90
|
+
const renameCollides = (targetId) => !!label && rows.some(
|
|
91
|
+
(r) => r.id !== targetId && r.label?.trim().toLowerCase() === label.toLowerCase()
|
|
92
|
+
);
|
|
93
|
+
const collision = {
|
|
94
|
+
ok: false,
|
|
95
|
+
status: 409,
|
|
96
|
+
error: `You already have a key named "${label}". Pick a different name.`
|
|
97
|
+
};
|
|
98
|
+
if (opts.keyId) {
|
|
99
|
+
const match = rows.find((r) => r.id === opts.keyId);
|
|
100
|
+
if (!match) return { ok: false, status: 404, error: "Key not found" };
|
|
101
|
+
if (renameCollides(match.id)) return collision;
|
|
102
|
+
return { ok: true, label, key: match };
|
|
103
|
+
}
|
|
104
|
+
if (rows.length === 1) {
|
|
105
|
+
if (renameCollides(rows[0].id)) return collision;
|
|
106
|
+
return { ok: true, label, key: rows[0] };
|
|
107
|
+
}
|
|
108
|
+
if (rows.length === 0) {
|
|
109
|
+
return { ok: false, status: 404, error: "No API key to rotate." };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
status: 400,
|
|
114
|
+
error: "You have several API keys \u2014 say which one to rotate (key_id)."
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
var INVALID_LABEL = /* @__PURE__ */ Symbol("invalid-label");
|
|
118
|
+
function normalizeKeyLabel(raw) {
|
|
119
|
+
if (raw === void 0 || raw === null || raw === "") return null;
|
|
120
|
+
if (typeof raw !== "string") return INVALID_LABEL;
|
|
121
|
+
const trimmed = raw.trim();
|
|
122
|
+
if (!trimmed) return null;
|
|
123
|
+
if (trimmed.length > MAX_LABEL_LENGTH) return INVALID_LABEL;
|
|
124
|
+
return trimmed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export {
|
|
128
|
+
generateApiKey,
|
|
129
|
+
hashApiKey,
|
|
130
|
+
getKeyPrefix,
|
|
131
|
+
parseBearerToken,
|
|
132
|
+
hashPassword,
|
|
133
|
+
verifyPassword,
|
|
134
|
+
MAX_API_KEYS_PER_USER,
|
|
135
|
+
verifyKeyRequest
|
|
136
|
+
};
|