@promptowl/contextnest-community 0.1.0-alpha.2 → 1.0.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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  config,
3
3
  getDb
4
- } from "./chunk-USIDOGVJ.js";
4
+ } from "./chunk-KQCWNHDM.js";
5
5
 
6
6
  // src/governance/stewardship-service.ts
7
7
  import { v4 as uuid } from "uuid";
@@ -108,8 +108,8 @@ function assignSteward(data) {
108
108
  const id = uuid();
109
109
  db.prepare(
110
110
  `INSERT INTO stewards
111
- (id, nest_id, scope, node_pattern, tag_name, user_email, user_id, role, can_approve, can_reject, assigned_by, is_active)
112
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
111
+ (id, nest_id, scope, node_pattern, tag_name, user_email, user_id, role, assigned_by, is_active)
112
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
113
113
  ).run(
114
114
  id,
115
115
  data.nestId,
@@ -119,8 +119,6 @@ function assignSteward(data) {
119
119
  data.userEmail,
120
120
  data.userId || null,
121
121
  data.role,
122
- data.canApprove ? 1 : 0,
123
- data.canReject ? 1 : 0,
124
122
  data.assignedBy,
125
123
  data.isActive ? 1 : 0
126
124
  );
@@ -128,7 +126,21 @@ function assignSteward(data) {
128
126
  }
129
127
  function removeSteward(id) {
130
128
  const db = getDb();
131
- db.prepare("UPDATE stewards SET is_active = 0 WHERE id = ?").run(id);
129
+ const remove = db.transaction((stewardId) => {
130
+ const row = db.prepare("SELECT nest_id, user_email FROM stewards WHERE id = ?").get(stewardId);
131
+ db.prepare("DELETE FROM stewards WHERE id = ?").run(stewardId);
132
+ if (!row) return;
133
+ const remaining = db.prepare(
134
+ "SELECT 1 FROM stewards WHERE nest_id = ? AND LOWER(user_email) = LOWER(?) AND is_active = 1 LIMIT 1"
135
+ ).get(row.nest_id, row.user_email);
136
+ if (remaining) return;
137
+ const user = db.prepare("SELECT id FROM users WHERE LOWER(email) = LOWER(?)").get(row.user_email);
138
+ if (!user) return;
139
+ db.prepare(
140
+ "DELETE FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
141
+ ).run(row.nest_id, user.id);
142
+ });
143
+ remove(id);
132
144
  }
133
145
  function getSteward(id) {
134
146
  const db = getDb();
@@ -170,7 +182,34 @@ function listStewards(params) {
170
182
  sql += " ORDER BY scope, COALESCE(node_pattern, tag_name, ''), user_email";
171
183
  return db.prepare(sql).all(...args).map(rowToSteward);
172
184
  }
173
- function createStewardRecord(params) {
185
+ function rolePermission(role) {
186
+ return role === "editor" ? "write" : "read";
187
+ }
188
+ async function ensureCollaborator(nestId, email, permission, grantedBy) {
189
+ const db = getDb();
190
+ let userRow = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
191
+ if (!userRow) {
192
+ const { hashPassword } = await import("./keys-YV33AJK3.js");
193
+ const newId = uuid();
194
+ db.prepare(
195
+ "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
196
+ ).run(newId, email, null, await hashPassword(uuid()));
197
+ userRow = { id: newId };
198
+ }
199
+ const nestRow = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
200
+ if (nestRow && nestRow.user_id === userRow.id) return;
201
+ const existing = db.prepare(
202
+ "SELECT id FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
203
+ ).get(nestId, userRow.id);
204
+ if (existing) return;
205
+ const granterRow = db.prepare("SELECT id FROM users WHERE email = ?").get(grantedBy);
206
+ const granterId = granterRow?.id ?? nestRow?.user_id;
207
+ if (!granterId) return;
208
+ db.prepare(
209
+ "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)"
210
+ ).run(uuid(), nestId, userRow.id, permission, granterId);
211
+ }
212
+ async function createStewardRecord(params) {
174
213
  if (params.users.length === 0) {
175
214
  throw new Error("At least one user is required");
176
215
  }
@@ -181,10 +220,6 @@ function createStewardRecord(params) {
181
220
  if (!params.documentId) throw new Error("documentId required for document scope");
182
221
  nodePattern = params.documentId;
183
222
  break;
184
- case "folder":
185
- if (!params.folderPath) throw new Error("folderPath required for folder scope");
186
- nodePattern = params.folderPath;
187
- break;
188
223
  case "tag":
189
224
  if (!params.tagName) throw new Error("tagName required for tag scope");
190
225
  tagName = params.tagName.trim().replace(/^#+/, "").toLowerCase();
@@ -222,13 +257,17 @@ function createStewardRecord(params) {
222
257
  userEmail: email,
223
258
  userId: userRow?.id,
224
259
  role: user.role ?? "reviewer",
225
- canApprove: user.canApprove !== false,
226
- canReject: user.canReject !== false,
227
260
  assignedBy: params.assignedBy,
228
261
  assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
229
262
  isActive: true
230
263
  });
231
264
  results.push(created);
265
+ await ensureCollaborator(
266
+ params.nestId,
267
+ email,
268
+ rolePermission(user.role),
269
+ params.assignedBy
270
+ );
232
271
  }
233
272
  db.prepare(
234
273
  "UPDATE nests SET stewardship_enabled = 1 WHERE id = ? AND stewardship_enabled = 0"
@@ -250,14 +289,7 @@ function resolve(nestId, nodeId) {
250
289
  WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'document'
251
290
  AND s.node_pattern = ?
252
291
  UNION ALL
253
- SELECT s.*, 2 AS priority, ('folder: ' || s.node_pattern) AS match_source
254
- FROM stewards s
255
- WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'folder'
256
- AND s.node_pattern IS NOT NULL
257
- AND instr(s.node_pattern, '*') = 0
258
- AND ? LIKE s.node_pattern || '/%'
259
- UNION ALL
260
- SELECT s.*, 3 AS priority, ('tag: ' || s.tag_name) AS match_source
292
+ SELECT s.*, 2 AS priority, ('tag: ' || s.tag_name) AS match_source
261
293
  FROM stewards s
262
294
  JOIN node_tag_index nt
263
295
  ON nt.nest_id = s.nest_id
@@ -265,34 +297,26 @@ function resolve(nestId, nodeId) {
265
297
  WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'tag'
266
298
  AND nt.node_id = ?
267
299
  UNION ALL
268
- SELECT s.*, 4 AS priority, 'nest-level steward' AS match_source
300
+ SELECT s.*, 3 AS priority, 'nest-level steward' AS match_source
269
301
  FROM stewards s
270
302
  WHERE s.nest_id = ? AND s.is_active = 1 AND s.scope = 'nest'
271
303
  ORDER BY priority ASC, user_email ASC
272
304
  `
273
- ).all(nestId, nodeId, nestId, nodeId, nestId, nodeId, nestId);
305
+ ).all(
306
+ nestId,
307
+ nodeId,
308
+ // document branch
309
+ nestId,
310
+ nodeId,
311
+ // tag branch
312
+ nestId
313
+ // nest branch
314
+ );
274
315
  const resolved = rows.map((row) => ({
275
316
  steward: rowToSteward(row),
276
317
  priority: row.priority,
277
318
  source: row.match_source
278
319
  }));
