@promptowl/contextnest-community 0.1.0-alpha.2 → 1.0.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 CHANGED
@@ -1,118 +1,118 @@
1
- # Configuration
2
-
3
- All server configuration is driven by environment variables. Set them in your shell, in a `.env` file, in a Docker image, or via your process manager — however you deploy the rest of your stack.
4
-
5
- ## Auth modes
6
-
7
- The server supports two authentication modes. You pick one per instance with `AUTH_MODE`.
8
-
9
- ### `AUTH_MODE=key` (default)
10
-
11
- Every request requires an `Authorization: Bearer cnst_...` header. Use this for any multi-user or internet-facing deployment.
12
-
13
- - Users register via `POST /auth/register` (email + password) or log in with PromptOwl via `POST /auth/device` → `POST /auth/promptowl`.
14
- - API keys are per-user (`cnst_<64-hex>`) and can be scoped to a single nest for service-account use.
15
- - Rate-limited login / register / device-auth endpoints (sliding window, per IP + per email).
16
- - First PromptOwl-authenticated user becomes the server admin (atomic claim). Admin can invite teammates at `POST /auth/invite`.
17
-
18
- Clients include the token on every request:
19
-
20
- ```bash
21
- curl -H 'Authorization: Bearer cnst_<your-token>' http://your-server/nests
22
- ```
23
-
24
- ### `AUTH_MODE=open`
25
-
26
- No authentication required. Every request is attributed to the anonymous admin user (`00000000-...`). Everyone effectively owns every anon-created nest.
27
-
28
- **Use this only when:**
29
- - You're running locally for yourself (`bind 127.0.0.1`), or
30
- - You're on a trusted LAN and don't care about access controls, or
31
- - You're behind an upstream reverse proxy that already authenticates.
32
-
33
- **Don't use this when:**
34
- - The port is reachable from the public internet
35
- - Multiple people share the deployment and need isolated data
36
- - You need audit trails (every write shows up under "anonymous admin")
37
-
38
- The server prints a loud warning at startup when `AUTH_MODE=open` is active.
39
-
40
- ---
41
-
42
- ## Full env var reference
43
-
44
- | Var | Default | Purpose |
45
- |---|---|---|
46
- | `PORT` | `3838` | HTTP port the server listens on. |
47
- | `DATA_ROOT` | `./data` (relative to cwd) | Root directory for SQLite DB + nest filesystem vaults. Set to an absolute path in production so it's independent of where you `cd`'d from. |
48
- | `DATABASE_PATH` | `$DATA_ROOT/community.db` | Override the SQLite file location explicitly. |
49
- | `AUTH_MODE` | `key` | `key` or `open`. See above. |
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
- | `PROMPTOWL_KEY` | `""` | Your PromptOwl Community Server license key (`pk_...`). Unlicensed instances still run but some features may be limited. |
52
- | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
53
- | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
54
- | `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). |
55
- | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. |
56
-
57
- ---
58
-
59
- ## Typical deployments
60
-
61
- ### Local dev / single user
62
-
63
- ```bash
64
- AUTH_MODE=open DATA_ROOT=./my-data pnpm dev
65
- ```
66
-
67
- Or the default dev mode if `pnpm dev` already sets `AUTH_MODE=open` in your scripts.
68
-
69
- ### Team / multi-user behind a reverse proxy
70
-
71
- ```bash
72
- AUTH_MODE=key \
73
- CORS_ORIGINS="https://team.example.com,https://admin.example.com" \
74
- DATA_ROOT=/var/lib/contextnest \
75
- PROMPTOWL_KEY=pk_... \
76
- pnpm start
77
- ```
78
-
79
- Terminate TLS at the proxy, forward `X-Forwarded-For` and `X-Real-IP` headers (the rate limiter reads them), and bind the server to `127.0.0.1` so only the proxy can reach it.
80
-
81
- ### Hosted / commercial SaaS
82
-
83
- Same as team, plus:
84
- - Run N instances behind a load balancer sharing a network-attached `DATA_ROOT` (note: SQLite + shared filesystem is not a great long-term story — migrate to the MongoDB storage adapter when scaling beyond one box).
85
- - Set `TELEMETRY_ENABLED=true` and a valid `PROMPTOWL_KEY` so usage rolls up to PromptOwl.
86
- - Rotate keys regularly via `DELETE /auth/keys/:id` + `POST /auth/keys`.
87
-
88
- ---
89
-
90
- ## access.yaml (optional, open + key mode)
91
-
92
- Drop an `access.yaml` at `$DATA_ROOT/access.yaml` to add a server-level ABAC layer — useful even in key mode for super-admins and group-based defaults.
93
-
94
- ```yaml
95
- mode: restricted
96
- allowed_users:
97
- - "*.acme.com" # email wildcard — anyone @acme.com
98
- - "partner@vendor.com" # exact match
99
- super_admins:
100
- - "ceo@acme.com" # always allowed on every nest
101
- groups:
102
- engineering:
103
- default_permission: write
104
- members:
105
- - "*.eng.acme.com"
106
- viewers:
107
- default_permission: read
108
- members:
109
- - "*.contractor.acme.com"
110
- ```
111
-
112
- See `STEWARDSHIP.md` for how super-admins and groups interact with per-nest stewardship.
113
-
114
- ---
115
-
116
- ## Reload
117
-
118
- Changing env vars requires a restart — the server reads them at boot. `tsx watch` in dev will pick up code changes automatically but not env changes.
1
+ # Configuration
2
+
3
+ All server configuration is driven by environment variables. Set them in your shell, in a `.env` file, in a Docker image, or via your process manager — however you deploy the rest of your stack.
4
+
5
+ ## Auth modes
6
+
7
+ The server supports two authentication modes. You pick one per instance with `AUTH_MODE`.
8
+
9
+ ### `AUTH_MODE=key` (default)
10
+
11
+ Every request requires an `Authorization: Bearer cnst_...` header. Use this for any multi-user or internet-facing deployment.
12
+
13
+ - Users register via `POST /auth/register` (email + password) or log in with PromptOwl via `POST /auth/device` → `POST /auth/promptowl`.
14
+ - API keys are per-user (`cnst_<64-hex>`) and can be scoped to a single nest for service-account use.
15
+ - Rate-limited login / register / device-auth endpoints (sliding window, per IP + per email).
16
+ - First PromptOwl-authenticated user becomes the server admin (atomic claim). Admin can invite teammates at `POST /auth/invite`.
17
+
18
+ Clients include the token on every request:
19
+
20
+ ```bash
21
+ curl -H 'Authorization: Bearer cnst_<your-token>' http://your-server/nests
22
+ ```
23
+
24
+ ### `AUTH_MODE=open`
25
+
26
+ No authentication required. Every request is attributed to the anonymous admin user (`00000000-...`). Everyone effectively owns every anon-created nest.
27
+
28
+ **Use this only when:**
29
+ - You're running locally for yourself (`bind 127.0.0.1`), or
30
+ - You're on a trusted LAN and don't care about access controls, or
31
+ - You're behind an upstream reverse proxy that already authenticates.
32
+
33
+ **Don't use this when:**
34
+ - The port is reachable from the public internet
35
+ - Multiple people share the deployment and need isolated data
36
+ - You need audit trails (every write shows up under "anonymous admin")
37
+
38
+ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
39
+
40
+ ---
41
+
42
+ ## Full env var reference
43
+
44
+ | Var | Default | Purpose |
45
+ |---|---|---|
46
+ | `PORT` | `3838` | HTTP port the server listens on. |
47
+ | `DATA_ROOT` | `./data` (relative to cwd) | Root directory for SQLite DB + nest filesystem vaults. Set to an absolute path in production so it's independent of where you `cd`'d from. |
48
+ | `DATABASE_PATH` | `$DATA_ROOT/community.db` | Override the SQLite file location explicitly. |
49
+ | `AUTH_MODE` | `key` | `key` or `open`. See above. |
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
+ | `PROMPTOWL_KEY` | `""` | Your PromptOwl Community Server license key (`pk_...`). Unlicensed instances still run but some features may be limited. |
52
+ | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
53
+ | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
54
+ | `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). |
55
+ | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. |
56
+
57
+ ---
58
+
59
+ ## Typical deployments
60
+
61
+ ### Local dev / single user
62
+
63
+ ```bash
64
+ AUTH_MODE=open DATA_ROOT=./my-data pnpm dev
65
+ ```
66
+
67
+ Or the default dev mode if `pnpm dev` already sets `AUTH_MODE=open` in your scripts.
68
+
69
+ ### Team / multi-user behind a reverse proxy
70
+
71
+ ```bash
72
+ AUTH_MODE=key \
73
+ CORS_ORIGINS="https://team.example.com,https://admin.example.com" \
74
+ DATA_ROOT=/var/lib/contextnest \
75
+ PROMPTOWL_KEY=pk_... \
76
+ pnpm start
77
+ ```
78
+
79
+ Terminate TLS at the proxy, forward `X-Forwarded-For` and `X-Real-IP` headers (the rate limiter reads them), and bind the server to `127.0.0.1` so only the proxy can reach it.
80
+
81
+ ### Hosted / commercial SaaS
82
+
83
+ Same as team, plus:
84
+ - Run N instances behind a load balancer sharing a network-attached `DATA_ROOT` (note: SQLite + shared filesystem is not a great long-term story — migrate to the MongoDB storage adapter when scaling beyond one box).
85
+ - Set `TELEMETRY_ENABLED=true` and a valid `PROMPTOWL_KEY` so usage rolls up to PromptOwl.
86
+ - Rotate keys regularly via `DELETE /auth/keys/:id` + `POST /auth/keys`.
87
+
88
+ ---
89
+
90
+ ## access.yaml (optional, open + key mode)
91
+
92
+ Drop an `access.yaml` at `$DATA_ROOT/access.yaml` to add a server-level ABAC layer — useful even in key mode for super-admins and group-based defaults.
93
+
94
+ ```yaml
95
+ mode: restricted
96
+ allowed_users:
97
+ - "*.acme.com" # email wildcard — anyone @acme.com
98
+ - "partner@vendor.com" # exact match
99
+ super_admins:
100
+ - "ceo@acme.com" # always allowed on every nest
101
+ groups:
102
+ engineering:
103
+ default_permission: write
104
+ members:
105
+ - "*.eng.acme.com"
106
+ viewers:
107
+ default_permission: read
108
+ members:
109
+ - "*.contractor.acme.com"
110
+ ```
111
+
112
+ See `STEWARDSHIP.md` for how super-admins and groups interact with per-nest stewardship.
113
+
114
+ ---
115
+
116
+ ## Reload
117
+
118
+ Changing env vars requires a restart — the server reads them at boot. `tsx watch` in dev will pick up code changes automatically but not env changes.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  config,
3
3
  getDb
