@promptowl/contextnest-community 1.8.0 → 1.9.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 +9 -0
- package/dist/chunk-3JTODC3Y.js +48 -0
- package/dist/{chunk-PJTAUVD7.js → chunk-BYS4HDME.js} +39 -53
- package/dist/{chunk-PLAHCDQG.js → chunk-DPHV6Q26.js} +253 -19
- package/dist/chunk-FRQJWGN3.js +8 -0
- package/dist/{chunk-XNGOO6RI.js → chunk-HRQWNRVI.js} +4 -2
- package/dist/chunk-TIO5XOFD.js +103 -0
- package/dist/chunk-VC3QNHHB.js +664 -0
- package/dist/chunk-XQ46F76G.js +16 -0
- package/dist/client-GI74NPIW.js +11 -0
- package/dist/email-R7DFS6E5.js +6 -0
- package/dist/grants-service-MDLRPMZR.js +21 -0
- package/dist/index.js +1862 -223
- package/dist/{migrations.postgres-VCG45LMK.js → migrations.postgres-ORSV7UFJ.js} +105 -1
- package/dist/{review-service-G5US3SMG.js → review-service-A3XFQTJH.js} +6 -4
- package/dist/{stewardship-service-ZXAMFTXO.js → stewardship-service-3KCVVXCW.js} +4 -2
- package/dist/{version-service-B3SDOJQE.js → version-service-6J4OWQL6.js} +3 -2
- package/dist/web3/assets/index-CHavHPIT.css +1 -0
- package/dist/web3/assets/index-DyvOBH92.js +934 -0
- package/dist/web3/index.html +2 -2
- package/package.json +14 -5
- package/dist/chunk-Z3Y46ZGU.js +0 -362
- package/dist/web3/assets/index-Den-5nzY.js +0 -904
- package/dist/web3/assets/index-Jfj-2MHm.css +0 -1
package/CONFIGURATION.md
CHANGED
|
@@ -66,7 +66,16 @@ 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
|
+
| `FEATURE_WORKFLOW_PLANE` | _(unset — off)_ | Enables the workflow plane: typed edges, edge-type registry, governed runs. Optional feature; also toggleable from Settings. |
|
|
70
|
+
| `FEATURE_SUBAGENT_RUNS` | _(unset — off)_ | Lets runs spawn nested sub-agent runs (recursion). Gated separately from the plane; requires `FEATURE_WORKFLOW_PLANE`. Also toggleable from Settings → Advanced (turning the plane off forces this off too). Opens a recursion surface — enable only after reviewing the depth/fan-out caps. |
|
|
71
|
+
| `SUBAGENT_MAX_DEPTH` | `8` | Max sub-agent nesting depth (clamped 1..32) — bounds the call tree's HEIGHT so it stays finite/haltable. |
|
|
72
|
+
| `SUBAGENT_MAX_CHILDREN` | `16` | Max direct children a single run may spawn (clamped 1..128) — bounds the call tree's WIDTH. Together with `SUBAGENT_MAX_DEPTH` this caps total tree size so a runner can't fork-bomb the DB. |
|
|
73
|
+
| `RUN_MAX_STEPS` | `10000` | Max steps a single run may accumulate (clamped 10..100000) — bounds a runaway/hostile runner's step log. |
|
|
69
74
|
| `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. |
|
|
75
|
+
| `SMTP_URL` | _(unset — connector off)_ | SMTP connection URL for email notifications (`smtp://` or `smtps://`, credentials inline). Requires `NOTIFY_EMAIL_FROM` and `NOTIFY_EMAIL_TO`. Also editable from Settings. |
|
|
76
|
+
| `NOTIFY_EMAIL_FROM` | _(unset)_ | From address for notification emails. |
|
|
77
|
+
| `NOTIFY_EMAIL_TO` | _(unset)_ | Comma-separated recipients for notification emails. |
|
|
78
|
+
| `NOTIFY_DEBOUNCE_MS` | `15000` | Per-nest buffer window before notifications flush; bursts collapse into one digest message. |
|
|
70
79
|
|
|
71
80
|
---
|
|
72
81
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/shared/errors.ts
|
|
2
|
+
var AppError = class extends Error {
|
|
3
|
+
constructor(statusCode, message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.statusCode = statusCode;
|
|
6
|
+
this.name = "AppError";
|
|
7
|
+
}
|
|
8
|
+
statusCode;
|
|
9
|
+
};
|
|
10
|
+
var NotFoundError = class extends AppError {
|
|
11
|
+
constructor(message = "Not found") {
|
|
12
|
+
super(404, message);
|
|
13
|
+
this.name = "NotFoundError";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var ForbiddenError = class extends AppError {
|
|
17
|
+
constructor(message = "Forbidden") {
|
|
18
|
+
super(403, message);
|
|
19
|
+
this.name = "ForbiddenError";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
var ValidationError = class extends AppError {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(400, message);
|
|
25
|
+
this.name = "ValidationError";
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var ConflictError = class extends AppError {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(409, message);
|
|
31
|
+
this.name = "ConflictError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
var LockedError = class extends AppError {
|
|
35
|
+
constructor(message = "Locked") {
|
|
36
|
+
super(423, message);
|
|
37
|
+
this.name = "LockedError";
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
AppError,
|
|
43
|
+
NotFoundError,
|
|
44
|
+
ForbiddenError,
|
|
45
|
+
ValidationError,
|
|
46
|
+
ConflictError,
|
|
47
|
+
LockedError
|
|
48
|
+
};
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
ConflictError,
|
|
3
|
+
ValidationError
|
|
4
|
+
} from "./chunk-3JTODC3Y.js";
|
|
5
|
+
import {
|
|
4
6
|
insertOrReplace,
|
|
5
7
|
nowExpr
|
|
6
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-XQ46F76G.js";
|
|
9
|
+
import {
|
|
10
|
+
config,
|
|
11
|
+
getDb
|
|
12
|
+
} from "./chunk-DPHV6Q26.js";
|
|
7
13
|
import {
|
|
8
14
|
ANON_USER_ID
|
|
9
15
|
} from "./chunk-SLTQACJW.js";
|
|
@@ -104,46 +110,6 @@ function parseAccessYaml(content) {
|
|
|
104
110
|
return result;
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
// src/shared/errors.ts
|
|
108
|
-
var AppError = class extends Error {
|
|
109
|
-
constructor(statusCode, message) {
|
|
110
|
-
super(message);
|
|
111
|
-
this.statusCode = statusCode;
|
|
112
|
-
this.name = "AppError";
|
|
113
|
-
}
|
|
114
|
-
statusCode;
|
|
115
|
-
};
|
|
116
|
-
var NotFoundError = class extends AppError {
|
|
117
|
-
constructor(message = "Not found") {
|
|
118
|
-
super(404, message);
|
|
119
|
-
this.name = "NotFoundError";
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
var ForbiddenError = class extends AppError {
|
|
123
|
-
constructor(message = "Forbidden") {
|
|
124
|
-
super(403, message);
|
|
125
|
-
this.name = "ForbiddenError";
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
var ValidationError = class extends AppError {
|
|
129
|
-
constructor(message) {
|
|
130
|
-
super(400, message);
|
|
131
|
-
this.name = "ValidationError";
|
|
132
|
-
}
|
|
133
|
-
};
|
|
134
|
-
var ConflictError = class extends AppError {
|
|
135
|
-
constructor(message) {
|
|
136
|
-
super(409, message);
|
|
137
|
-
this.name = "ConflictError";
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
var LockedError = class extends AppError {
|
|
141
|
-
constructor(message = "Locked") {
|
|
142
|
-
super(423, message);
|
|
143
|
-
this.name = "LockedError";
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
|
|
147
113
|
// src/nodes/engine.ts
|
|
148
114
|
import {
|
|
149
115
|
NestStorage as NestStorage3,
|
|
@@ -924,11 +890,35 @@ async function buildTitleMap(nestId) {
|
|
|
924
890
|
function folderForNode(nodeId) {
|
|
925
891
|
return nodeId.replace(/^nodes\//, "").split("/").slice(0, -1).join("/");
|
|
926
892
|
}
|
|
927
|
-
async function
|
|
893
|
+
async function nestName(nestId) {
|
|
894
|
+
try {
|
|
895
|
+
const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
|
|
896
|
+
nestId
|
|
897
|
+
]);
|
|
898
|
+
return row?.name || nestId;
|
|
899
|
+
} catch {
|
|
900
|
+
return nestId;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
function docLink(nestId, nodeId, baseUrl) {
|
|
904
|
+
const base = baseUrl || config.PUBLIC_BASE_URL;
|
|
905
|
+
if (!base) return "";
|
|
906
|
+
const q = new URLSearchParams({ nest: nestId });
|
|
907
|
+
if (nodeId) q.set("doc", nodeId);
|
|
908
|
+
return `${base.replace(/\/$/, "")}/?${q.toString()}`;
|
|
909
|
+
}
|
|
910
|
+
async function buildDocContext(nestId, nodeId, baseUrl) {
|
|
928
911
|
const titles = await buildTitleMap(nestId);
|
|
929
|
-
const
|
|
912
|
+
const docTitle = titles.get(nodeId) || nodeId;
|
|
930
913
|
const folder = folderForNode(nodeId);
|
|
931
|
-
|
|
914
|
+
const name = await nestName(nestId);
|
|
915
|
+
return {
|
|
916
|
+
docTitle,
|
|
917
|
+
// Slack one-liner label ("Title (in Folder)"), same as describeNode().
|
|
918
|
+
label: folder ? `${docTitle} (in ${folder})` : docTitle,
|
|
919
|
+
path: folder ? `${name} / ${folder}` : name,
|
|
920
|
+
link: docLink(nestId, nodeId, baseUrl)
|
|
921
|
+
};
|
|
932
922
|
}
|
|
933
923
|
|
|
934
924
|
// src/shared/access.ts
|
|
@@ -1549,12 +1539,6 @@ function rowToSteward(row) {
|
|
|
1549
1539
|
}
|
|
1550
1540
|
|
|
1551
1541
|
export {
|
|
1552
|
-
AppError,
|
|
1553
|
-
NotFoundError,
|
|
1554
|
-
ForbiddenError,
|
|
1555
|
-
ValidationError,
|
|
1556
|
-
ConflictError,
|
|
1557
|
-
LockedError,
|
|
1558
1542
|
trackEvent,
|
|
1559
1543
|
startTelemetryLoop,
|
|
1560
1544
|
getCurrentLicense,
|
|
@@ -1589,7 +1573,9 @@ export {
|
|
|
1589
1573
|
deleteNest,
|
|
1590
1574
|
engineCache,
|
|
1591
1575
|
buildTitleMap,
|
|
1592
|
-
|
|
1576
|
+
nestName,
|
|
1577
|
+
docLink,
|
|
1578
|
+
buildDocContext,
|
|
1593
1579
|
collabPermToRole,
|
|
1594
1580
|
assignSteward,
|
|
1595
1581
|
removeSteward,
|
|
@@ -2,6 +2,11 @@ import {
|
|
|
2
2
|
ANON_USER_ID
|
|
3
3
|
} from "./chunk-SLTQACJW.js";
|
|
4
4
|
|
|
5
|
+
// src/db/client.ts
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { mkdirSync, readFileSync } from "fs";
|
|
8
|
+
import { dirname as dirname2 } from "path";
|
|
9
|
+
|
|
5
10
|
// src/config.ts
|
|
6
11
|
import { join, dirname } from "path";
|
|
7
12
|
import { existsSync } from "fs";
|
|
@@ -21,6 +26,14 @@ if (envFileLoaded && !isTestRun) {
|
|
|
21
26
|
var canonicalEnvFile = process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
|
|
22
27
|
var canonicalEnvLoaded = null;
|
|
23
28
|
var slackUrlWarned = false;
|
|
29
|
+
var emailFromWarned = false;
|
|
30
|
+
var emailToWarned = false;
|
|
31
|
+
function isEmailish(v) {
|
|
32
|
+
return /^[^\s@]+@[^\s@]+$/.test(v) && !/[\r\n]/.test(v);
|
|
33
|
+
}
|
|
34
|
+
function isEmailListish(v) {
|
|
35
|
+
return v.split(",").every((e) => isEmailish(e.trim()));
|
|
36
|
+
}
|
|
24
37
|
if (!isTestRun && canonicalEnvFile !== envFileLoaded && existsSync(canonicalEnvFile)) {
|
|
25
38
|
dotenv.config({ path: canonicalEnvFile, override: true });
|
|
26
39
|
canonicalEnvLoaded = canonicalEnvFile;
|
|
@@ -187,9 +200,101 @@ var config = {
|
|
|
187
200
|
}
|
|
188
201
|
return raw;
|
|
189
202
|
},
|
|
203
|
+
/**
|
|
204
|
+
* Workflow plane ("turing" track): typed edges, edge-type registry, runs.
|
|
205
|
+
* OFF by default — an optional feature an admin turns on (env or Settings).
|
|
206
|
+
* This flag is also the future license-tier hook: gating a paid tier here
|
|
207
|
+
* is a one-line change because every plane route checks it per request.
|
|
208
|
+
*/
|
|
209
|
+
get FEATURE_WORKFLOW_PLANE() {
|
|
210
|
+
return process.env.FEATURE_WORKFLOW_PLANE === "true";
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* Sub-agent runs: lets a run spawn nested runs (an external runner calling
|
|
214
|
+
* back into POST /run for a child agent). Gated SEPARATELY from the base
|
|
215
|
+
* plane because recursion is the risky surface — unbounded fan-out,
|
|
216
|
+
* re-entrancy, resource exhaustion. Off by default; must clear a security
|
|
217
|
+
* review before an operator turns it on. Requires FEATURE_WORKFLOW_PLANE too.
|
|
218
|
+
*/
|
|
219
|
+
get FEATURE_SUBAGENT_RUNS() {
|
|
220
|
+
return process.env.FEATURE_SUBAGENT_RUNS === "true";
|
|
221
|
+
},
|
|
222
|
+
/** Hard ceiling on sub-agent nesting depth — keeps the call tree finite
|
|
223
|
+
* (haltable / statically bounded). Clamped to [1, 32]. */
|
|
224
|
+
get SUBAGENT_MAX_DEPTH() {
|
|
225
|
+
const n = parseInt(process.env.SUBAGENT_MAX_DEPTH || "8", 10);
|
|
226
|
+
return Number.isFinite(n) ? Math.min(32, Math.max(1, n)) : 8;
|
|
227
|
+
},
|
|
228
|
+
/** Max direct children a single run may spawn. Depth bounds the tree's
|
|
229
|
+
* height; this bounds its width — together they cap total size so a
|
|
230
|
+
* runner can't fork-bomb the DB. Clamped to [1, 128]. */
|
|
231
|
+
get SUBAGENT_MAX_CHILDREN() {
|
|
232
|
+
const n = parseInt(process.env.SUBAGENT_MAX_CHILDREN || "16", 10);
|
|
233
|
+
return Number.isFinite(n) ? Math.min(128, Math.max(1, n)) : 16;
|
|
234
|
+
},
|
|
235
|
+
/** Max steps a single run may accumulate — bounds a runaway/hostile
|
|
236
|
+
* runner's step log. Clamped to [10, 100000]. */
|
|
237
|
+
get RUN_MAX_STEPS() {
|
|
238
|
+
const n = parseInt(process.env.RUN_MAX_STEPS || "10000", 10);
|
|
239
|
+
return Number.isFinite(n) ? Math.min(1e5, Math.max(10, n)) : 1e4;
|
|
240
|
+
},
|
|
190
241
|
get AUTH_MODE() {
|
|
191
242
|
return process.env.AUTH_MODE || "key";
|
|
192
243
|
},
|
|
244
|
+
/**
|
|
245
|
+
* Optional SMTP connection URL for email notifications, e.g.
|
|
246
|
+
* smtps://user:pass@smtp.example.com:465. smtp:// or smtps:// only.
|
|
247
|
+
* Empty/unset = email connector off. Requires NOTIFY_EMAIL_FROM/_TO.
|
|
248
|
+
*/
|
|
249
|
+
get SMTP_URL() {
|
|
250
|
+
const raw = process.env.SMTP_URL?.trim();
|
|
251
|
+
if (!raw) return null;
|
|
252
|
+
if (!/^smtps?:\/\//i.test(raw)) {
|
|
253
|
+
console.warn(
|
|
254
|
+
"[config] SMTP_URL rejected: must be an smtp:// or smtps:// URL. Email notifications disabled."
|
|
255
|
+
);
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
return raw;
|
|
259
|
+
},
|
|
260
|
+
/**
|
|
261
|
+
* From address for notification emails. Shape-validated here (not only in
|
|
262
|
+
* the /admin/settings PATCH) so a value set directly via env/Docker can't
|
|
263
|
+
* reach nodemailer's envelope unchecked. Invalid = connector off.
|
|
264
|
+
*/
|
|
265
|
+
get NOTIFY_EMAIL_FROM() {
|
|
266
|
+
const raw = process.env.NOTIFY_EMAIL_FROM?.trim();
|
|
267
|
+
if (!raw) return null;
|
|
268
|
+
if (!isEmailish(raw)) {
|
|
269
|
+
if (!emailFromWarned) {
|
|
270
|
+
emailFromWarned = true;
|
|
271
|
+
console.warn(
|
|
272
|
+
"[config] NOTIFY_EMAIL_FROM rejected: must be a plain email address. Email notifications disabled."
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return raw;
|
|
278
|
+
},
|
|
279
|
+
/**
|
|
280
|
+
* Comma-separated recipient list for notification emails. Shape-validated
|
|
281
|
+
* here for the same env-bypass reason as NOTIFY_EMAIL_FROM. Invalid (any
|
|
282
|
+
* entry malformed) = connector off.
|
|
283
|
+
*/
|
|
284
|
+
get NOTIFY_EMAIL_TO() {
|
|
285
|
+
const raw = process.env.NOTIFY_EMAIL_TO?.trim();
|
|
286
|
+
if (!raw) return null;
|
|
287
|
+
if (!isEmailListish(raw)) {
|
|
288
|
+
if (!emailToWarned) {
|
|
289
|
+
emailToWarned = true;
|
|
290
|
+
console.warn(
|
|
291
|
+
"[config] NOTIFY_EMAIL_TO rejected: must be a comma-separated list of email addresses. Email notifications disabled."
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
return raw;
|
|
297
|
+
},
|
|
193
298
|
/**
|
|
194
299
|
* Optional Slack incoming-webhook URL for team notifications (review
|
|
195
300
|
* submitted/approved/rejected, nest shared). Empty/unset = connector off.
|
|
@@ -210,6 +315,18 @@ var config = {
|
|
|
210
315
|
}
|
|
211
316
|
return raw;
|
|
212
317
|
},
|
|
318
|
+
/**
|
|
319
|
+
* Per-nest notification digest window in milliseconds. A burst of governance
|
|
320
|
+
* events on one nest inside this window collapses into a single digest
|
|
321
|
+
* message instead of one post per event. Empty / non-numeric / negative /
|
|
322
|
+
* unset → 15000 (15s).
|
|
323
|
+
*/
|
|
324
|
+
get NOTIFY_DEBOUNCE_MS() {
|
|
325
|
+
const raw = process.env.NOTIFY_DEBOUNCE_MS?.trim();
|
|
326
|
+
if (!raw) return 15e3;
|
|
327
|
+
const n = Number(raw);
|
|
328
|
+
return Number.isFinite(n) && n >= 0 ? n : 15e3;
|
|
329
|
+
},
|
|
213
330
|
/**
|
|
214
331
|
* CORS origin allowlist. Comma-separated list of origins or "*".
|
|
215
332
|
* Defaults to "*" in open mode (read-heavy, no credentials to steal)
|
|
@@ -231,11 +348,6 @@ var config = {
|
|
|
231
348
|
}
|
|
232
349
|
};
|
|
233
350
|
|
|
234
|
-
// src/db/client.ts
|
|
235
|
-
import Database from "better-sqlite3";
|
|
236
|
-
import { mkdirSync, readFileSync } from "fs";
|
|
237
|
-
import { dirname as dirname2 } from "path";
|
|
238
|
-
|
|
239
351
|
// src/db/migrations.ts
|
|
240
352
|
function runMigrations(db) {
|
|
241
353
|
db.exec(`
|
|
@@ -802,6 +914,27 @@ function runMigrations(db) {
|
|
|
802
914
|
})();
|
|
803
915
|
recordMigration("012_merge_case_colliding_users");
|
|
804
916
|
}
|
|
917
|
+
if (!hasMigration("019_grants")) {
|
|
918
|
+
db.transaction(() => {
|
|
919
|
+
db.exec(`
|
|
920
|
+
CREATE TABLE IF NOT EXISTS grants (
|
|
921
|
+
id TEXT PRIMARY KEY,
|
|
922
|
+
nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
|
|
923
|
+
target_type TEXT NOT NULL CHECK(target_type IN ('document', 'folder')),
|
|
924
|
+
target TEXT NOT NULL, -- node id (document) or path prefix (folder)
|
|
925
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
926
|
+
role TEXT NOT NULL CHECK(role IN ('read', 'write')),
|
|
927
|
+
granted_by TEXT NOT NULL,
|
|
928
|
+
created_at TEXT NOT NULL,
|
|
929
|
+
updated_at TEXT NOT NULL
|
|
930
|
+
);
|
|
931
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_grants_unique
|
|
932
|
+
ON grants(nest_id, target_type, target, user_id);
|
|
933
|
+
CREATE INDEX IF NOT EXISTS idx_grants_user ON grants(nest_id, user_id);
|
|
934
|
+
`);
|
|
935
|
+
})();
|
|
936
|
+
recordMigration("019_grants");
|
|
937
|
+
}
|
|
805
938
|
if (!hasMigration("013_node_deletion_tombstones")) {
|
|
806
939
|
db.transaction(() => {
|
|
807
940
|
db.exec(`
|
|
@@ -839,6 +972,112 @@ function runMigrations(db) {
|
|
|
839
972
|
})();
|
|
840
973
|
recordMigration("014_api_events");
|
|
841
974
|
}
|
|
975
|
+
if (!hasMigration("015_definitions")) {
|
|
976
|
+
db.transaction(() => {
|
|
977
|
+
db.exec(`
|
|
978
|
+
CREATE TABLE IF NOT EXISTS definitions (
|
|
979
|
+
id TEXT PRIMARY KEY,
|
|
980
|
+
nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
|
|
981
|
+
term TEXT NOT NULL,
|
|
982
|
+
definition TEXT NOT NULL,
|
|
983
|
+
linked_tag TEXT, -- optional #tag this term maps to
|
|
984
|
+
defined_by TEXT NOT NULL, -- email
|
|
985
|
+
created_at TEXT NOT NULL,
|
|
986
|
+
updated_at TEXT NOT NULL
|
|
987
|
+
);
|
|
988
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_definitions_term
|
|
989
|
+
ON definitions(nest_id, term COLLATE NOCASE);
|
|
990
|
+
`);
|
|
991
|
+
})();
|
|
992
|
+
recordMigration("015_definitions");
|
|
993
|
+
}
|
|
994
|
+
if (!hasMigration("016_workflow_edges")) {
|
|
995
|
+
db.transaction(() => {
|
|
996
|
+
db.exec(`
|
|
997
|
+
CREATE TABLE IF NOT EXISTS edge_types (
|
|
998
|
+
id TEXT PRIMARY KEY,
|
|
999
|
+
nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
|
|
1000
|
+
name TEXT NOT NULL,
|
|
1001
|
+
description TEXT NOT NULL,
|
|
1002
|
+
direction TEXT NOT NULL DEFAULT 'directed'
|
|
1003
|
+
CHECK(direction IN ('directed', 'undirected')),
|
|
1004
|
+
is_flow INTEGER NOT NULL DEFAULT 0, -- flow types form the DAG
|
|
1005
|
+
condition_schema TEXT, -- JSON template | NULL
|
|
1006
|
+
color TEXT,
|
|
1007
|
+
created_by TEXT NOT NULL,
|
|
1008
|
+
created_at TEXT NOT NULL,
|
|
1009
|
+
updated_at TEXT NOT NULL
|
|
1010
|
+
);
|
|
1011
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_edge_types_name
|
|
1012
|
+
ON edge_types(nest_id, LOWER(name));
|
|
1013
|
+
|
|
1014
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
1015
|
+
id TEXT PRIMARY KEY,
|
|
1016
|
+
nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
|
|
1017
|
+
from_node TEXT NOT NULL,
|
|
1018
|
+
to_node TEXT NOT NULL,
|
|
1019
|
+
-- RESTRICT (not CASCADE): the app refuses to delete an edge type
|
|
1020
|
+
-- while edges reference it; the DB backs that invariant instead of
|
|
1021
|
+
-- silently cascade-deleting live edges if the app check is bypassed.
|
|
1022
|
+
type_id TEXT NOT NULL REFERENCES edge_types(id) ON DELETE RESTRICT,
|
|
1023
|
+
condition_mode TEXT
|
|
1024
|
+
CHECK(condition_mode IN ('structured', 'nl', 'open')),
|
|
1025
|
+
condition TEXT,
|
|
1026
|
+
metadata TEXT,
|
|
1027
|
+
created_by TEXT NOT NULL,
|
|
1028
|
+
created_at TEXT NOT NULL,
|
|
1029
|
+
updated_at TEXT NOT NULL
|
|
1030
|
+
);
|
|
1031
|
+
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(nest_id, from_node);
|
|
1032
|
+
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(nest_id, to_node);
|
|
1033
|
+
CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(nest_id, type_id);
|
|
1034
|
+
`);
|
|
1035
|
+
})();
|
|
1036
|
+
recordMigration("016_workflow_edges");
|
|
1037
|
+
}
|
|
1038
|
+
if (!hasMigration("017_workflow_runs")) {
|
|
1039
|
+
db.transaction(() => {
|
|
1040
|
+
db.exec(`
|
|
1041
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
1042
|
+
id TEXT PRIMARY KEY,
|
|
1043
|
+
nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
|
|
1044
|
+
agent_node TEXT NOT NULL,
|
|
1045
|
+
triggered_by TEXT NOT NULL,
|
|
1046
|
+
status TEXT NOT NULL DEFAULT 'running'
|
|
1047
|
+
CHECK(status IN ('running', 'succeeded', 'failed', 'cancelled')),
|
|
1048
|
+
inputs TEXT,
|
|
1049
|
+
trace TEXT,
|
|
1050
|
+
started_at TEXT NOT NULL,
|
|
1051
|
+
finished_at TEXT
|
|
1052
|
+
);
|
|
1053
|
+
CREATE INDEX IF NOT EXISTS idx_runs_nest ON runs(nest_id, started_at);
|
|
1054
|
+
CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(nest_id, agent_node);
|
|
1055
|
+
|
|
1056
|
+
CREATE TABLE IF NOT EXISTS run_steps (
|
|
1057
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1058
|
+
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
1059
|
+
seq INTEGER NOT NULL,
|
|
1060
|
+
node_id TEXT,
|
|
1061
|
+
edge_id TEXT,
|
|
1062
|
+
action TEXT NOT NULL,
|
|
1063
|
+
detail TEXT,
|
|
1064
|
+
at TEXT NOT NULL
|
|
1065
|
+
);
|
|
1066
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_run_steps ON run_steps(run_id, seq);
|
|
1067
|
+
`);
|
|
1068
|
+
})();
|
|
1069
|
+
recordMigration("017_workflow_runs");
|
|
1070
|
+
}
|
|
1071
|
+
if (!hasMigration("018_subagent_runs")) {
|
|
1072
|
+
db.transaction(() => {
|
|
1073
|
+
db.exec(`
|
|
1074
|
+
ALTER TABLE runs ADD COLUMN parent_run_id TEXT;
|
|
1075
|
+
ALTER TABLE runs ADD COLUMN depth INTEGER NOT NULL DEFAULT 0;
|
|
1076
|
+
CREATE INDEX IF NOT EXISTS idx_runs_parent ON runs(parent_run_id);
|
|
1077
|
+
`);
|
|
1078
|
+
})();
|
|
1079
|
+
recordMigration("018_subagent_runs");
|
|
1080
|
+
}
|
|
842
1081
|
}
|
|
843
1082
|
function mergeCaseCollidingUsers(db) {
|
|
844
1083
|
const groups = db.prepare(
|
|
@@ -1056,7 +1295,7 @@ async function initDb() {
|
|
|
1056
1295
|
if (config.DB_DRIVER === "postgres") {
|
|
1057
1296
|
const { Pool } = await import("pg");
|
|
1058
1297
|
const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
|
|
1059
|
-
const { runPostgresMigrations } = await import("./migrations.postgres-
|
|
1298
|
+
const { runPostgresMigrations } = await import("./migrations.postgres-ORSV7UFJ.js");
|
|
1060
1299
|
const pool = new Pool(buildPgConfig());
|
|
1061
1300
|
adapter = new PostgresAdapter(pool);
|
|
1062
1301
|
await runPostgresMigrations(adapter);
|
|
@@ -1076,23 +1315,18 @@ function getDb() {
|
|
|
1076
1315
|
}
|
|
1077
1316
|
return adapter;
|
|
1078
1317
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
}
|
|
1084
|
-
function insertOrIgnore(db, insertSql) {
|
|
1085
|
-
return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR IGNORE INTO") : `${insertSql} ON CONFLICT DO NOTHING`;
|
|
1086
|
-
}
|
|
1087
|
-
function insertOrReplace(db, insertSql, conflictCols, set) {
|
|
1088
|
-
return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR REPLACE INTO") : `${insertSql} ON CONFLICT (${conflictCols.join(", ")}) DO UPDATE SET ${set}`;
|
|
1318
|
+
async function resetDb() {
|
|
1319
|
+
if (adapter) {
|
|
1320
|
+
await adapter.close();
|
|
1321
|
+
adapter = null;
|
|
1322
|
+
}
|
|
1089
1323
|
}
|
|
1090
1324
|
|
|
1091
1325
|
export {
|
|
1326
|
+
isEmailish,
|
|
1327
|
+
isEmailListish,
|
|
1092
1328
|
config,
|
|
1093
1329
|
initDb,
|
|
1094
1330
|
getDb,
|
|
1095
|
-
|
|
1096
|
-
insertOrIgnore,
|
|
1097
|
-
insertOrReplace
|
|
1331
|
+
resetDb
|
|
1098
1332
|
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ValidationError
|
|
3
|
+
} from "./chunk-3JTODC3Y.js";
|
|
4
|
+
import {
|
|
5
|
+
getDb
|
|
6
|
+
} from "./chunk-DPHV6Q26.js";
|
|
7
|
+
|
|
8
|
+
// src/governance/grants-service.ts
|
|
9
|
+
import { v4 as uuid } from "uuid";
|
|
10
|
+
var rank = (r) => r === "write" ? 2 : 1;
|
|
11
|
+
async function resolveNodeGrant(nestId, userId, nodeId) {
|
|
12
|
+
if (!userId || !nodeId) return null;
|
|
13
|
+
const rows = await getDb().all(
|
|
14
|
+
"SELECT target, role FROM grants WHERE nest_id = ? AND user_id = ?",
|
|
15
|
+
[nestId, userId]
|
|
16
|
+
);
|
|
17
|
+
let best = null;
|
|
18
|
+
for (const r of rows) {
|
|
19
|
+
if (nodeId === r.target || nodeId.startsWith(r.target + "/")) {
|
|
20
|
+
if (!best || rank(r.role) > rank(best)) best = r.role;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return best;
|
|
24
|
+
}
|
|
25
|
+
async function hasAnyGrant(nestId, userId) {
|
|
26
|
+
if (!userId) return false;
|
|
27
|
+
const row = await getDb().get(
|
|
28
|
+
"SELECT 1 AS x FROM grants WHERE nest_id = ? AND user_id = ? LIMIT 1",
|
|
29
|
+
[nestId, userId]
|
|
30
|
+
);
|
|
31
|
+
return !!row;
|
|
32
|
+
}
|
|
33
|
+
async function listUserGrants(nestId, userId) {
|
|
34
|
+
if (!userId) return [];
|
|
35
|
+
return await getDb().all(
|
|
36
|
+
"SELECT target, role FROM grants WHERE nest_id = ? AND user_id = ?",
|
|
37
|
+
[nestId, userId]
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
function grantCoversNode(grants, nodeId) {
|
|
41
|
+
return grants.some(
|
|
42
|
+
(g) => nodeId === g.target || nodeId.startsWith(g.target + "/")
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
async function createGrant(params) {
|
|
46
|
+
const { nestId, targetType, target, userId, role, grantedBy } = params;
|
|
47
|
+
if (!["document", "folder"].includes(targetType)) {
|
|
48
|
+
throw new ValidationError("target_type must be document | folder");
|
|
49
|
+
}
|
|
50
|
+
if (!["read", "write"].includes(role)) {
|
|
51
|
+
throw new ValidationError("role must be read | write");
|
|
52
|
+
}
|
|
53
|
+
if (!target.trim()) throw new ValidationError("target is required");
|
|
54
|
+
if (targetType === "folder") {
|
|
55
|
+
const t = target.trim().replace(/\/+$/, "");
|
|
56
|
+
if (t === "nodes" || t === "") {
|
|
57
|
+
throw new ValidationError("folder target must name a subfolder, not the nest root");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const db = getDb();
|
|
61
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
62
|
+
const existing = await db.get(
|
|
63
|
+
"SELECT id FROM grants WHERE nest_id = ? AND target_type = ? AND target = ? AND user_id = ?",
|
|
64
|
+
[nestId, targetType, target, userId]
|
|
65
|
+
);
|
|
66
|
+
if (existing) {
|
|
67
|
+
await db.run(
|
|
68
|
+
"UPDATE grants SET role = ?, granted_by = ?, updated_at = ? WHERE id = ?",
|
|
69
|
+
[role, grantedBy, now, existing.id]
|
|
70
|
+
);
|
|
71
|
+
return await db.get("SELECT * FROM grants WHERE id = ?", [existing.id]);
|
|
72
|
+
}
|
|
73
|
+
const id = uuid();
|
|
74
|
+
await db.run(
|
|
75
|
+
`INSERT INTO grants (id, nest_id, target_type, target, user_id, role, granted_by, created_at, updated_at)
|
|
76
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
77
|
+
[id, nestId, targetType, target, userId, role, grantedBy, now, now]
|
|
78
|
+
);
|
|
79
|
+
return await db.get("SELECT * FROM grants WHERE id = ?", [id]);
|
|
80
|
+
}
|
|
81
|
+
async function listGrants(nestId) {
|
|
82
|
+
return await getDb().all(
|
|
83
|
+
"SELECT * FROM grants WHERE nest_id = ? ORDER BY target_type, target",
|
|
84
|
+
[nestId]
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
async function deleteGrant(nestId, id) {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
const row = await db.get("SELECT id FROM grants WHERE id = ? AND nest_id = ?", [id, nestId]);
|
|
90
|
+
if (!row) return false;
|
|
91
|
+
await db.run("DELETE FROM grants WHERE id = ?", [id]);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export {
|
|
96
|
+
resolveNodeGrant,
|
|
97
|
+
hasAnyGrant,
|
|
98
|
+
listUserGrants,
|
|
99
|
+
grantCoversNode,
|
|
100
|
+
createGrant,
|
|
101
|
+
listGrants,
|
|
102
|
+
deleteGrant
|
|
103
|
+
};
|