@promptowl/contextnest-community 1.14.0 → 1.16.1
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 +15 -0
- package/dist/chunk-CLKZZZS2.js +624 -0
- package/dist/{chunk-JP67WXLA.js → chunk-D6HICFVW.js} +77 -3
- package/dist/{chunk-I5KYGMIT.js → chunk-JMJLNEXE.js} +78 -1
- package/dist/{chunk-KPK656BH.js → chunk-L7UCMGWZ.js} +478 -924
- package/dist/{chunk-IHGPZ6NY.js → chunk-OTGZQZKK.js} +1 -1
- package/dist/{chunk-MZ4IR5KH.js → chunk-QJVRZT6M.js} +356 -76
- package/dist/{client-3MGIP57D.js → client-OBS5HOTA.js} +1 -1
- package/dist/{grants-service-5ZFISCBH.js → grants-service-I535QVTW.js} +2 -2
- package/dist/index.js +2419 -1061
- package/dist/{migrations.postgres-BRXZY2GE.js → migrations.postgres-CYKO5CFV.js} +41 -1
- package/dist/review-service-V2GR6VM3.js +47 -0
- package/dist/{stewardship-service-G7IF7ZNP.js → stewardship-service-LTREGVSJ.js} +4 -4
- package/dist/{version-service-FGL47APL.js → version-service-V6K5SZ3P.js} +11 -3
- package/dist/web3/assets/index-DnQARUL3.css +1 -0
- package/dist/web3/assets/index-_m7WyozM.js +1051 -0
- package/dist/web3/index.html +2 -2
- package/package.json +4 -3
- package/dist/chunk-XQ46F76G.js +0 -16
- package/dist/review-service-6G7MVTY6.js +0 -31
- package/dist/web3/assets/index-BYmOkm4p.js +0 -1052
- package/dist/web3/assets/index-p6uQyhml.css +0 -1
package/CONFIGURATION.md
CHANGED
|
@@ -73,6 +73,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
|
|
|
73
73
|
| `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
|
|
74
74
|
| `TRACE_RETENTION_DAYS` | `14` | Activity-trace retention window in days (the `api_events` rows behind `GET /admin/trace` and `GET /nests/:id/trace`). Rows older than this are pruned opportunistically (every ~500 inserts). `0` = keep forever (pruning is skipped entirely). Capped at `3650`; invalid/negative values fall back to `14`. Also editable from Settings → Advanced. |
|
|
75
75
|
| `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). |
|
|
76
|
+
| `FRAME_ANCESTORS` | `'self'` | Which origins may embed this server in an iframe, sent as CSP `frame-ancestors`. The default lets nothing but this origin frame the UI, which blocks clickjacking. Deployments that are meant to be embedded list the embedding origin — e.g. the PromptOwl Data Room iframes ContextNest, so that install sets `FRAME_ANCESTORS="https://app.promptowl.ai"`. Comma-separated; `'self'` is always included; `*` allows any site and disables the protection. Note the embedding page must be **same-site** (a sibling subdomain) for the session cookie to survive inside the frame — a genuinely cross-domain embed will render the login page no matter what this is set to. |
|
|
76
77
|
| `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. The asset-upload route (`POST /nests/:id/assets`) is exempt up to the video cap below. |
|
|
77
78
|
| `VIDEO_MAX_MB` | `30` | Max size (in MB) of a video uploaded into a doc. Default `30` keeps it under Cloud Run's ~32 MiB HTTP/1 request-body limit, so an oversized video is rejected with a clear message instead of a bare `413` from the platform. Raise only where the deployment can actually accept larger request bodies (not behind Cloud Run, or on HTTP/2 / direct-to-bucket upload). Images are fixed at 10 MB. |
|
|
78
79
|
| `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. |
|
|
@@ -279,6 +280,20 @@ On first connect the server creates its schema automatically (idempotent) — no
|
|
|
279
280
|
manual migration step. There is no built-in SQLite→Postgres data migration; a new
|
|
280
281
|
Postgres backend starts empty.
|
|
281
282
|
|
|
283
|
+
### First-boot upgrade passes
|
|
284
|
+
|
|
285
|
+
Some upgrades carry a one-shot data pass that runs at startup, before the server
|
|
286
|
+
accepts requests, and records itself in `schema_migrations` so it never repeats.
|
|
287
|
+
Two exist today: the `node_versions` backfill from `history.yaml`, and the
|
|
288
|
+
version-chain repair added in 1.13 (re-anchors documents whose version chain was
|
|
289
|
+
grafted by an older import, and moves inline patches into `v{N}.diff` files).
|
|
290
|
+
|
|
291
|
+
Both walk every document in every nest, so on a large vault the **first** boot
|
|
292
|
+
after upgrading can take noticeably longer than usual — reads of the on-disk
|
|
293
|
+
vault, not database work, so an install on network/FUSE storage feels it most.
|
|
294
|
+
Every subsequent boot skips the pass entirely. Progress and a per-pass summary
|
|
295
|
+
are printed to the server log.
|
|
296
|
+
|
|
282
297
|
> ⚠️ **Cloud Run durability — read this.** Cloud SQL persists only the *metadata
|
|
283
298
|
> database*. The actual **document content and version history** live on the
|
|
284
299
|
> filesystem under `$DATA_ROOT/nests/`, which on Cloud Run is **ephemeral** and is
|
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildTitleMap,
|
|
3
|
+
canEditWith,
|
|
4
|
+
canViewWith,
|
|
5
|
+
collabPermToRole,
|
|
6
|
+
docLink,
|
|
7
|
+
isSuperAdmin,
|
|
8
|
+
nestAllowsSelfApprove,
|
|
9
|
+
nestName,
|
|
10
|
+
primaryRole,
|
|
11
|
+
resolveNestPermission,
|
|
12
|
+
resolveTeamRolesForUser,
|
|
13
|
+
sendEmailToRecipient
|
|
14
|
+
} from "./chunk-L7UCMGWZ.js";
|
|
15
|
+
import {
|
|
16
|
+
ConflictError,
|
|
17
|
+
ValidationError
|
|
18
|
+
} from "./chunk-GUNJTORH.js";
|
|
19
|
+
import {
|
|
20
|
+
config,
|
|
21
|
+
getDb,
|
|
22
|
+
isEmailish
|
|
23
|
+
} from "./chunk-JMJLNEXE.js";
|
|
24
|
+
|
|
25
|
+
// src/governance/stewardship-service.ts
|
|
26
|
+
import { v4 as uuid } from "uuid";
|
|
27
|
+
async function assignSteward(data) {
|
|
28
|
+
const db = getDb();
|
|
29
|
+
const id = uuid();
|
|
30
|
+
await db.run(
|
|
31
|
+
`INSERT INTO stewards
|
|
32
|
+
(id, nest_id, scope, node_pattern, tag_name, user_email, user_id, role, assigned_by, is_active)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
34
|
+
[
|
|
35
|
+
id,
|
|
36
|
+
data.nestId,
|
|
37
|
+
data.scope,
|
|
38
|
+
data.nodePattern || null,
|
|
39
|
+
data.tagName || null,
|
|
40
|
+
data.userEmail,
|
|
41
|
+
data.userId || null,
|
|
42
|
+
data.role,
|
|
43
|
+
data.assignedBy,
|
|
44
|
+
data.isActive ? 1 : 0
|
|
45
|
+
]
|
|
46
|
+
);
|
|
47
|
+
return { ...data, id };
|
|
48
|
+
}
|
|
49
|
+
async function removeSteward(id) {
|
|
50
|
+
const db = getDb();
|
|
51
|
+
await db.run("DELETE FROM stewards WHERE id = ?", [id]);
|
|
52
|
+
}
|
|
53
|
+
var VALID_STEWARD_ROLES = ["editor", "reviewer", "viewer"];
|
|
54
|
+
async function updateSteward(id, update) {
|
|
55
|
+
const db = getDb();
|
|
56
|
+
const current = await db.get(
|
|
57
|
+
"SELECT * FROM stewards WHERE id = ? AND is_active = 1",
|
|
58
|
+
[id]
|
|
59
|
+
);
|
|
60
|
+
if (!current) {
|
|
61
|
+
throw new ValidationError("Steward not found");
|
|
62
|
+
}
|
|
63
|
+
const role = update.role ?? current.role;
|
|
64
|
+
if (!VALID_STEWARD_ROLES.includes(role)) {
|
|
65
|
+
throw new ValidationError(
|
|
66
|
+
`Invalid role "${role}" \u2014 must be editor, reviewer, or viewer.`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
let scope = current.scope;
|
|
70
|
+
let nodePattern = current.node_pattern ?? null;
|
|
71
|
+
let tagName = current.tag_name ?? null;
|
|
72
|
+
if (update.scope) {
|
|
73
|
+
scope = update.scope;
|
|
74
|
+
nodePattern = null;
|
|
75
|
+
tagName = null;
|
|
76
|
+
switch (scope) {
|
|
77
|
+
case "document":
|
|
78
|
+
if (!update.documentId)
|
|
79
|
+
throw new ValidationError("documentId required for document scope");
|
|
80
|
+
nodePattern = update.documentId;
|
|
81
|
+
break;
|
|
82
|
+
case "tag":
|
|
83
|
+
if (!update.tagName)
|
|
84
|
+
throw new ValidationError("tagName required for tag scope");
|
|
85
|
+
tagName = update.tagName.trim().replace(/^#+/, "").toLowerCase();
|
|
86
|
+
break;
|
|
87
|
+
case "nest":
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
const dup = await db.get(
|
|
91
|
+
`SELECT role FROM stewards
|
|
92
|
+
WHERE nest_id = ? AND is_active = 1 AND id != ? AND scope = ?
|
|
93
|
+
AND user_email = ?
|
|
94
|
+
AND COALESCE(node_pattern, '') = COALESCE(?, '')
|
|
95
|
+
AND COALESCE(tag_name, '') = COALESCE(?, '')`,
|
|
96
|
+
[current.nest_id, id, scope, current.user_email, nodePattern, tagName]
|
|
97
|
+
);
|
|
98
|
+
if (dup) {
|
|
99
|
+
const scopeLabel = scope === "document" ? `document "${nodePattern}"` : scope === "tag" ? `tag "#${tagName}"` : "this nest";
|
|
100
|
+
throw new ConflictError(
|
|
101
|
+
`"${current.user_email}" is already a steward of ${scopeLabel} with the "${dup.role}" role.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
await db.run(
|
|
106
|
+
"UPDATE stewards SET role = ?, scope = ?, node_pattern = ?, tag_name = ? WHERE id = ?",
|
|
107
|
+
[role, scope, nodePattern, tagName, id]
|
|
108
|
+
);
|
|
109
|
+
const row = await db.get("SELECT * FROM stewards WHERE id = ?", [id]);
|
|
110
|
+
return rowToSteward(row);
|
|
111
|
+
}
|
|
112
|
+
async function updateStewardRole(id, role) {
|
|
113
|
+
return updateSteward(id, { role });
|
|
114
|
+
}
|
|
115
|
+
async function getSteward(id) {
|
|
116
|
+
const db = getDb();
|
|
117
|
+
const row = await db.get("SELECT * FROM stewards WHERE id = ?", [id]);
|
|
118
|
+
return row ? rowToSteward(row) : null;
|
|
119
|
+
}
|
|
120
|
+
async function getStewardsForNest(nestId) {
|
|
121
|
+
const db = getDb();
|
|
122
|
+
const rows = await db.all(
|
|
123
|
+
"SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1",
|
|
124
|
+
[nestId]
|
|
125
|
+
);
|
|
126
|
+
return enrichWithTitles(nestId, rows.map(rowToSteward));
|
|
127
|
+
}
|
|
128
|
+
async function getStewardsForScope(params) {
|
|
129
|
+
const db = getDb();
|
|
130
|
+
let sql = "SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1";
|
|
131
|
+
const args = [params.nestId];
|
|
132
|
+
if (params.scope) {
|
|
133
|
+
sql += " AND scope = ?";
|
|
134
|
+
args.push(params.scope);
|
|
135
|
+
}
|
|
136
|
+
if (params.scopeTarget) {
|
|
137
|
+
sql += " AND (node_pattern = ? OR tag_name = ?)";
|
|
138
|
+
args.push(params.scopeTarget, params.scopeTarget);
|
|
139
|
+
}
|
|
140
|
+
return (await db.all(sql, args)).map(rowToSteward);
|
|
141
|
+
}
|
|
142
|
+
async function listStewards(params) {
|
|
143
|
+
const db = getDb();
|
|
144
|
+
let sql = "SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1";
|
|
145
|
+
const args = [params.nestId];
|
|
146
|
+
if (params.scope) {
|
|
147
|
+
sql += " AND scope = ?";
|
|
148
|
+
args.push(params.scope);
|
|
149
|
+
}
|
|
150
|
+
if (params.search) {
|
|
151
|
+
sql += " AND (user_email LIKE ? OR tag_name LIKE ? OR node_pattern LIKE ?)";
|
|
152
|
+
const like = `%${params.search.toLowerCase()}%`;
|
|
153
|
+
args.push(like, like, like);
|
|
154
|
+
}
|
|
155
|
+
sql += " ORDER BY scope, COALESCE(node_pattern, tag_name, ''), user_email";
|
|
156
|
+
const stewards = (await db.all(sql, args)).map(rowToSteward);
|
|
157
|
+
return enrichWithTitles(params.nestId, stewards);
|
|
158
|
+
}
|
|
159
|
+
async function enrichWithTitles(nestId, stewards) {
|
|
160
|
+
if (!stewards.some((s) => s.scope === "document")) return stewards;
|
|
161
|
+
const titleByNodeId = await buildTitleMap(nestId);
|
|
162
|
+
for (const s of stewards) {
|
|
163
|
+
if (s.scope === "document" && s.nodePattern) {
|
|
164
|
+
s.nodeTitle = titleByNodeId.get(s.nodePattern);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return stewards;
|
|
168
|
+
}
|
|
169
|
+
async function createStewardRecord(params) {
|
|
170
|
+
if (params.users.length === 0) {
|
|
171
|
+
throw new Error("At least one user is required");
|
|
172
|
+
}
|
|
173
|
+
let nodePattern;
|
|
174
|
+
let tagName;
|
|
175
|
+
switch (params.scope) {
|
|
176
|
+
case "document":
|
|
177
|
+
if (!params.documentId) throw new Error("documentId required for document scope");
|
|
178
|
+
nodePattern = params.documentId;
|
|
179
|
+
break;
|
|
180
|
+
case "tag":
|
|
181
|
+
if (!params.tagName) throw new Error("tagName required for tag scope");
|
|
182
|
+
tagName = params.tagName.trim().replace(/^#+/, "").toLowerCase();
|
|
183
|
+
break;
|
|
184
|
+
case "nest":
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
const db = getDb();
|
|
188
|
+
const results = [];
|
|
189
|
+
const actor = (params.assignedBy || "").trim().toLowerCase();
|
|
190
|
+
const ownerEmail = (await getNestOwnerEmail(params.nestId) || "").toLowerCase();
|
|
191
|
+
for (const user of params.users) {
|
|
192
|
+
const email = user.email.trim().toLowerCase();
|
|
193
|
+
if (!email) continue;
|
|
194
|
+
if (!isEmailish(email)) {
|
|
195
|
+
throw new ValidationError(`"${user.email}" is not a valid email address.`);
|
|
196
|
+
}
|
|
197
|
+
if (email === actor) {
|
|
198
|
+
throw new ValidationError(
|
|
199
|
+
"You already manage this nest, so you can't add yourself as a steward."
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (ownerEmail && email === ownerEmail) {
|
|
203
|
+
throw new ValidationError(
|
|
204
|
+
"The nest owner already has full access and doesn't need a steward role."
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const existing = await db.get(
|
|
208
|
+
`SELECT * FROM stewards
|
|
209
|
+
WHERE nest_id = ? AND is_active = 1 AND scope = ? AND user_email = ?
|
|
210
|
+
AND COALESCE(node_pattern, '') = COALESCE(?, '')
|
|
211
|
+
AND COALESCE(tag_name, '') = COALESCE(?, '')`,
|
|
212
|
+
[
|
|
213
|
+
params.nestId,
|
|
214
|
+
params.scope,
|
|
215
|
+
email,
|
|
216
|
+
nodePattern ?? null,
|
|
217
|
+
tagName ?? null
|
|
218
|
+
]
|
|
219
|
+
);
|
|
220
|
+
if (existing) {
|
|
221
|
+
const scopeLabel = params.scope === "document" ? `document "${nodePattern}"` : params.scope === "tag" ? `tag "#${tagName}"` : "this nest";
|
|
222
|
+
throw new ConflictError(
|
|
223
|
+
`"${email}" is already a steward of ${scopeLabel} with the "${existing.role}" role. Remove the existing assignment first to change the role.`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
const userRow = await db.get(
|
|
227
|
+
"SELECT id FROM users WHERE email = ?",
|
|
228
|
+
[email]
|
|
229
|
+
);
|
|
230
|
+
let userId = userRow?.id;
|
|
231
|
+
if (!userId) {
|
|
232
|
+
const { hashPassword } = await import("./keys-73STFJJB.js");
|
|
233
|
+
userId = uuid();
|
|
234
|
+
await db.run(
|
|
235
|
+
"INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
|
|
236
|
+
[userId, email, null, await hashPassword(uuid())]
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const created = await assignSteward({
|
|
240
|
+
nestId: params.nestId,
|
|
241
|
+
scope: params.scope,
|
|
242
|
+
nodePattern,
|
|
243
|
+
tagName,
|
|
244
|
+
userEmail: email,
|
|
245
|
+
userId,
|
|
246
|
+
role: user.role ?? "reviewer",
|
|
247
|
+
assignedBy: params.assignedBy,
|
|
248
|
+
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
249
|
+
isActive: true
|
|
250
|
+
});
|
|
251
|
+
results.push(created);
|
|
252
|
+
}
|
|
253
|
+
await db.run(
|
|
254
|
+
"UPDATE nests SET stewardship_enabled = 1 WHERE id = ? AND stewardship_enabled = 0",
|
|
255
|
+
[params.nestId]
|
|
256
|
+
);
|
|
257
|
+
if (results.length > 0) {
|
|
258
|
+
let scopeLabel;
|
|
259
|
+
if (params.scope === "document") {
|
|
260
|
+
const titles = await buildTitleMap(params.nestId);
|
|
261
|
+
const docTitle = titles.get(nodePattern) || nodePattern;
|
|
262
|
+
const capitalized = docTitle.charAt(0).toUpperCase() + docTitle.slice(1);
|
|
263
|
+
scopeLabel = `"${capitalized}" document`;
|
|
264
|
+
} else if (params.scope === "tag") {
|
|
265
|
+
scopeLabel = `#${tagName}`;
|
|
266
|
+
} else {
|
|
267
|
+
scopeLabel = "the whole nest";
|
|
268
|
+
}
|
|
269
|
+
const shareNestName = await nestName(params.nestId);
|
|
270
|
+
const link = docLink(params.nestId, void 0, params.baseUrl);
|
|
271
|
+
for (const s of results) {
|
|
272
|
+
void sendEmailToRecipient(s.userEmail, {
|
|
273
|
+
status: "steward",
|
|
274
|
+
docTitle: shareNestName,
|
|
275
|
+
path: shareNestName,
|
|
276
|
+
permission: s.role,
|
|
277
|
+
scopeLabel,
|
|
278
|
+
by: params.assignedBy || void 0,
|
|
279
|
+
link
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return results;
|
|
284
|
+
}
|
|
285
|
+
async function resolveStewardsForNode(nestId, nodeId) {
|
|
286
|
+
return (await resolve(nestId, nodeId)).stewards;
|
|
287
|
+
}
|
|
288
|
+
async function resolveStewardsWithFallback(nestId, nodeId) {
|
|
289
|
+
return resolve(nestId, nodeId);
|
|
290
|
+
}
|
|
291
|
+
async function resolve(nestId, nodeId) {
|
|
292
|
+
const db = getDb();
|
|
293
|
+
const rows = await db.all(
|
|
294
|
+
`
|
|
295
|
+
SELECT s.*, 1 AS priority, ('document: ' || s.node_pattern) AS match_source
|
|
296
|
+
FROM stewards s
|
|
297
|
+
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'document'
|
|
298
|
+
AND s.node_pattern = ?
|
|
299
|
+
UNION ALL
|
|
300
|
+
SELECT s.*, 2 AS priority, ('tag: ' || s.tag_name) AS match_source
|
|
301
|
+
FROM stewards s
|
|
302
|
+
JOIN node_tag_index nt
|
|
303
|
+
ON nt.nest_id = s.nest_id
|
|
304
|
+
AND nt.tag_name = s.tag_name
|
|
305
|
+
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'tag'
|
|
306
|
+
AND nt.node_id = ?
|
|
307
|
+
UNION ALL
|
|
308
|
+
SELECT s.*, 3 AS priority, 'nest-level steward' AS match_source
|
|
309
|
+
FROM stewards s
|
|
310
|
+
WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'nest'
|
|
311
|
+
ORDER BY priority ASC, user_email ASC
|
|
312
|
+
`,
|
|
313
|
+
[
|
|
314
|
+
nestId,
|
|
315
|
+
nodeId,
|
|
316
|
+
// document branch
|
|
317
|
+
nestId,
|
|
318
|
+
nodeId,
|
|
319
|
+
// tag branch
|
|
320
|
+
nestId
|
|
321
|
+
// nest branch
|
|
322
|
+
]
|
|
323
|
+
);
|
|
324
|
+
const resolved = rows.map((row) => ({
|
|
325
|
+
steward: rowToSteward(row),
|
|
326
|
+
priority: row.priority,
|
|
327
|
+
source: row.match_source
|
|
328
|
+
}));
|
|
329
|
+
if (resolved.length > 0) {
|
|
330
|
+
return { stewards: resolved, fallbackToOwner: false };
|
|
331
|
+
}
|
|
332
|
+
const owner = await db.get(
|
|
333
|
+
`SELECT u.email FROM nests n
|
|
334
|
+
JOIN users u ON u.id = n.user_id
|
|
335
|
+
WHERE n.id = ?`,
|
|
336
|
+
[nestId]
|
|
337
|
+
);
|
|
338
|
+
return {
|
|
339
|
+
stewards: [],
|
|
340
|
+
fallbackToOwner: true,
|
|
341
|
+
ownerEmail: owner?.email
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
async function userIdForEmail(email) {
|
|
345
|
+
const db = getDb();
|
|
346
|
+
const row = await db.get(
|
|
347
|
+
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
|
|
348
|
+
[email]
|
|
349
|
+
);
|
|
350
|
+
return row?.id;
|
|
351
|
+
}
|
|
352
|
+
async function getStewardRolesForUser(nestId, userEmail) {
|
|
353
|
+
const db = getDb();
|
|
354
|
+
const rows = await db.all(
|
|
355
|
+
"SELECT DISTINCT role FROM stewards WHERE nest_id = ? AND is_active = 1 AND LOWER(user_email) = LOWER(?)",
|
|
356
|
+
[nestId, userEmail]
|
|
357
|
+
);
|
|
358
|
+
return rows.map((r) => r.role);
|
|
359
|
+
}
|
|
360
|
+
async function getStewardsForUser(nestId, userEmail) {
|
|
361
|
+
const db = getDb();
|
|
362
|
+
const rows = await db.all(
|
|
363
|
+
"SELECT * FROM stewards WHERE nest_id = ? AND is_active = 1 AND LOWER(user_email) = LOWER(?)",
|
|
364
|
+
[nestId, userEmail]
|
|
365
|
+
);
|
|
366
|
+
return rows.map(rowToSteward);
|
|
367
|
+
}
|
|
368
|
+
async function getCollaboratorRole(nestId, userEmail) {
|
|
369
|
+
const userId = await userIdForEmail(userEmail);
|
|
370
|
+
if (!userId) return null;
|
|
371
|
+
const db = getDb();
|
|
372
|
+
const row = await db.get(
|
|
373
|
+
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
|
|
374
|
+
[nestId, userId]
|
|
375
|
+
);
|
|
376
|
+
return row?.permission ?? null;
|
|
377
|
+
}
|
|
378
|
+
async function resolveGrantRoles(nestId, userEmail, userId) {
|
|
379
|
+
const [grantRaw, stewardRoles, teamRoles] = await Promise.all([
|
|
380
|
+
getCollaboratorRole(nestId, userEmail),
|
|
381
|
+
getStewardRolesForUser(nestId, userEmail),
|
|
382
|
+
resolveTeamRolesForUser(nestId, userId)
|
|
383
|
+
]);
|
|
384
|
+
return [
|
|
385
|
+
...new Set(
|
|
386
|
+
[collabPermToRole(grantRaw), ...stewardRoles, ...teamRoles].filter(
|
|
387
|
+
Boolean
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
async function resolveUserRoles(nestId, userEmail, opts) {
|
|
393
|
+
const roles = /* @__PURE__ */ new Set();
|
|
394
|
+
if (isSuperAdmin(userEmail)) roles.add("admin");
|
|
395
|
+
const owner = await getNestOwnerEmail(nestId);
|
|
396
|
+
if (owner && owner.toLowerCase() === userEmail.toLowerCase()) {
|
|
397
|
+
roles.add("owner");
|
|
398
|
+
}
|
|
399
|
+
const userId = await userIdForEmail(userEmail);
|
|
400
|
+
if (userId) {
|
|
401
|
+
const collabRole = collabPermToRole(await resolveNestPermission(nestId, userId));
|
|
402
|
+
if (collabRole) roles.add(collabRole);
|
|
403
|
+
for (const role of await resolveTeamRolesForUser(nestId, userId)) {
|
|
404
|
+
roles.add(role);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const stewardRoles = opts?.nodeId ? (await resolveStewardsForNode(nestId, opts.nodeId)).filter(
|
|
408
|
+
(r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase()
|
|
409
|
+
).map((r) => r.steward.role) : await getStewardRolesForUser(nestId, userEmail);
|
|
410
|
+
for (const role of stewardRoles) roles.add(role);
|
|
411
|
+
return [...roles];
|
|
412
|
+
}
|
|
413
|
+
async function canManageStewards(nestId, userId) {
|
|
414
|
+
if (config.AUTH_MODE === "open") return true;
|
|
415
|
+
const perm = await resolveNestPermission(nestId, userId);
|
|
416
|
+
return perm === "owner" || perm === "admin";
|
|
417
|
+
}
|
|
418
|
+
async function canCreateInNest(nestId, userEmail) {
|
|
419
|
+
if (config.AUTH_MODE === "open" || isSuperAdmin(userEmail)) return true;
|
|
420
|
+
const userId = await userIdForEmail(userEmail);
|
|
421
|
+
if (userId) {
|
|
422
|
+
const perm = await resolveNestPermission(nestId, userId);
|
|
423
|
+
if (perm === "owner" || perm === "admin" || perm === "write") return true;
|
|
424
|
+
}
|
|
425
|
+
return (await getStewardsForUser(nestId, userEmail)).some(
|
|
426
|
+
(s) => s.role === "editor" && s.scope === "nest"
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
async function getNestOwnerEmail(nestId) {
|
|
430
|
+
const db = getDb();
|
|
431
|
+
const row = await db.get(
|
|
432
|
+
`SELECT u.email FROM nests n
|
|
433
|
+
JOIN users u ON u.id = n.user_id
|
|
434
|
+
WHERE n.id = ?`,
|
|
435
|
+
[nestId]
|
|
436
|
+
);
|
|
437
|
+
return row?.email ?? null;
|
|
438
|
+
}
|
|
439
|
+
async function canUserEdit(nestId, nodeId, userEmail) {
|
|
440
|
+
const roles = await resolveUserRoles(nestId, userEmail, { nodeId });
|
|
441
|
+
if (roles.includes("owner")) {
|
|
442
|
+
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
443
|
+
}
|
|
444
|
+
if (isSuperAdmin(userEmail)) {
|
|
445
|
+
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
446
|
+
}
|
|
447
|
+
if (canEditWith(roles)) {
|
|
448
|
+
return {
|
|
449
|
+
allowed: true,
|
|
450
|
+
reason: "editor access (collaborator or steward)",
|
|
451
|
+
role: roles.includes("admin") ? "admin" : "editor"
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return { allowed: false, reason: "no editor role on this node", role: null };
|
|
455
|
+
}
|
|
456
|
+
async function getCurrentVersionAuthor(nestId, nodeId) {
|
|
457
|
+
const db = getDb();
|
|
458
|
+
const row = await db.get(
|
|
459
|
+
`SELECT author FROM node_versions
|
|
460
|
+
WHERE nest_id = ? AND node_id = ?
|
|
461
|
+
ORDER BY version DESC LIMIT 1`,
|
|
462
|
+
[nestId, nodeId]
|
|
463
|
+
);
|
|
464
|
+
return row?.author ?? null;
|
|
465
|
+
}
|
|
466
|
+
async function getPendingReviewRequester(nestId, nodeId) {
|
|
467
|
+
const db = getDb();
|
|
468
|
+
const row = await db.get(
|
|
469
|
+
`SELECT requested_by FROM review_requests
|
|
470
|
+
WHERE nest_id = ? AND node_id = ? AND status = 'pending'
|
|
471
|
+
ORDER BY requested_at DESC LIMIT 1`,
|
|
472
|
+
[nestId, nodeId]
|
|
473
|
+
);
|
|
474
|
+
return row?.requested_by ?? null;
|
|
475
|
+
}
|
|
476
|
+
async function canUserApprove(nestId, nodeId, userEmail) {
|
|
477
|
+
const roles = await resolveUserRoles(nestId, userEmail, { nodeId });
|
|
478
|
+
const isOwner = roles.includes("owner");
|
|
479
|
+
const isSuper = isSuperAdmin(userEmail);
|
|
480
|
+
const allowSelf = await nestAllowsSelfApprove(nestId);
|
|
481
|
+
const hasStewardApprove = roles.includes("admin") || roles.includes("reviewer");
|
|
482
|
+
const actor = await getPendingReviewRequester(nestId, nodeId) ?? await getCurrentVersionAuthor(nestId, nodeId);
|
|
483
|
+
const isOwnSubmission = !!actor && actor.toLowerCase() === userEmail.toLowerCase();
|
|
484
|
+
if (hasStewardApprove) {
|
|
485
|
+
if (isOwnSubmission && !((isOwner || isSuper) && allowSelf)) {
|
|
486
|
+
return {
|
|
487
|
+
allowed: false,
|
|
488
|
+
reason: "You submitted this version for review, so you can't approve it yourself. Ask another reviewer to approve it (separation of duties).",
|
|
489
|
+
role: roles.includes("admin") ? "admin" : "reviewer"
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
allowed: true,
|
|
494
|
+
reason: "reviewer access (collaborator or steward)",
|
|
495
|
+
role: roles.includes("admin") ? "admin" : "reviewer"
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (isOwner || isSuper) {
|
|
499
|
+
if (allowSelf) {
|
|
500
|
+
return {
|
|
501
|
+
allowed: true,
|
|
502
|
+
reason: isOwner ? "nest owner (self-approve enabled)" : "super admin",
|
|
503
|
+
role: isOwner ? "owner" : "super_admin"
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
allowed: false,
|
|
508
|
+
reason: "Approvals go to assigned reviewers while self-approve is off. Enable self-approve in nest settings to approve directly (e.g. while seeding), or add a reviewer steward.",
|
|
509
|
+
role: isOwner ? "owner" : "super_admin"
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const held = primaryRole(roles);
|
|
513
|
+
return {
|
|
514
|
+
allowed: false,
|
|
515
|
+
reason: held ? `You're a "${held}" on this document \u2014 only reviewers (or admins) can approve. Ask the nest owner to grant you the reviewer steward role.` : "You're not a steward on this document, so you can't approve it. Ask the nest owner to add you as a reviewer.",
|
|
516
|
+
role: held
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
async function canUserAccess(nestId, nodeId, userEmail) {
|
|
520
|
+
const roles = await resolveUserRoles(nestId, userEmail, { nodeId });
|
|
521
|
+
if (roles.includes("owner")) {
|
|
522
|
+
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
523
|
+
}
|
|
524
|
+
if (isSuperAdmin(userEmail)) {
|
|
525
|
+
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
526
|
+
}
|
|
527
|
+
if (canViewWith(roles)) {
|
|
528
|
+
return {
|
|
529
|
+
allowed: true,
|
|
530
|
+
reason: "collaborator or steward access",
|
|
531
|
+
role: primaryRole(roles)
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
return { allowed: false, reason: "no collaborator or steward access", role: null };
|
|
535
|
+
}
|
|
536
|
+
async function syncFromConfig(nestId, config2) {
|
|
537
|
+
const db = getDb();
|
|
538
|
+
let count = 0;
|
|
539
|
+
await db.run(
|
|
540
|
+
"UPDATE stewards SET is_active = 0 WHERE nest_id = ?",
|
|
541
|
+
[nestId]
|
|
542
|
+
);
|
|
543
|
+
await db.run(
|
|
544
|
+
"UPDATE nests SET stewardship_enabled = 1 WHERE id = ?",
|
|
545
|
+
[nestId]
|
|
546
|
+
);
|
|
547
|
+
const addEntries = async (scope, entries, target) => {
|
|
548
|
+
for (const entry of entries) {
|
|
549
|
+
const user = await db.get(
|
|
550
|
+
"SELECT id FROM users WHERE email = ?",
|
|
551
|
+
[entry.email]
|
|
552
|
+
);
|
|
553
|
+
const rawRole = entry.role || "reviewer";
|
|
554
|
+
const role = rawRole === "admin" ? "reviewer" : rawRole;
|
|
555
|
+
await assignSteward({
|
|
556
|
+
nestId,
|
|
557
|
+
scope,
|
|
558
|
+
nodePattern: target?.nodePattern,
|
|
559
|
+
tagName: target?.tagName ? target.tagName.trim().replace(/^#+/, "").toLowerCase() : void 0,
|
|
560
|
+
userEmail: entry.email.toLowerCase(),
|
|
561
|
+
userId: user?.id,
|
|
562
|
+
role,
|
|
563
|
+
assignedBy: "config",
|
|
564
|
+
assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
565
|
+
isActive: true
|
|
566
|
+
});
|
|
567
|
+
count++;
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
if (config2.nest) {
|
|
571
|
+
await addEntries("nest", config2.nest);
|
|
572
|
+
}
|
|
573
|
+
if (config2.tags) {
|
|
574
|
+
for (const [tagName, entries] of Object.entries(config2.tags)) {
|
|
575
|
+
await addEntries("tag", entries, { tagName });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (config2.documents) {
|
|
579
|
+
for (const [docPattern, entries] of Object.entries(config2.documents)) {
|
|
580
|
+
await addEntries("document", entries, { nodePattern: docPattern });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return count;
|
|
584
|
+
}
|
|
585
|
+
function rowToSteward(row) {
|
|
586
|
+
return {
|
|
587
|
+
id: row.id,
|
|
588
|
+
nestId: row.nest_id,
|
|
589
|
+
scope: row.scope,
|
|
590
|
+
nodePattern: row.node_pattern || void 0,
|
|
591
|
+
tagName: row.tag_name || void 0,
|
|
592
|
+
userEmail: row.user_email,
|
|
593
|
+
userId: row.user_id || void 0,
|
|
594
|
+
role: row.role,
|
|
595
|
+
assignedBy: row.assigned_by,
|
|
596
|
+
assignedAt: row.assigned_at,
|
|
597
|
+
isActive: !!row.is_active
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export {
|
|
602
|
+
assignSteward,
|
|
603
|
+
removeSteward,
|
|
604
|
+
updateSteward,
|
|
605
|
+
updateStewardRole,
|
|
606
|
+
getSteward,
|
|
607
|
+
getStewardsForNest,
|
|
608
|
+
getStewardsForScope,
|
|
609
|
+
listStewards,
|
|
610
|
+
createStewardRecord,
|
|
611
|
+
resolveStewardsForNode,
|
|
612
|
+
resolveStewardsWithFallback,
|
|
613
|
+
getStewardRolesForUser,
|
|
614
|
+
getStewardsForUser,
|
|
615
|
+
getCollaboratorRole,
|
|
616
|
+
resolveGrantRoles,
|
|
617
|
+
resolveUserRoles,
|
|
618
|
+
canManageStewards,
|
|
619
|
+
canCreateInNest,
|
|
620
|
+
canUserEdit,
|
|
621
|
+
canUserApprove,
|
|
622
|
+
canUserAccess,
|
|
623
|
+
syncFromConfig
|
|
624
|
+
};
|