4
- } from "./chunk-USIDOGVJ.js";
4
+ } from "./chunk-2TW25QEA.js";
5
5
 
6
6
  // src/governance/stewardship-service.ts
7
7
  import { v4 as uuid } from "uuid";
@@ -170,7 +170,34 @@ function listStewards(params) {
170
170
  sql += " ORDER BY scope, COALESCE(node_pattern, tag_name, ''), user_email";
171
171
  return db.prepare(sql).all(...args).map(rowToSteward);
172
172
  }
173
- function createStewardRecord(params) {
173
+ function rolePermission(role) {
174
+ return role === "editor" ? "write" : "read";
175
+ }
176
+ async function ensureCollaborator(nestId, email, permission, grantedBy) {
177
+ const db = getDb();
178
+ let userRow = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
179
+ if (!userRow) {
180
+ const { hashPassword } = await import("./keys-YV33AJK3.js");
181
+ const newId = uuid();
182
+ db.prepare(
183
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
184
+ ).run(newId, email, null, await hashPassword(uuid()));
185
+ userRow = { id: newId };
186
+ }
187
+ const nestRow = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
188
+ if (nestRow && nestRow.user_id === userRow.id) return;
189
+ const existing = db.prepare(
190
+ "SELECT id FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
191
+ ).get(nestId, userRow.id);
192
+ if (existing) return;
193
+ const granterRow = db.prepare("SELECT id FROM users WHERE email = ?").get(grantedBy);
194
+ const granterId = granterRow?.id ?? nestRow?.user_id;
195
+ if (!granterId) return;
196
+ db.prepare(
197
+ "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
198
+ ).run(uuid(), nestId, userRow.id, permission, granterId);
199
+ }
200
+ async function createStewardRecord(params) {
174
201
  if (params.users.length === 0) {
175
202
  throw new Error("At least one user is required");
176
203
  }
@@ -229,6 +256,12 @@ function createStewardRecord(params) {
229
256
  isActive: true
230
257
  });
231
258
  results.push(created);
259
+ await ensureCollaborator(
260
+ params.nestId,
261
+ email,
262
+ rolePermission(user.role),
263
+ params.assignedBy
264
+ );
232
265
  }