279
- const legacyFolderGlobs = db.prepare(
280
- `SELECT * FROM stewards
281
- WHERE nest_id = ? AND is_active = 1 AND scope = 'folder'
282
- AND node_pattern IS NOT NULL AND instr(node_pattern, '*') > 0`
283
- ).all(nestId);
284
- for (const row of legacyFolderGlobs) {
285
- if (globMatch(nodeId, row.node_pattern)) {
286
- resolved.push({
287
- steward: rowToSteward(row),
288
- priority: 2,
289
- source: `folder: ${row.node_pattern}`
290
- });
291
- }
292
- }
293
- if (legacyFolderGlobs.length > 0) {
294
- resolved.sort((a, b) => a.priority - b.priority);
295
- }
296
320
  if (resolved.length > 0) {
297
321
  return { stewards: resolved, fallbackToOwner: false };
298
322
  }
@@ -359,7 +383,7 @@ function canUserApprove(nestId, nodeId, userEmail) {
359
383
  };
360
384
  }
361
385
  const match = resolved.find(
362
- (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "reviewer" && r.steward.canApprove
386
+ (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "reviewer"
363
387
  );
364
388
  if (!match) {
365
389
  return {
@@ -395,14 +419,6 @@ function canUserAccess(nestId, nodeId, userEmail) {
395
419
  }
396
420
  return { allowed: false, reason: "no steward assignment", role: null };
397
421
  }
398
- function globMatch(value, pattern) {
399
- if (pattern === "*") return true;
400
- if (!pattern.includes("*")) return value === pattern;
401
- const regex = new RegExp(
402
- "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"
403
- );
404
- return regex.test(value);
405
- }
406
422
  function syncFromConfig(nestId, config2) {
407
423
  const db = getDb();
408
424
  let count = 0;
@@ -425,8 +441,6 @@ function syncFromConfig(nestId, config2) {
425
441
  userEmail: entry.email.toLowerCase(),
426
442
  userId: user?.id,
427
443
  role,
428
- canApprove: entry.can_approve !== false,
429
- canReject: entry.can_reject !== false,
430
444
  assignedBy: "config",
431
445
  assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
432
446
  isActive: true
@@ -437,11 +451,6 @@ function syncFromConfig(nestId, config2) {
437
451
  if (config2.nest) {
438
452
  addEntries("nest", config2.nest);
439
453
  }
440
- if (config2.folders) {
441
- for (const [pattern, entries] of Object.entries(config2.folders)) {
442
- addEntries("folder", entries, { nodePattern: pattern });
443
- }
444
- }
445
454
  if (config2.tags) {
446
455
  for (const [tagName, entries] of Object.entries(config2.tags)) {
447
456
  addEntries("tag", entries, { tagName });
@@ -464,8 +473,6 @@ function rowToSteward(row) {
464
473
  userEmail: row.user_email,
465
474
  userId: row.user_id || void 0,
466
475
  role: row.role,
467
- canApprove: !!row.can_approve,
468
- canReject: !!row.can_reject,
469
476
  assignedBy: row.assigned_by,
470
477
  assignedAt: row.assigned_at,
471
478
  isActive: !!row.is_active
@@ -1,5 +1,24 @@
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
+ var isTestRun = !!process.env.VITEST;
14
+ if (envFileLoaded && !isTestRun) {
15
+ dotenv.config({ path: envFileLoaded, override: true });
16
+ }
17
+ if (!isTestRun) {
18
+ console.log(
19
+ `[config] dotenv: ${envFileLoaded ? `loaded ${envFileLoaded}` : "no .env file found"}`
20
+ );
21
+ }
3
22
  function dataRoot() {
4
23
  return process.env.DATA_ROOT || join(process.cwd(), "data");
5
24
  }
@@ -19,6 +38,14 @@ var config = {
19
38
  get PROMPTOWL_KEY() {
20
39
  return process.env.PROMPTOWL_KEY || "";
21
40
  },
41
+ /**
42
+ * Path to the .env file the server reads its config from. Used by
43
+ * the license install flow to persist PROMPTOWL_KEY alongside any
44
+ * existing env vars, instead of a separate sidecar file.
45
+ */
46
+ get ENV_FILE_PATH() {
47
+ return process.env.ENV_FILE_PATH || join(process.cwd(), ".env");
48
+ },
22
49
  get TELEMETRY_ENABLED() {
23
50
  return process.env.TELEMETRY_ENABLED !== "false";
24
51
  },
@@ -52,7 +79,11 @@ var config = {
52
79
  // src/db/client.ts
53
80
  import Database from "better-sqlite3";
54
81
  import { mkdirSync } from "fs";
55
- import { dirname } from "path";
82
+ import { dirname as dirname2 } from "path";
83
+
84
+ // src/shared/constants.ts
85
+ var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
86
+ var ANON_EMAIL = "admin@localhost";
56
87
 
57
88
  // src/db/migrations.ts
58
89
  function runMigrations(db2) {
@@ -124,19 +155,17 @@ function runMigrations(db2) {
124
155
  db2.exec(`
125
156
  -- Steward assignments (mirrors PromptOwl ContextSteward model)
126
157
  -- scope+target combination determines what the steward governs
127
- -- Resolution priority: document(1) > folder(2) > tag(3) > nest(4)
158
+ -- Resolution priority: document(1) > tag(2) > nest(3)
128
159
  CREATE TABLE IF NOT EXISTS stewards (
129
160
  id TEXT PRIMARY KEY,
130
161
  nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
131
- scope TEXT NOT NULL CHECK(scope IN ('document', 'folder', 'tag', 'nest')),
132
- node_pattern TEXT, -- glob for document/folder scope (e.g. "nodes/api-*")
162
+ scope TEXT NOT NULL CHECK(scope IN ('document', 'tag', 'nest')),
163
+ node_pattern TEXT, -- exact node id for document scope
133
164
  tag_name TEXT, -- for tag scope
134
165
  user_email TEXT NOT NULL,
135
166
  user_id TEXT REFERENCES users(id),
136
167
  role TEXT NOT NULL DEFAULT 'reviewer'
137
168
  CHECK(role IN ('editor', 'reviewer', 'admin')),
138
- can_approve INTEGER NOT NULL DEFAULT 1,
139
- can_reject INTEGER NOT NULL DEFAULT 1,
140
169
  assigned_by TEXT NOT NULL,
141
170
  assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
142
171
  is_active INTEGER NOT NULL DEFAULT 1
@@ -204,6 +233,22 @@ function runMigrations(db2) {
204
233
  if (!userCols.includes("is_admin")) {
205
234
  db2.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0");
206
235
  }
236
+ if (!userCols.includes("is_invited")) {
237
+ db2.exec("ALTER TABLE users ADD COLUMN is_invited INTEGER NOT NULL DEFAULT 0");
238
+ }
239
+ const stewardCols = db2.prepare("PRAGMA table_info(stewards)").all().map((c) => c.name);
240
+ if (stewardCols.length > 0) {
241
+ if (!stewardCols.includes("can_approve")) {
242
+ db2.exec(
243
+ "ALTER TABLE stewards ADD COLUMN can_approve INTEGER NOT NULL DEFAULT 1"
244
+ );
245
+ }
246
+ if (!stewardCols.includes("can_reject")) {
247
+ db2.exec(
248
+ "ALTER TABLE stewards ADD COLUMN can_reject INTEGER NOT NULL DEFAULT 1"
249
+ );
250
+ }
251
+ }
207
252
  db2.exec(`
208
253
  CREATE TABLE IF NOT EXISTS schema_migrations (
209
254
  id TEXT PRIMARY KEY,
@@ -218,24 +263,23 @@ function runMigrations(db2) {
218
263
  CREATE TABLE stewards_new (
219
264
  id TEXT PRIMARY KEY,
220
265
  nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
221
- scope TEXT NOT NULL CHECK(scope IN ('document', 'folder', 'tag', 'nest')),
266
+ scope TEXT NOT NULL CHECK(scope IN ('document', 'tag', 'nest')),
222
267
  node_pattern TEXT,
223
268
  tag_name TEXT,
224
269
  user_email TEXT NOT NULL,
225
270
  user_id TEXT REFERENCES users(id),
226
271
  role TEXT NOT NULL DEFAULT 'reviewer'
227
272
  CHECK(role IN ('editor', 'reviewer', 'viewer')),
228
- can_approve INTEGER NOT NULL DEFAULT 1,
229
- can_reject INTEGER NOT NULL DEFAULT 1,
230
273
  assigned_by TEXT NOT NULL,
231
274
  assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
232
275
  is_active INTEGER NOT NULL DEFAULT 1
233
276
  );
234
277
 
235
- -- Copy rows; map legacy 'admin' role to 'reviewer', normalize tag_name + email
278
+ -- Copy rows; map legacy 'admin' role to 'reviewer', normalize tag_name + email.
279
+ -- Legacy can_approve/can_reject columns are dropped here (role is now the sole signal).
236
280
  INSERT INTO stewards_new
237
281
  (id, nest_id, scope, node_pattern, tag_name, user_email, user_id,
238
- role, can_approve, can_reject, assigned_by, assigned_at, is_active)
282
+ role, assigned_by, assigned_at, is_active)
239
283
  SELECT
240
284
  id, nest_id, scope, node_pattern,
241
285
  CASE
@@ -245,7 +289,7 @@ function runMigrations(db2) {
245
289
  lower(user_email),
246
290
  user_id,
247
291
  CASE role WHEN 'admin' THEN 'reviewer' ELSE role END,
248
- can_approve, can_reject, assigned_by, assigned_at, is_active
292
+ assigned_by, assigned_at, is_active
249
293
  FROM stewards;
250
294
 
251
295
  DROP TABLE stewards;
@@ -264,10 +308,6 @@ function runMigrations(db2) {
264
308
  ON stewards(nest_id, node_pattern, user_email)
265
309
  WHERE scope = 'document' AND node_pattern IS NOT NULL AND is_active = 1;
266
310
 
267
- CREATE UNIQUE INDEX idx_stewards_uniq_folder
268
- ON stewards(nest_id, node_pattern, user_email)
269
- WHERE scope = 'folder' AND node_pattern IS NOT NULL AND is_active = 1;
270
-
271
311
  CREATE UNIQUE INDEX idx_stewards_uniq_tag
272
312
  ON stewards(nest_id, tag_name, user_email)
273
313
  WHERE scope = 'tag' AND tag_name IS NOT NULL AND is_active = 1;
@@ -276,9 +316,6 @@ function runMigrations(db2) {
276
316
  CREATE INDEX idx_stewards_tag_lookup
277
317
  ON stewards(nest_id, tag_name)
278
318
  WHERE scope = 'tag' AND is_active = 1;
279
- CREATE INDEX idx_stewards_folder_lookup
280
- ON stewards(nest_id, node_pattern)
281
- WHERE scope = 'folder' AND is_active = 1;
282
319
  CREATE INDEX idx_stewards_doc_lookup
283
320
  ON stewards(nest_id, node_pattern)
284
321
  WHERE scope = 'document' AND is_active = 1;
@@ -326,16 +363,174 @@ function runMigrations(db2) {
326
363
  recordMigration("002_steward_parity");
327
364
  })();
328
365
  }
366
+ if (!hasMigration("003_sessions_and_single_api_key")) {
367
+ db2.transaction(() => {
368
+ db2.exec(`
369
+ CREATE TABLE IF NOT EXISTS sessions (
370
+ id TEXT PRIMARY KEY,
371
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
372
+ expires_at TEXT NOT NULL,
373
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
374
+ last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
375
+ user_agent TEXT
376
+ );
377
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
378
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
379
+ `);
380
+ db2.exec(`
381
+ DELETE FROM api_keys
382
+ WHERE id IN (
383
+ SELECT id FROM (
384
+ SELECT
385
+ id,
386
+ ROW_NUMBER() OVER (
387
+ PARTITION BY user_id
388
+ ORDER BY
389
+ COALESCE(last_used_at, '') DESC,
390
+ created_at DESC,
391
+ id DESC
392
+ ) AS rn
393
+ FROM api_keys
394
+ )
395
+ WHERE rn > 1
396
+ );
397
+ `);
398
+ db2.exec(`
399
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_user_unique
400
+ ON api_keys(user_id);
401
+ `);
402
+ recordMigration("003_sessions_and_single_api_key");
403
+ })();
404
+ }
405
+ if (!hasMigration("004_license_cache_owner_email")) {
406
+ db2.transaction(() => {
407
+ const cols = db2.prepare("PRAGMA table_info(license_cache)").all().map((c) => c.name);
408
+ if (!cols.includes("owner_email")) {
409
+ db2.exec("ALTER TABLE license_cache ADD COLUMN owner_email TEXT");
410
+ }
411
+ recordMigration("004_license_cache_owner_email");
412
+ })();
413
+ }
414
+ if (!hasMigration("005_anon_nest_public_default")) {
415
+ db2.transaction(() => {
416
+ db2.prepare(
417
+ "UPDATE nests SET visibility = 'public' WHERE user_id = ? AND visibility = 'private'"
418
+ ).run(ANON_USER_ID);
419
+ recordMigration("005_anon_nest_public_default");
420
+ })();
421
+ }
422
+ if (!hasMigration("006_node_versions_published_status")) {
423
+ db2.transaction(() => {
424
+ db2.exec(`
425
+ CREATE TABLE node_versions_new (
426
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
427
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
428
+ node_id TEXT NOT NULL,
429
+ version INTEGER NOT NULL,
430
+ content_hash TEXT NOT NULL,
431
+ author TEXT NOT NULL,
432
+ status TEXT NOT NULL DEFAULT 'draft'
433
+ CHECK(status IN ('draft', 'pending_review', 'approved', 'published', 'rejected')),
434
+ change_note TEXT,
435
+ tags_json TEXT,
436
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
437
+ UNIQUE(nest_id, node_id, version)
438
+ );
439
+ INSERT INTO node_versions_new
440
+ (id, nest_id, node_id, version, content_hash, author, status, change_note, tags_json, created_at)
441
+ SELECT
442
+ id, nest_id, node_id, version, content_hash, author, status, change_note, tags_json, created_at
443
+ FROM node_versions;
444
+ DROP TABLE node_versions;
445
+ ALTER TABLE node_versions_new RENAME TO node_versions;
446
+ CREATE INDEX idx_versions_node ON node_versions(nest_id, node_id);
447
+ `);
448
+ recordMigration("006_node_versions_published_status");
449
+ })();
450
+ }
451
+ if (!hasMigration("007_drop_steward_capability_flags")) {
452
+ const stewardCols2 = db2.prepare("PRAGMA table_info(stewards)").all().map((c) => c.name);
453
+ const hasLegacyCols = stewardCols2.includes("can_approve") || stewardCols2.includes("can_reject");
454
+ if (hasLegacyCols) {
455
+ db2.transaction(() => {
456
+ if (stewardCols2.includes("can_approve")) {
457
+ db2.exec("ALTER TABLE stewards DROP COLUMN can_approve");
458
+ }
459
+ if (stewardCols2.includes("can_reject")) {
460
+ db2.exec("ALTER TABLE stewards DROP COLUMN can_reject");
461
+ }
462
+ recordMigration("007_drop_steward_capability_flags");
463
+ })();
464
+ } else {
465
+ recordMigration("007_drop_steward_capability_flags");
466
+ }
467
+ }
468
+ if (!hasMigration("008_drop_steward_folder_scope")) {
469
+ db2.transaction(() => {
470
+ db2.exec("DELETE FROM stewards WHERE scope = 'folder'");
471
+ db2.exec("DROP INDEX IF EXISTS idx_stewards_uniq_folder");
472
+ db2.exec("DROP INDEX IF EXISTS idx_stewards_folder_lookup");
473
+ const tbl = db2.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='stewards'").get();
474
+ if (tbl?.sql && tbl.sql.includes("'folder'")) {
475
+ db2.exec(`
476
+ CREATE TABLE stewards_new (
477
+ id TEXT PRIMARY KEY,
478
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
479
+ scope TEXT NOT NULL CHECK(scope IN ('document', 'tag', 'nest')),
480
+ node_pattern TEXT,
481
+ tag_name TEXT,
482
+ user_email TEXT NOT NULL,
483
+ user_id TEXT REFERENCES users(id),
484
+ role TEXT NOT NULL DEFAULT 'reviewer'
485
+ CHECK(role IN ('editor', 'reviewer', 'viewer')),
486
+ assigned_by TEXT NOT NULL,
487
+ assigned_at TEXT NOT NULL DEFAULT (datetime('now')),
488
+ is_active INTEGER NOT NULL DEFAULT 1
489
+ );
490
+ INSERT INTO stewards_new
491
+ (id, nest_id, scope, node_pattern, tag_name, user_email, user_id,
492
+ role, assigned_by, assigned_at, is_active)
493
+ SELECT
494
+ id, nest_id, scope, node_pattern, tag_name, user_email, user_id,
495
+ role, assigned_by, assigned_at, is_active
496
+ FROM stewards;
497
+ DROP TABLE stewards;
498
+ ALTER TABLE stewards_new RENAME TO stewards;
499
+
500
+ CREATE INDEX idx_stewards_nest ON stewards(nest_id);
501
+ CREATE INDEX idx_stewards_email ON stewards(user_email);
502
+ CREATE INDEX idx_stewards_scope ON stewards(nest_id, scope);
503
+ CREATE UNIQUE INDEX idx_stewards_uniq_nest
504
+ ON stewards(nest_id, user_email)
505
+ WHERE scope = 'nest' AND is_active = 1;
506
+ CREATE UNIQUE INDEX idx_stewards_uniq_document
507
+ ON stewards(nest_id, node_pattern, user_email)
508
+ WHERE scope = 'document' AND node_pattern IS NOT NULL AND is_active = 1;
509
+ CREATE UNIQUE INDEX idx_stewards_uniq_tag
510
+ ON stewards(nest_id, tag_name, user_email)
511
+ WHERE scope = 'tag' AND tag_name IS NOT NULL AND is_active = 1;
512
+ CREATE INDEX idx_stewards_tag_lookup
513
+ ON stewards(nest_id, tag_name)
514
+ WHERE scope = 'tag' AND is_active = 1;
515
+ CREATE INDEX idx_stewards_doc_lookup
516
+ ON stewards(nest_id, node_pattern)
517
+ WHERE scope = 'document' AND is_active = 1;
518
+ `);
519
+ }
520
+ recordMigration("008_drop_steward_folder_scope");
521
+ })();
522
+ }
329
523
  }
330
524
 
331
525
  // src/db/client.ts
332
526
  var db = null;
333
527
  function getDb() {
334
528
  if (!db) {
335
- mkdirSync(dirname(config.DATABASE_PATH), { recursive: true });
529
+ mkdirSync(dirname2(config.DATABASE_PATH), { recursive: true });
336
530
  db = new Database(config.DATABASE_PATH);
337
531
  db.pragma("journal_mode = WAL");
338
532
  db.pragma("foreign_keys = ON");
533
+ db.pragma("busy_timeout = 5000");
339
534
  runMigrations(db);
340
535
  }
341
536
  return db;
@@ -343,5 +538,7 @@ function getDb() {
343
538
 
344
539
  export {
345
540
  config,
541
+ ANON_USER_ID,
542
+ ANON_EMAIL,
346
543
  getDb
347
544
  };