@promptowl/contextnest-community 1.1.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 +3 -2
- package/README.md +33 -0
- package/dist/{chunk-7UTMBL6Z.js → chunk-6AXBB65N.js} +1 -1
- package/dist/{chunk-S2EWN2VA.js → chunk-EDJRDWPL.js} +3 -3
- package/dist/{chunk-WCOUCBDJ.js → chunk-SO74PQWI.js} +137 -28
- package/dist/{chunk-TDAX3JOT.js → chunk-UEHFNBNR.js} +63 -5
- package/dist/index.js +645 -49
- package/dist/{review-service-3OJIPYNV.js → review-service-QU7Q7XX2.js} +4 -4
- package/dist/{stewardship-service-3XGX7QIN.js → stewardship-service-ZUSHJCNR.js} +2 -2
- package/dist/{version-service-UODXLAOJ.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 +13 -1
- package/dist/web3/assets/index-BLxRS7jD.js +0 -673
- package/dist/web3/assets/index-DszK6Vkc.css +0 -1
package/CONFIGURATION.md
CHANGED
|
@@ -49,7 +49,8 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
|
|
|
49
49
|
| `AUTH_MODE` | `key` | `key` or `open`. See above. |
|
|
50
50
|
| `PROMPTOWL_API_URL` | `https://app.promptowl.ai` | PromptOwl's API origin — used for device auth, license validation, telemetry. Override for air-gapped or test setups. |
|
|
51
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` | `$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. |
|
|
53
54
|
| `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
|
|
54
55
|
| `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
|
|
55
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). |
|
|
@@ -99,7 +100,7 @@ allowed_users:
|
|
|
99
100
|
- "*.acme.com" # email wildcard — anyone @acme.com
|
|
100
101
|
- "partner@vendor.com" # exact match
|
|
101
102
|
super_admins:
|
|
102
|
-
- "ceo@acme.com" #
|
|
103
|
+
- "ceo@acme.com" # admin on every nest: visibility, collaborators, stewards (not owner-only delete/transfer)
|
|
103
104
|
groups:
|
|
104
105
|
engineering:
|
|
105
106
|
default_permission: write
|
package/README.md
CHANGED
|
@@ -97,6 +97,10 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
|
|
|
97
97
|
| Per-nest sharing + collaborators | ✅ | ✅ |
|
|
98
98
|
| Public read-only nests | ✅ | ✅ |
|
|
99
99
|
| Custom logo / branding | ✅ | ✅ |
|
|
100
|
+
| Admin password reset + user removal (in-platform) | ✅ | ✅ |
|
|
101
|
+
| Wiki backlinks, outline, hover-preview, link health | ✅ | ✅ |
|
|
102
|
+
| Rich editor — tables, callouts, toggles, code highlight, find/replace | ✅ | ✅ |
|
|
103
|
+
| Steward version revert | ✅ | ✅ |
|
|
100
104
|
| MCP server for AI agents | ✅ | ✅ |
|
|
101
105
|
| Centralized multi-tenant admin console | — | ✅ |
|
|
102
106
|
| SSO / SAML / SCIM | — | ✅ |
|
|
@@ -106,6 +110,35 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
|
|
|
106
110
|
|
|
107
111
|
For Enterprise pricing and features, contact **hoot@promptowl.ai** or visit <https://promptowl.ai/contextnest/>.
|
|
108
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
|
+
|
|
129
|
+
## What's new in 1.2.0
|
|
130
|
+
|
|
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.
|
|
132
|
+
- **`PROMPTOWL_SIGN_IN_GATE`** — `open` / `admin-only` / `disabled` restricts "Sign in with PromptOwl" (admin reaches it via `?admin=1`). See [CONFIGURATION.md](./CONFIGURATION.md).
|
|
133
|
+
- **Plain-text agent view** — `?format=markdown` on a node returns frontmatter + body as `text/markdown` for LLM-friendly consumption.
|
|
134
|
+
- **Steward version revert** — `POST .../revert` restores an earlier version as a new one; pending-review docs are now editable (saving withdraws the review).
|
|
135
|
+
- **Editor — always-editable surface** — Notion-style buttery headline, selection bubble toolbar (format-on-highlight), turn-into block conversion, Cmd+F find & replace, rich blocks (tables, callouts, toggles, syntax-highlighted code, columns), and wiki/code-safety fixes.
|
|
136
|
+
- **Wiki usability** — backlinks panel ("Linked from N documents"), outline/TOC for 3+-heading docs, hover-preview on `[[wiki links]]`, multi-tag AND filtering with clickable chips, per-doc copy link, rename-safety warning for title-form links, link-health report (broken links + orphans), recently-edited strip.
|
|
137
|
+
- **Sharing clarity** — share button reads "Share nest"; dialog clarifies nest-wide scope; deep links open documents directly.
|
|
138
|
+
- **Security** — hardened scanner gate (PolinRider + Shai-Hulud preflight) in `bin/`.
|
|
139
|
+
|
|
140
|
+
Full history in [CHANGELOG.md](./CHANGELOG.md).
|
|
141
|
+
|
|
109
142
|
## What's new in 1.1.0
|
|
110
143
|
|
|
111
144
|
- **Vault import** — import an existing folder of markdown files into a new nest in one step, from the dashboard ("Import folder") or via the API. Frontmatter, wiki links, and folder structure are preserved.
|
|
@@ -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());
|
|
@@ -136,6 +133,12 @@ var ConflictError = class extends AppError {
|
|
|
136
133
|
this.name = "ConflictError";
|
|
137
134
|
}
|
|
138
135
|
};
|
|
136
|
+
var LockedError = class extends AppError {
|
|
137
|
+
constructor(message = "Locked") {
|
|
138
|
+
super(423, message);
|
|
139
|
+
this.name = "LockedError";
|
|
140
|
+
}
|
|
141
|
+
};
|
|
139
142
|
|
|
140
143
|
// src/nodes/engine.ts
|
|
141
144
|
import {
|
|
@@ -186,6 +189,11 @@ function discardImportDir(dest) {
|
|
|
186
189
|
}
|
|
187
190
|
|
|
188
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
|
+
}
|
|
189
197
|
function trackEvent(event, data) {
|
|
190
198
|
if (!config.TELEMETRY_ENABLED) return;
|
|
191
199
|
try {
|
|
@@ -204,31 +212,61 @@ async function flushTelemetry() {
|
|
|
204
212
|
const events = db.prepare(
|
|
205
213
|
"SELECT id, event, data_json, created_at FROM telemetry_events WHERE sent = 0 ORDER BY id LIMIT 100"
|
|
206
214
|
).all();
|
|
207
|
-
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;
|
|
208
230
|
const payload = {
|
|
209
231
|
server_key: config.PROMPTOWL_KEY,
|
|
232
|
+
batch_id: batchId,
|
|
210
233
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
211
234
|
stats: { users: userCount, nests: nestCount },
|
|
212
|
-
events: events.map((e) =>
|
|
213
|
-
|
|
214
|
-
data
|
|
215
|
-
|
|
216
|
-
|
|
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
|
+
})
|
|
217
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));
|
|
218
254
|
try {
|
|
219
|
-
const
|
|
220
|
-
const res = await fetch(`${promptowlUrl}/api/telemetry/ingest`, {
|
|
255
|
+
const res = await fetch(url, {
|
|
221
256
|
method: "POST",
|
|
222
257
|
headers: { "Content-Type": "application/json" },
|
|
223
258
|
body: JSON.stringify(payload)
|
|
224
259
|
});
|
|
260
|
+
console.log(`[telemetry] response: ${res.status} ${res.statusText}`);
|
|
225
261
|
if (res.ok && events.length > 0) {
|
|
226
262
|
const ids = events.map((e) => e.id);
|
|
227
263
|
db.prepare(
|
|
228
264
|
`UPDATE telemetry_events SET sent = 1 WHERE id IN (${ids.map(() => "?").join(",")})`
|
|
229
265
|
).run(...ids);
|
|
266
|
+
console.log(`[telemetry] marked ${ids.length} events as sent`);
|
|
230
267
|
}
|
|
231
|
-
} catch {
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.error("[telemetry] flush failed:", err);
|
|
232
270
|
}
|
|
233
271
|
}
|
|
234
272
|
var telemetryTimer = null;
|
|
@@ -289,14 +327,18 @@ async function installLicenseKey(key) {
|
|
|
289
327
|
if (previousKey) {
|
|
290
328
|
await validateLicense({ forceFresh: true });
|
|
291
329
|
}
|
|
292
|
-
return info;
|
|
330
|
+
return { ...info, persisted: false };
|
|
293
331
|
}
|
|
294
332
|
try {
|
|
295
333
|
upsertEnvVar(config.ENV_FILE_PATH, "PROMPTOWL_KEY", trimmed);
|
|
334
|
+
return { ...info, persisted: true };
|
|
296
335
|
} catch (err) {
|
|
297
|
-
|
|
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 };
|
|
298
341
|
}
|
|
299
|
-
return info;
|
|
300
342
|
}
|
|
301
343
|
var safetyPollHandle = null;
|
|
302
344
|
var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
|
|
@@ -542,6 +584,22 @@ function isImportedNest(nestId) {
|
|
|
542
584
|
function toSlug(name) {
|
|
543
585
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
544
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
|
+
}
|
|
545
603
|
function isStewardshipEnabled(nestId) {
|
|
546
604
|
const db = getDb();
|
|
547
605
|
const row = db.prepare("SELECT stewardship_enabled FROM nests WHERE id = ?").get(nestId);
|
|
@@ -578,6 +636,43 @@ function disableStewardshipAndWipeGovernance(nestId) {
|
|
|
578
636
|
});
|
|
579
637
|
return wipe(nestId);
|
|
580
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
|
+
}
|
|
581
676
|
async function createNest(userId, name, description) {
|
|
582
677
|
const id = uuid();
|
|
583
678
|
const slug = toSlug(name);
|
|
@@ -635,6 +730,13 @@ function listNests(userId) {
|
|
|
635
730
|
}
|
|
636
731
|
function listSharedNests(userId) {
|
|
637
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
|
+
}
|
|
638
740
|
return db.prepare(
|
|
639
741
|
`SELECT DISTINCT n.* FROM nests n
|
|
640
742
|
LEFT JOIN nest_collaborators nc
|
|
@@ -783,6 +885,11 @@ function resolveNestPermission(nestId, userId) {
|
|
|
783
885
|
if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
|
|
784
886
|
return "owner";
|
|
785
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
|
+
}
|
|
786
893
|
const directGrant = db.prepare(
|
|
787
894
|
"SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
788
895
|
).get(nestId, userId);
|
|
@@ -811,6 +918,9 @@ function isPublicReader(nestId, userId) {
|
|
|
811
918
|
"SELECT 1 FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
|
|
812
919
|
).get(nestId, userId);
|
|
813
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;
|
|
814
924
|
const stewardGrant = db.prepare(
|
|
815
925
|
`SELECT 1 FROM stewards s
|
|
816
926
|
JOIN users u ON u.id = ?
|
|
@@ -1147,7 +1257,7 @@ function getCollaboratorRole(nestId, userEmail) {
|
|
|
1147
1257
|
}
|
|
1148
1258
|
function resolveUserRoles(nestId, userEmail, opts) {
|
|
1149
1259
|
const roles = /* @__PURE__ */ new Set();
|
|
1150
|
-
if (
|
|
1260
|
+
if (isSuperAdmin(userEmail)) roles.add("admin");
|
|
1151
1261
|
const owner = getNestOwnerEmail(nestId);
|
|
1152
1262
|
if (owner && owner.toLowerCase() === userEmail.toLowerCase()) {
|
|
1153
1263
|
roles.add("owner");
|
|
@@ -1163,16 +1273,12 @@ function resolveUserRoles(nestId, userEmail, opts) {
|
|
|
1163
1273
|
for (const role of stewardRoles) roles.add(role);
|
|
1164
1274
|
return [...roles];
|
|
1165
1275
|
}
|
|
1166
|
-
function isSuperAdmin2(userEmail) {
|
|
1167
|
-
const cfg = getAccessConfig();
|
|
1168
|
-
return !!cfg?.super_admins?.includes(userEmail);
|
|
1169
|
-
}
|
|
1170
1276
|
function canManageStewards(userEmail) {
|
|
1171
1277
|
if (config.AUTH_MODE === "open") return true;
|
|
1172
|
-
return isLicenseAdminEmail(userEmail) ||
|
|
1278
|
+
return isLicenseAdminEmail(userEmail) || isSuperAdmin(userEmail);
|
|
1173
1279
|
}
|
|
1174
1280
|
function canCreateInNest(nestId, userEmail) {
|
|
1175
|
-
if (config.AUTH_MODE === "open" ||
|
|
1281
|
+
if (config.AUTH_MODE === "open" || isSuperAdmin(userEmail)) return true;
|
|
1176
1282
|
const userId = userIdForEmail(userEmail);
|
|
1177
1283
|
if (userId) {
|
|
1178
1284
|
const perm = resolveNestPermission(nestId, userId);
|
|
@@ -1196,7 +1302,7 @@ function canUserEdit(nestId, nodeId, userEmail) {
|
|
|
1196
1302
|
if (roles.includes("owner")) {
|
|
1197
1303
|
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
1198
1304
|
}
|
|
1199
|
-
if (
|
|
1305
|
+
if (isSuperAdmin(userEmail)) {
|
|
1200
1306
|
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
1201
1307
|
}
|
|
1202
1308
|
if (canEditWith(roles)) {
|
|
@@ -1229,7 +1335,7 @@ function getPendingReviewRequester(nestId, nodeId) {
|
|
|
1229
1335
|
function canUserApprove(nestId, nodeId, userEmail) {
|
|
1230
1336
|
const roles = resolveUserRoles(nestId, userEmail, { nodeId });
|
|
1231
1337
|
const isOwner = roles.includes("owner");
|
|
1232
|
-
const isSuper =
|
|
1338
|
+
const isSuper = isSuperAdmin(userEmail);
|
|
1233
1339
|
const allowSelf = nestAllowsSelfApprove(nestId);
|
|
1234
1340
|
const hasStewardApprove = roles.includes("admin") || roles.includes("reviewer");
|
|
1235
1341
|
const actor = getPendingReviewRequester(nestId, nodeId) ?? getCurrentVersionAuthor(nestId, nodeId);
|
|
@@ -1274,7 +1380,7 @@ function canUserAccess(nestId, nodeId, userEmail) {
|
|
|
1274
1380
|
if (roles.includes("owner")) {
|
|
1275
1381
|
return { allowed: true, reason: "nest owner", role: "owner" };
|
|
1276
1382
|
}
|
|
1277
|
-
if (
|
|
1383
|
+
if (isSuperAdmin(userEmail)) {
|
|
1278
1384
|
return { allowed: true, reason: "super admin", role: "super_admin" };
|
|
1279
1385
|
}
|
|
1280
1386
|
if (canViewWith(roles)) {
|
|
@@ -1352,6 +1458,7 @@ export {
|
|
|
1352
1458
|
ForbiddenError,
|
|
1353
1459
|
ValidationError,
|
|
1354
1460
|
ConflictError,
|
|
1461
|
+
LockedError,
|
|
1355
1462
|
trackEvent,
|
|
1356
1463
|
startTelemetryLoop,
|
|
1357
1464
|
getCurrentLicense,
|
|
@@ -1362,14 +1469,18 @@ export {
|
|
|
1362
1469
|
isSuspended,
|
|
1363
1470
|
getSuspensionReason,
|
|
1364
1471
|
validateLicense,
|
|
1472
|
+
loadAccessConfig,
|
|
1473
|
+
isSuperAdmin,
|
|
1365
1474
|
resolveNestPermission,
|
|
1366
1475
|
permissionLevel,
|
|
1367
1476
|
isPublicReader,
|
|
1477
|
+
uniqueNestName,
|
|
1368
1478
|
isStewardshipEnabled,
|
|
1369
1479
|
setStewardshipEnabled,
|
|
1370
1480
|
nestAllowsSelfApprove,
|
|
1371
1481
|
setAllowSelfApprove,
|
|
1372
1482
|
disableStewardshipAndWipeGovernance,
|
|
1483
|
+
renameNest,
|
|
1373
1484
|
createNest,
|
|
1374
1485
|
importNest,
|
|
1375
1486
|
listNests,
|
|
@@ -1378,8 +1489,6 @@ export {
|
|
|
1378
1489
|
getNest,
|
|
1379
1490
|
deleteNest,
|
|
1380
1491
|
engineCache,
|
|
1381
|
-
loadAccessConfig,
|
|
1382
|
-
isSuperAdmin,
|
|
1383
1492
|
buildTitleMap,
|
|
1384
1493
|
canManageWith,
|
|
1385
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() {
|
|
@@ -39,12 +45,29 @@ var config = {
|
|
|
39
45
|
return process.env.PROMPTOWL_KEY || "";
|
|
40
46
|
},
|
|
41
47
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
48
|
+
* Restrict "Sign in with PromptOwl":
|
|
49
|
+
* "open" — anyone may sign in with PromptOwl (default)
|
|
50
|
+
* "admin-only" — only the license owner (admin) may; everyone else
|
|
51
|
+
* uses email/password. Admin reaches it via the login
|
|
52
|
+
* page's admin route (?admin=1).
|
|
53
|
+
* "disabled" — nobody may sign in with PromptOwl.
|
|
54
|
+
* Enforced server-side at the device entry points (POST /auth/device,
|
|
55
|
+
* GET /auth/device/poll) and the identity point (POST /auth/promptowl).
|
|
56
|
+
*/
|
|
57
|
+
get PROMPTOWL_SIGN_IN_GATE() {
|
|
58
|
+
const v = (process.env.PROMPTOWL_SIGN_IN_GATE || "open").trim().toLowerCase();
|
|
59
|
+
return v === "admin-only" || v === "disabled" ? v : "open";
|
|
60
|
+
},
|
|
61
|
+
/**
|
|
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).
|
|
45
68
|
*/
|
|
46
69
|
get ENV_FILE_PATH() {
|
|
47
|
-
return process.env.ENV_FILE_PATH || join(
|
|
70
|
+
return process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
|
|
48
71
|
},
|
|
49
72
|
get TELEMETRY_ENABLED() {
|
|
50
73
|
return process.env.TELEMETRY_ENABLED !== "false";
|
|
@@ -545,6 +568,41 @@ function runMigrations(db2) {
|
|
|
545
568
|
recordMigration("008_drop_steward_folder_scope");
|
|
546
569
|
})();
|
|
547
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
|
+
}
|
|
548
606
|
}
|
|
549
607
|
|
|
550
608
|
// src/db/client.ts
|