233
266
  db.prepare(
234
267
  "UPDATE nests SET stewardship_enabled = 1 WHERE id = ? AND stewardship_enabled = 0"
@@ -255,7 +288,10 @@ function resolve(nestId, nodeId) {
255
288
  WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'folder'
256
289
  AND s.node_pattern IS NOT NULL
257
290
  AND instr(s.node_pattern, '*') = 0
258
- AND ? LIKE s.node_pattern || '/%'
291
+ AND (
292
+ ? LIKE s.node_pattern || '/%'
293
+ OR s.node_pattern = ?
294
+ )
259
295
  UNION ALL
260
296
  SELECT s.*, 3 AS priority, ('tag: ' || s.tag_name) AS match_source
261
297
  FROM stewards s
@@ -270,7 +306,20 @@ function resolve(nestId, nodeId) {
270
306
  WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'nest'
271
307
  ORDER BY priority ASC, user_email ASC
272
308
  `
273
- ).all(nestId, nodeId, nestId, nodeId, nestId, nodeId, nestId);
309
+ ).all(
310
+ nestId,
311
+ nodeId,
312
+ // document branch
313
+ nestId,
314
+ nodeId,
315
+ nestId,
316
+ // folder branch (path-prefix OR whole-nest folder)
317
+ nestId,
318
+ nodeId,
319
+ // tag branch
320
+ nestId
321
+ // nest branch
322
+ );
274
323
  const resolved = rows.map((row) => ({
275
324
  steward: rowToSteward(row),
276
325
  priority: row.priority,
@@ -1,5 +1,21 @@
1
1
  // src/config.ts
2
- import { join } from "path";
2
+ import { join, dirname } from "path";
3
+ import { existsSync } from "fs";
4
+ import { fileURLToPath } from "url";
5
+ import dotenv from "dotenv";
6
+ var __filename = fileURLToPath(import.meta.url);
7
+ var __dirname = dirname(__filename);
8
+ var envCandidates = [
9
+ join(process.cwd(), ".env"),
10
+ join(__dirname, "..", ".env")
11
+ ];
12
+ var envFileLoaded = envCandidates.find((p) => existsSync(p)) || null;
13
+ if (envFileLoaded) {
14
+ dotenv.config({ path: envFileLoaded, override: true });
15
+ }
16
+ console.log(
17
+ `[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}`
18
+ );
3
19
  function dataRoot() {
4
20
  return process.env.DATA_ROOT || join(process.cwd(), "data");
5
21
  }
@@ -19,6 +35,14 @@ var config = {
19
35
  get PROMPTOWL_KEY() {
20
36
  return process.env.PROMPTOWL_KEY || "";
21
37
  },
38
+ /**
39
+ * Path to the .env file the server reads its config from. Used by
40
+ * the license install flow to persist PROMPTOWL_KEY alongside any
41
+ * existing env vars, instead of a separate sidecar file.
42
+ */
43
+ get ENV_FILE_PATH() {
44
+ return process.env.ENV_FILE_PATH || join(process.cwd(), ".env");
45
+ },
22
46
  get TELEMETRY_ENABLED() {
23
47
  return process.env.TELEMETRY_ENABLED !== "false";
24
48
  },
@@ -52,7 +76,7 @@ var config = {
52
76
  // src/db/client.ts
53
77
  import Database from "better-sqlite3";
54
78
  import { mkdirSync } from "fs";
55
- import { dirname } from "path";
79
+ import { dirname as dirname2 } from "path";
56
80
 
57
81
  // src/db/migrations.ts
58
82
  function runMigrations(db2) {
@@ -204,6 +228,9 @@ function runMigrations(db2) {
204
228
  if (!userCols.includes("is_admin")) {
205
229
  db2.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0");
206
230
  }
231
+ if (!userCols.includes("is_invited")) {
232
+ db2.exec("ALTER TABLE users ADD COLUMN is_invited INTEGER NOT NULL DEFAULT 0");
233
+ }
207
234
  db2.exec(`
208
235
  CREATE TABLE IF NOT EXISTS schema_migrations (
209
236
  id TEXT PRIMARY KEY,
@@ -326,16 +353,65 @@ function runMigrations(db2) {
326
353
  recordMigration("002_steward_parity");
327
354
  })();
328
355
  }
356
+ if (!hasMigration("003_sessions_and_single_api_key")) {
357
+ db2.transaction(() => {
358
+ db2.exec(`
359
+ CREATE TABLE IF NOT EXISTS sessions (
360
+ id TEXT PRIMARY KEY,
361
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
362
+ expires_at TEXT NOT NULL,
363
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
364
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
365
+ user_agent TEXT
366
+ );
367
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
368
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
369
+ `);
370
+ db2.exec(`
371
+ DELETE FROM api_keys
372
+ WHERE id IN (
373
+ SELECT id FROM (
374
+ SELECT
375
+ id,
376
+ ROW_NUMBER() OVER (
377
+ PARTITION BY user_id
378
+ ORDER BY
379
+ COALESCE(last_used_at, '') DESC,
380
+ created_at DESC,
381
+ id DESC
382
+ ) AS rn
383
+ FROM api_keys
384
+ )
385
+ WHERE rn > 1
386
+ );
387
+ `);
388
+ db2.exec(`
389
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_user_unique
390
+ ON api_keys(user_id);
391
+ `);
392
+ recordMigration("003_sessions_and_single_api_key");
393
+ })();
394
+ }
395
+ if (!hasMigration("004_license_cache_owner_email")) {
396
+ db2.transaction(() => {
397
+ const cols = db2.prepare("PRAGMA table_info(license_cache)").all().map((c) => c.name);
398
+ if (!cols.includes("owner_email")) {
399
+ db2.exec("ALTER TABLE license_cache ADD COLUMN owner_email TEXT");
400
+ }
401
+ recordMigration("004_license_cache_owner_email");
402
+ })();
403
+ }
329
404
  }
330
405
 
331
406
  // src/db/client.ts
332
407
  var db = null;
333
408
  function getDb() {
334
409
  if (!db) {
335
- mkdirSync(dirname(config.DATABASE_PATH), { recursive: true });
410
+ mkdirSync(dirname2(config.DATABASE_PATH), { recursive: true });
336
411
  db = new Database(config.DATABASE_PATH);
337
412
  db.pragma("journal_mode = WAL");
338
413
  db.pragma("foreign_keys = ON");
414
+ db.pragma("busy_timeout = 5000");
339
415
  runMigrations(db);
340
416
  }
341
417
  return db;
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getDb
3
- } from "./chunk-USIDOGVJ.js";
3
+ } from "./chunk-2TW25QEA.js";
4
4
 
