@promptowl/contextnest-community 1.2.0 → 1.3.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 +2 -2
- package/README.md +16 -0
- package/dist/{chunk-LO54V4AU.js → chunk-6AXBB65N.js} +1 -1
- package/dist/{chunk-E7E3JMQR.js → chunk-EDJRDWPL.js} +3 -3
- package/dist/{chunk-5MT4ZBVF.js → chunk-SO74PQWI.js} +130 -28
- package/dist/{chunk-G62P54ET.js → chunk-UEHFNBNR.js} +49 -5
- package/dist/index.js +330 -41
- package/dist/{review-service-GYX3AW6E.js → review-service-QU7Q7XX2.js} +4 -4
- package/dist/{stewardship-service-VOD5HY3I.js → stewardship-service-ZUSHJCNR.js} +2 -2
- package/dist/{version-service-OCZUV2QP.js → version-service-624QTR5K.js} +2 -2
- package/dist/web3/assets/index-DJH4nUEV.js +776 -0
- package/dist/web3/assets/index-uR0ua3Ak.css +1 -0
- package/dist/web3/index.html +2 -2
- package/package.json +1 -1
- package/dist/web3/assets/index-72vKyivD.js +0 -756
- package/dist/web3/assets/index-JmSevkg_.css +0 -1
package/CONFIGURATION.md
CHANGED
|
@@ -50,7 +50,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
|
|
|
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
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
52
|
| `PROMPTOWL_SIGN_IN_GATE` | `open` | Restrict "Sign in with PromptOwl". `open` = anyone may; `admin-only` = only the license owner (admin) may, everyone else uses email/password (admin opens the login page with `?admin=1`); `disabled` = nobody may. Enforced server-side at `POST /auth/promptowl` and surfaced on the health endpoint. Unknown values fall back to `open`. |
|
|
53
|
-
| `ENV_FILE_PATH` | `$
|
|
53
|
+
| `ENV_FILE_PATH` | `$DATA_ROOT/.env` | Path to the `.env` file the license install flow writes `PROMPTOWL_KEY` into (alongside existing vars), and which the server also reads at boot. Defaults **under `DATA_ROOT`** so the browser License Setup Page persists durably in containers — `$cwd` is `/app` in the official image (root-owned, discarded on container recreate), which silently lost the key. Override only if your writable, persisted `.env` lives elsewhere. In containers, providing `PROMPTOWL_KEY` directly via the environment also works and is read at boot. |
|
|
54
54
|
| `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
|
|
55
55
|
| `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
|
|
56
56
|
| `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). |
|
|
@@ -100,7 +100,7 @@ allowed_users:
|
|
|
100
100
|
- "*.acme.com" # email wildcard — anyone @acme.com
|
|
101
101
|
- "partner@vendor.com" # exact match
|
|
102
102
|
super_admins:
|
|
103
|
-
- "ceo@acme.com" #
|
|
103
|
+
- "ceo@acme.com" # admin on every nest: visibility, collaborators, stewards (not owner-only delete/transfer)
|
|
104
104
|
groups:
|
|
105
105
|
engineering:
|
|
106
106
|
default_permission: write
|
package/README.md
CHANGED
|
@@ -110,6 +110,22 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
|
|
|
110
110
|
|
|
111
111
|
For Enterprise pricing and features, contact **hoot@promptowl.ai** or visit <https://promptowl.ai/contextnest/>.
|
|
112
112
|
|
|
113
|
+
## What's new in 1.3.0
|
|
114
|
+
|
|
115
|
+
- **Server super-admins** — emails listed in `access.yaml: super_admins` administer every nest (change visibility, manage collaborators and stewards) without being added per-nest. Owner-only operations (delete, transfer) still require the nest owner. See [STEWARDSHIP.md](./STEWARDSHIP.md).
|
|
116
|
+
- **License persistence under `DATA_ROOT`** — `PROMPTOWL_KEY` now writes to `$DATA_ROOT/.env` (was `$cwd/.env`, which is root-owned `/app` in the official Docker image and silently lost the key on container recreate). The License Setup Page also surfaces write failures instead of failing quietly. Override via `ENV_FILE_PATH`. See [CONFIGURATION.md](./CONFIGURATION.md).
|
|
117
|
+
- **Case-insensitive email** across registration, login, invite, and access checks. Migration dedupes existing accounts that differ only in case; fixes "I can't log in because I capitalized my email" and access-guard / lockout mismatches.
|
|
118
|
+
- **Document version history + inline diff** — every saved version is browsable in the document detail view, and an inline diff highlights what changed between any two versions.
|
|
119
|
+
- **Nest Overview / index landing** — every nest opens on a grouped table-of-contents with a "recently edited" strip instead of a flat list.
|
|
120
|
+
- **Public/private toggle on the nest header** — visibility is set directly from the header, not buried in a dialog.
|
|
121
|
+
- **Review queue surfaces submitted-for-review edits** — reviewers see the pending versions a contributor staged, not just net-new documents.
|
|
122
|
+
- **Internal-folder sync over MCP** — agents can list a nest's unsynced filesystem folders and pull them into the nest on demand. New `unsynced-service.ts` + MCP tools, covered by `test/unsynced-folder.test.ts` and `test/mcp-unsynced.test.ts`.
|
|
123
|
+
- **Telemetry `batch_id` + user email** — usage events carry a stable `batch_id` (de-duped on the receiver) and the authenticated user email alongside the user id, so the PromptOwl ingest bridge can meter credits to the right account.
|
|
124
|
+
- **Hyperlink navigation fix** — hyperlinks in the editor and viewer navigate to their configured target URL correctly.
|
|
125
|
+
- **Repo / DX** — `CLAUDE.md` at the repo root with project-specific guidance for Claude Code; `claude.yml` (PR assistant) and `claude-code-review.yml` (auto review on PRs) GitHub Actions workflows.
|
|
126
|
+
|
|
127
|
+
Full history in [CHANGELOG.md](./CHANGELOG.md).
|
|
128
|
+
|
|
113
129
|
## What's new in 1.2.0
|
|
114
130
|
|
|
115
131
|
- **Admin user management (no email server needed)** — `POST /auth/admin/reset-password/:userId` sets a user's password or returns a one-time temp password; `DELETE /auth/users/:userId` removes a user and revokes their API keys, sessions, and role rows. New self-service **Change password** in the user menu blocks reusing the current password.
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createVersion,
|
|
3
3
|
setApprovedVersion
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-6AXBB65N.js";
|
|
5
5
|
import {
|
|
6
6
|
buildTitleMap,
|
|
7
7
|
canUserApprove,
|
|
8
8
|
engineCache,
|
|
9
9
|
resolveStewardsForNode
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-SO74PQWI.js";
|
|
11
11
|
import {
|
|
12
12
|
getDb
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-UEHFNBNR.js";
|
|
14
14
|
|
|
15
15
|
// src/governance/review-service.ts
|
|
16
16
|
import { v4 as uuid } from "uuid";
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
ANON_USER_ID,
|
|
3
3
|
config,
|
|
4
4
|
getDb
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-UEHFNBNR.js";
|
|
6
6
|
|
|
7
7
|
// src/governance/stewardship-service.ts
|
|
8
8
|
import { v4 as uuid2 } from "uuid";
|
|
@@ -26,9 +26,6 @@ function loadAccessConfig() {
|
|
|
26
26
|
accessConfig = null;
|
|
27
27
|
return null;
|
|
28
28
|
}
|
|
29
|
-
function getAccessConfig() {
|
|
30
|
-
return accessConfig;
|
|
31
|
-
}
|
|
32
29
|
function isSuperAdmin(email) {
|
|
33
30
|
if (!accessConfig?.super_admins) return false;
|
|
34
31
|
return accessConfig.super_admins.map((e) => e.toLowerCase()).includes(email.toLowerCase());
|
|
@@ -192,6 +189,11 @@ function discardImportDir(dest) {
|
|
|
192
189
|
}
|
|
193
190
|
|
|
194
191
|
// src/telemetry/tracker.ts
|
|
192
|
+
import { createHash } from "crypto";
|
|
193
|
+
function computeBatchId(ids) {
|
|
194
|
+
const key = ids.slice().sort((a, b) => a - b).join(",");
|
|
195
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 32);
|
|
196
|
+
}
|
|
195
197
|
function trackEvent(event, data) {
|
|
196
198
|
if (!config.TELEMETRY_ENABLED) return;
|
|
197
199
|
try {
|
|
@@ -210,31 +212,61 @@ async function flushTelemetry() {
|
|
|
210
212
|
const events = db.prepare(
|
|
211
213
|
"SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
|
|
212
214
|
).all();
|
|
213
|
-
if (events.length === 0 && userCount === 0)
|
|
215
|
+
if (events.length === 0 && userCount === 0) {
|
|
216
|
+
console.log("[telemetry] skip flush: no events and no users");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const emailStmt = db.prepare("SELECT email FROM users WHERE id = ?");
|
|
220
|
+
const emailCache = /* @__PURE__ */ new Map();
|
|
221
|
+
const resolveEmail = (userId) => {
|
|
222
|
+
if (typeof userId !== "string" || !userId) return null;
|
|
223
|
+
if (emailCache.has(userId)) return emailCache.get(userId) ?? null;
|
|
224
|
+
const row = emailStmt.get(userId);
|
|
225
|
+
const email = row?.email ?? null;
|
|
226
|
+
emailCache.set(userId, email);
|
|
227
|
+
return email;
|
|
228
|
+
};
|
|
229
|
+
const batchId = events.length > 0 ? computeBatchId(events.map((e) => e.id)) : null;
|
|
214
230
|
const payload = {
|
|
215
231
|
server_key: config.PROMPTOWL_KEY,
|
|
232
|
+
batch_id: batchId,
|
|
216
233
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
217
234
|
stats: { users: userCount, nests: nestCount },
|
|
218
|
-
events: events.map((e) =>
|
|
219
|
-
|
|
220
|
-
data
|
|
221
|
-
|
|
222
|
-
|
|
235
|
+
events: events.map((e) => {
|
|
236
|
+
const data = e.data_json ? JSON.parse(e.data_json) : null;
|
|
237
|
+
if (data && data.userId && !data.userEmail) {
|
|
238
|
+
const email = resolveEmail(data.userId);
|
|
239
|
+
if (email) data.userEmail = email;
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
event: e.event,
|
|
243
|
+
data,
|
|
244
|
+
at: e.created_at
|
|
245
|
+
};
|
|
246
|
+
})
|
|
223
247
|
};
|
|
248
|
+
const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
|
|
249
|
+
const url = `${promptowlUrl}/api/telemetry/ingest`;
|
|
250
|
+
console.log(
|
|
251
|
+
`[telemetry] POST ${url} events=${events.length} users=${userCount} nests=${nestCount}`
|
|
252
|
+
);
|
|
253
|
+
console.log("[telemetry] payload:", JSON.stringify(payload, null, 2));
|
|
224
254
|
try {
|
|
225
|
-
const
|
|
226
|
-
const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
|
|
255
|
+
const res = await fetch(url, {
|
|
227
256
|
method: "POST",
|
|
228
257
|
headers: { "Content-Type": "application/json" },
|
|
229
258
|
body: JSON.stringify(payload)
|
|
230
259
|
});
|
|
260
|
+
console.log(`[telemetry] response: ${res.status} ${res.statusText}`);
|
|
231
261
|
if (res.ok && events.length > 0) {
|
|
232
262
|
const ids = events.map((e) => e.id);
|
|
233
263
|
db.prepare(
|
|
234
264
|
`UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
|
|
235
265
|
).run(...ids);
|
|
266
|
+
console.log(`[telemetry] marked ${ids.length} events as sent`);
|
|
236
267
|
}
|
|
237
|
-
} catch {
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.error("[telemetry] flush failed:", err);
|
|
238
270
|
}
|
|
239
271
|
}
|
|
240
272
|
var telemetryTimer = null;
|
|
@@ -295,14 +327,18 @@ async function installLicenseKey(key) {
|
|
|
295
327
|
if (previousKey) {
|
|
296
328
|
await validateLicense({ forceFresh: true });
|
|
297
329
|
}
|
|
298
|
-
return info;
|
|
330
|
+
return { ...info, persisted: false };
|
|
299
331
|
}
|
|
300
332
|
try {
|
|
301
333
|
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
|
|
334
|
+
return { ...info, persisted: true };
|
|
302
335
|
} catch (err) {
|
|
303
|
-
|
|
336
|
+
const persistError = err instanceof Error ? err.message : String(err);
|
|
337
|
+
console.warn(
|
|
338
|
+
`[license] validated but FAILED to persist to ${config.ENV_FILE_PATH}: ${persistError}`
|
|
339
|
+
);
|
|
340
|
+
return { ...info, persisted: false, persistError };
|
|
304
341
|
}
|
|
305
|
-
return info;
|
|
306
342
|
}
|
|
307
343
|
var safetyPollHandle = null;
|
|
308
344
|
var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
|
|
@@ -548,6 +584,22 @@ function isImportedNest(nestId) {
|
|
|
548
584
|
function toSlug(name) {
|
|
549
585
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
550
586
|
}
|
|
587
|
+
function uniqueNestName(userId, baseName) {
|
|
588
|
+
const db = getDb();
|
|
589
|
+
const slugTaken = db.prepare(
|
|
590
|
+
"SELECT 1 FROM nests WHERE user_id = ? AND slug = ? LIMIT 1"
|
|
591
|
+
);
|
|
592
|
+
let candidate = baseName;
|
|
593
|
+
let n = 0;
|
|
594
|
+
while (n < 1e3) {
|
|
595
|
+
const slug = toSlug(candidate);
|
|
596
|
+
const hit = slugTaken.get(userId, slug);
|
|
597
|
+
if (!hit) return candidate;
|
|
598
|
+
n++;
|
|
599
|
+
candidate = `${baseName} (${n})`;
|
|
600
|
+
}
|
|
601
|
+
return candidate;
|
|
602
|
+
}
|
|
551
603
|
function isStewardshipEnabled(nestId) {
|
|
552
604
|
const db = getDb();
|
|
553
605
|
const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
|
|
@@ -584,6 +636,43 @@ function disableStewardshipAndWipeGovernance(nestId) {
|
|
|
584
636
|
});
|
|
585
637
|
return wipe(nestId);
|
|
586
638
|
}
|
|
639
|
+
function renameNest(nestId, patch) {
|
|
640
|
+
const db = getDb();
|
|
641
|
+
const existing = db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId);
|
|
642
|
+
if (!existing) {
|
|
643
|
+
throw new ValidationError("Nest not found");
|
|
644
|
+
}
|
|
645
|
+
const updates = [];
|
|
646
|
+
const params = [];
|
|
647
|
+
if (patch.name !== void 0) {
|
|
648
|
+
const trimmed = patch.name.trim();
|
|
649
|
+
if (!trimmed) throw new ValidationError("Name cannot be empty");
|
|
650
|
+
const slug = toSlug(trimmed);
|
|
651
|
+
if (!slug) throw new ValidationError("Name must contain at least one alphanumeric character");
|
|
652
|
+
const conflict = db.prepare(
|
|
653
|
+
"SELECT id FROM nests WHERE user_id = ? AND slug = ? AND id != ? LIMIT 1"
|
|
654
|
+
).get(existing.user_id, slug, nestId);
|
|
655
|
+
if (conflict) {
|
|
656
|
+
throw new ValidationError(
|
|
657
|
+
`Another nest already uses this name. Pick a different one.`
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
updates.push("name = ?", "slug = ?");
|
|
661
|
+
params.push(trimmed, slug);
|
|
662
|
+
}
|
|
663
|
+
if (patch.description !== void 0) {
|
|
664
|
+
updates.push("description = ?");
|
|
665
|
+
params.push(patch.description || null);
|
|
666
|
+
}
|
|
667
|
+
if (updates.length === 0) {
|
|
668
|
+
return existing;
|
|
669
|
+
}
|
|
670
|
+
params.push(nestId);
|
|
671
|
+
db.prepare(`UPDATE nests SET ${updates.join(", ")} WHERE id = ?`).run(
|
|
672
|
+
...params
|
|
673
|
+
);
|
|
674
|
+
return db.prepare("SELECT * FROM nests WHERE id = ?").get(nestId);
|
|
675
|
+
}
|
|
587
676
|
async function createNest(userId, name, description) {
|
|
588
677
|
const id = uuid();
|
|
589
678
|
const slug = toSlug(name);
|
|
@@ -641,6 +730,13 @@ function listNests(userId) {
|
|
|
641
730
|
}
|
|
642
731
|
function listSharedNests(userId) {
|
|
643
732
|
const db = getDb();
|
|
733
|
+
const caller = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
734
|
+
const isServerAdmin = isLicenseAdminUserId(userId) || (caller?.email ? isSuperAdmin(caller.email) : false);
|
|
735
|
+
if (isServerAdmin) {
|
|
736
|
+
return db.prepare(
|
|
737
|
+
"SELECT * FROM nests WHERE user_id != ? ORDER BY created_at DESC"
|
|
738
|
+
).all(userId);
|
|
739
|
+
}
|
|
644
740
|
return db.prepare(
|
|
645
741
|
`SELECT DISTINCT n.* FROM nests n
|
|
646
742
|
LEFT JOIN nest_collaborators nc
|
|
@@ -789,6 +885,11 @@ function resolveNestPermission(nestId, userId) {
|
|
|
789
885
|
if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
|
|
790
886
|
return "owner";
|
|
791
887
|
}
|
|
888
|
+
if (isLicenseAdminUserId(userId)) return "admin";
|
|
889
|
+
const caller = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
890
|
+
if (caller?.email && isSuperAdmin(caller.email)) {
|
|
891
|
+
return "admin";
|
|
892
|
+
}
|
|
792
893
|
const directGrant = db.prepare(
|
|
793
894
|
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
794
895
|
).get(nestId, userId);
|
|
@@ -817,6 +918,9 @@ function isPublicReader(nestId, userId) {
|
|
|
817
918
|
"SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
818
919
|
).get(nestId, userId);
|
|
819
920
|
if (directGrant) return false;
|
|
921
|
+
if (isLicenseAdminUserId(userId)) return false;
|
|
922
|
+
const caller = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
|
|
923
|
+
if (caller?.email && isSuperAdmin(caller.email)) return false;
|
|
820
924
|
const stewardGrant = db.prepare(
|
|
821
925
|
`SELECT 1 FROM stewards s
|
|
822
926
|
JOIN users u ON u.id = ?
|
|
@@ -1153,7 +1257,7 @@ function getCollaboratorRole(nestId, userEmail) {
|
|
|
1153
1257
|
}
|
|
1154
1258
|
function resolveUserRoles(nestId, userEmail, opts) {
|
|
1155
1259
|
const roles = /* @__PURE__ */ new Set();
|
|
1156
|
-
if (
|
|
1260
|
+
if (isSuperAdmin(userEmail)) roles.add("admin");
|
|
1157
1261
|
const owner = getNestOwnerEmail(nestId);
|
|
1158
1262
|
if (owner && owner.toLowerCase() === userEmail.toLowerCase()) {
|
|
1159
1263
|
roles.add("owner");
|
|
@@ -1169,16 +1273,12 @@ function resolveUserRoles(nestId, userEmail, opts) {
|
|
|
1169
1273
|
for (const role of stewardRoles) roles.add(role);
|
|
1170
1274
|
return [...roles];
|
|
1171
1275
|
}
|
|
1172
|
-
function isSuperAdmin2(userEmail) {
|
|
1173
|
-
const cfg = getAccessConfig();
|
|
1174
|
-
return !!cfg?.super_admins?.includes(userEmail);
|
|
1175
|
-
}
|
|
1176
1276
|
function canManageStewards(userEmail) {
|
|
1177
1277
|
if (config.AUTH_MODE === "open") return true;
|
|
1178
|
-
return isLicenseAdminEmail(userEmail) ||
|
|
1278
|
+
return isLicenseAdminEmail(userEmail) || isSuperAdmin(userEmail);
|
|
1179
1279
|
}
|
|
1180
1280
|
function canCreateInNest(nestId, userEmail) {
|
|
1181
|
-
if (config.AUTH_MODE === "open" ||
|
|
1281
|
+
if (config.AUTH_MODE === "open" || isSuperAdmin(userEmail)) return true;
|
|
1182
1282
|
const userId = userIdForEmail(userEmail);
|
|
1183
1283
|
if (userId) {
|
|
1184
1284
|
const perm = resolveNestPermission(nestId, userId);
|
|
@@ -1202,7 +1302,7 @@ function canUserEdit(nestId, nodeId, userEmail) {
|
|
|
1202
1302
|
if (roles.includes("owner")) {
|
|
1203
1303
|
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
1204
1304
|
}
|
|
1205
|
-
if (
|
|
1305
|
+
if (isSuperAdmin(userEmail)) {
|
|
1206
1306
|
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
1207
1307
|
}
|
|
1208
1308
|
if (canEditWith(roles)) {
|
|
@@ -1235,7 +1335,7 @@ function getPendingReviewRequester(nestId, nodeId) {
|
|
|
1235
1335
|
function canUserApprove(nestId, nodeId, userEmail) {
|
|
1236
1336
|
const roles = resolveUserRoles(nestId, userEmail, { nodeId });
|
|
1237
1337
|
const isOwner = roles.includes("owner");
|
|
1238
|
-
const isSuper =
|
|
1338
|
+
const isSuper = isSuperAdmin(userEmail);
|
|
1239
1339
|
const allowSelf = nestAllowsSelfApprove(nestId);
|
|
1240
1340
|
const hasStewardApprove = roles.includes("admin") || roles.includes("reviewer");
|
|
1241
1341
|
const actor = getPendingReviewRequester(nestId, nodeId) ?? getCurrentVersionAuthor(nestId, nodeId);
|
|
@@ -1280,7 +1380,7 @@ function canUserAccess(nestId, nodeId, userEmail) {
|
|
|
1280
1380
|
if (roles.includes("owner")) {
|
|
1281
1381
|
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
1282
1382
|
}
|
|
1283
|
-
if (
|
|
1383
|
+
if (isSuperAdmin(userEmail)) {
|
|
1284
1384
|
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
1285
1385
|
}
|
|
1286
1386
|
if (canViewWith(roles)) {
|
|
@@ -1369,14 +1469,18 @@ export {
|
|
|
1369
1469
|
isSuspended,
|
|
1370
1470
|
getSuspensionReason,
|
|
1371
1471
|
validateLicense,
|
|
1472
|
+
loadAccessConfig,
|
|
1473
|
+
isSuperAdmin,
|
|
1372
1474
|
resolveNestPermission,
|
|
1373
1475
|
permissionLevel,
|
|
1374
1476
|
isPublicReader,
|
|
1477
|
+
uniqueNestName,
|
|
1375
1478
|
isStewardshipEnabled,
|
|
1376
1479
|
setStewardshipEnabled,
|
|
1377
1480
|
nestAllowsSelfApprove,
|
|
1378
1481
|
setAllowSelfApprove,
|
|
1379
1482
|
disableStewardshipAndWipeGovernance,
|
|
1483
|
+
renameNest,
|
|
1380
1484
|
createNest,
|
|
1381
1485
|
importNest,
|
|
1382
1486
|
listNests,
|
|
@@ -1385,8 +1489,6 @@ export {
|
|
|
1385
1489
|
getNest,
|
|
1386
1490
|
deleteNest,
|
|
1387
1491
|
engineCache,
|
|
1388
|
-
loadAccessConfig,
|
|
1389
|
-
isSuperAdmin,
|
|
1390
1492
|
buildTitleMap,
|
|
1391
1493
|
canManageWith,
|
|
1392
1494
|
assignSteward,
|
|
@@ -14,9 +14,15 @@ var isTestRun = !!process.env.VITEST;
|
|
|
14
14
|
if (envFileLoaded && !isTestRun) {
|
|
15
15
|
dotenv.config({ path: envFileLoaded, override: true });
|
|
16
16
|
}
|
|
17
|
+
var canonicalEnvFile = process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
|
|
18
|
+
var canonicalEnvLoaded = null;
|
|
19
|
+
if (!isTestRun && canonicalEnvFile !== envFileLoaded && existsSync(canonicalEnvFile)) {
|
|
20
|
+
dotenv.config({ path: canonicalEnvFile, override: true });
|
|
21
|
+
canonicalEnvLoaded = canonicalEnvFile;
|
|
22
|
+
}
|
|
17
23
|
if (!isTestRun) {
|
|
18
24
|
console.log(
|
|
19
|
-
`[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}`
|
|
25
|
+
`[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}` + (canonicalEnvLoaded ? ` + ${canonicalEnvLoaded}` : "")
|
|
20
26
|
);
|
|
21
27
|
}
|
|
22
28
|
function dataRoot() {
|
|
@@ -53,12 +59,15 @@ var config = {
|
|
|
53
59
|
return v === "admin-only" || v === "disabled" ? v : "open";
|
|
54
60
|
},
|
|
55
61
|
/**
|
|
56
|
-
* Path to the .env file the server reads its config from
|
|
57
|
-
*
|
|
58
|
-
*
|
|
62
|
+
* Path to the .env file the server reads its config from and the license
|
|
63
|
+
* install flow persists PROMPTOWL_KEY into. Defaults UNDER DATA_ROOT (not
|
|
64
|
+
* $cwd) so it lands on the writable, volume-mounted, restart-surviving path
|
|
65
|
+
* in containers — $cwd is /app in the official image: root-owned and
|
|
66
|
+
* discarded on container recreate, which silently lost the key. The boot
|
|
67
|
+
* dotenv loader reads this same path (see top of file).
|
|
59
68
|
*/
|
|
60
69
|
get ENV_FILE_PATH() {
|
|
61
|
-
return process.env.ENV_FILE_PATH || join(
|
|
70
|
+
return process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
|
|
62
71
|
},
|
|
63
72
|
get TELEMETRY_ENABLED() {
|
|
64
73
|
return process.env.TELEMETRY_ENABLED !== "false";
|
|
@@ -559,6 +568,41 @@ function runMigrations(db2) {
|
|
|
559
568
|
recordMigration("008_drop_steward_folder_scope");
|
|
560
569
|
})();
|
|
561
570
|
}
|
|
571
|
+
if (!hasMigration("009_lowercase_emails")) {
|
|
572
|
+
db2.transaction(() => {
|
|
573
|
+
const collisions = db2.prepare(
|
|
574
|
+
`SELECT GROUP_CONCAT(email, ', ') AS emails
|
|
575
|
+
FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1`
|
|
576
|
+
).all();
|
|
577
|
+
for (const c of collisions) {
|
|
578
|
+
console.warn(
|
|
579
|
+
`[migration 009] case-collision user rows NOT auto-merged: ${c.emails} \u2014 reconcile manually (pick the row the person logs into; reset its password; ensure collaborator/steward grants point at that user_id).`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
db2.exec(
|
|
583
|
+
`UPDATE users SET email = LOWER(email)
|
|
584
|
+
WHERE email <> LOWER(email)
|
|
585
|
+
AND LOWER(email) NOT IN (
|
|
586
|
+
SELECT LOWER(email) FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1
|
|
587
|
+
)`
|
|
588
|
+
);
|
|
589
|
+
const remaining = db2.prepare(
|
|
590
|
+
`SELECT COUNT(*) AS c FROM (
|
|
591
|
+
SELECT 1 FROM users GROUP BY LOWER(email) HAVING COUNT(*) > 1
|
|
592
|
+
)`
|
|
593
|
+
).get().c;
|
|
594
|
+
if (remaining === 0) {
|
|
595
|
+
db2.exec(
|
|
596
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nocase ON users(email COLLATE NOCASE)"
|
|
597
|
+
);
|
|
598
|
+
} else {
|
|
599
|
+
console.warn(
|
|
600
|
+
`[migration 009] ${remaining} case-collision group(s) remain \u2014 skipping the NOCASE unique index until reconciled.`
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
})();
|
|
604
|
+
recordMigration("009_lowercase_emails");
|
|
605
|
+
}
|
|
562
606
|
}
|
|
563
607
|
|
|
564
608
|
// src/db/client.ts
|