5
5
  // src/governance/version-service.ts
6
6
  import { createHash } from "crypto";
@@ -102,6 +102,28 @@ function getNodeTags(nestId, nodeId) {
102
102
  return [];
103
103
  }
104
104
  }
105
+ function getDisplayStatus(nestId, nodeId) {
106
+ const db = getDb();
107
+ const pending = db.prepare(
108
+ "SELECT 1 FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' LIMIT 1"
109
+ ).get(nestId, nodeId);
110
+ if (pending) return "pending_review";
111
+ const current = db.prepare(
112
+ `SELECT version, status FROM node_versions
113
+ WHERE nest_id = ? AND node_id = ?
114
+ ORDER BY version DESC LIMIT 1`
115
+ ).get(nestId, nodeId);
116
+ if (!current) return "draft";
117
+ if (current.status === "approved") {
118
+ const approved = db.prepare(
119
+ "SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?"
120
+ ).get(nestId, nodeId);
121
+ if (approved?.approved_version === current.version) return "approved";
122
+ return "draft";
123
+ }
124
+ if (current.status === "rejected") return "rejected";
125
+ return "draft";
126
+ }
105
127
  function rowToVersion(row) {
106
128
  return {
107
129
  version: row.version,
@@ -123,5 +145,6 @@ export {
123
145
  getApprovedVersion,
124
146
  setApprovedVersion,
125
147
  checkConflict,
126
- getNodeTags
148
+ getNodeTags,
149
+ getDisplayStatus
127
150
  };
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  canUserApprove,
3
3
  resolveStewardsForNode
4
- } from "./chunk-Q2DCOS7V.js";
4
+ } from "./chunk-2FXVMVZJ.js";
5
5
  import {
6
6
  getDb
7
- } from "./chunk-USIDOGVJ.js";
7
+ } from "./chunk-2TW25QEA.js";
8
8
 
9
9
  // src/governance/review-service.ts
10
10
  import { v4 as uuid } from "uuid";