@promptowl/contextnest-community 1.21.1 → 1.23.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.
Files changed (25) hide show
  1. package/CONFIGURATION.md +6 -2
  2. package/README.md +1 -1
  3. package/dist/{chunk-LL2JU5AA.js → chunk-2YPY2HGZ.js} +2 -2
  4. package/dist/{chunk-FXYFLUE7.js → chunk-5CVZMHHB.js} +2 -2
  5. package/dist/{chunk-NH7L2S6S.js → chunk-MLQU4I5J.js} +52 -12
  6. package/dist/{chunk-G4AL7ETF.js → chunk-QLXC6542.js} +43 -11
  7. package/dist/{chunk-SKZ7YYT4.js → chunk-QWNXWRWO.js} +114 -2
  8. package/dist/{chunk-IVXVOXYN.js → chunk-UOMW7TT6.js} +1 -1
  9. package/dist/{chunk-UVOAYQTU.js → chunk-XYD6V2LH.js} +4 -4
  10. package/dist/{client-KBK4VIWJ.js → client-LWDMNKX3.js} +1 -1
  11. package/dist/{engine-WXWER2V7.js → engine-QFF2IA3G.js} +2 -2
  12. package/dist/{external-edit-service-Y757EFMS.js → external-edit-service-F7D3VSA2.js} +3 -3
  13. package/dist/{grants-service-FKINSDLZ.js → grants-service-TBGTCO5K.js} +2 -2
  14. package/dist/index.js +920 -228
  15. package/dist/{migrations.postgres-JA2YGLS3.js → migrations.postgres-APCVSYUE.js} +32 -2
  16. package/dist/{review-service-PPR5BV5J.js → review-service-A7PNLWG5.js} +6 -6
  17. package/dist/{stewardship-service-IKC7MXLP.js → stewardship-service-32UHIRQ5.js} +3 -3
  18. package/dist/{version-service-GWSDSUZR.js → version-service-RJ6CWZ3O.js} +3 -3
  19. package/dist/web3/assets/index-BJ-LRNis.js +1382 -0
  20. package/dist/web3/assets/index-DPMEt-A_.css +1 -0
  21. package/dist/web3/assets/module-uADzTImp.js +3 -0
  22. package/dist/web3/index.html +2 -2
  23. package/package.json +2 -1
  24. package/dist/web3/assets/index-T4pilFNL.css +0 -1
  25. package/dist/web3/assets/index-deyhV93k.js +0 -1382
package/CONFIGURATION.md CHANGED
@@ -59,7 +59,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
59
59
  | `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 the database (`server_settings` table) so it survives a rebuild and reaches every instance — see [Runtime settings persistence](#runtime-settings-persistence). Setting it here in the deploy environment takes precedence on the next boot. |
60
60
  | `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`. |
61
61
  | `MANUAL_SIGN_IN` | `open` | Email + password sign-in mode. `open` = anyone may log in and self-register a new account; `invite-only` = existing/invited users may log in but brand-new self-registration returns `403` (the admin provisions accounts via invite/share/steward and shares the password — there is no self-service "set password", which would be account takeover without email verification); `disabled` = no email/password sign-in at all (`POST /auth/login` and `POST /auth/register` return `403`). Independent of `PROMPTOWL_SIGN_IN_GATE`, so the two methods are controlled separately (e.g. `invite-only` manual + `admin-only` PromptOwl). Also settable from Settings → General. The server refuses `disabled` while PromptOwl sign-in is also `disabled` (that would leave no way to log in). Unknown values fall back to `open`. |
62
- | `OFFICIAL_COMMUNITY_SSO_SECRET` | `""` | **Official deployment only — leave unset on self-hosted.** Shared HMAC secret enabling the one-click "Open Community" SSO auto-login from PromptOwl. Must exactly match the same-named var on PromptOwl. When unset, `GET /auth/sso` returns `404` and the feature is disabled; self-hosted users keep using the manual device-code flow. |
62
+ | `OFFICIAL_COMMUNITY_SSO_SECRET` | `""` | **Legacy — official deployment only, leave unset on self-hosted.** Shared HMAC secret enabling the one-click "Open Community" SSO auto-login from PromptOwl. Must exactly match the same-named var on PromptOwl. Superseded by the DB-backed **Settings → Community sites** list (`/admin/community-sites`), which supports multiple official sites each with their own name/url/secret/active flag — this env var still works as an implicit extra site for backward compatibility. When no site is configured (env var unset and no DB rows), `GET /auth/sso` returns `404` and the feature is disabled; self-hosted users keep using the manual device-code flow. See `API.md → Community sites`. |
63
63
  | `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`). Two uses. (1) Checked against the SSO ticket's `aud` claim so a ticket minted for this server can't be replayed against another — whenever a ticket-signing secret is set (`OFFICIAL_COMMUNITY_SSO_SECRET` on the official deployment, `MCP_SIGNING_SECRET` on a self-hosted one); with neither set no ticket verifies at all, so the audience check is moot. (2) **Set this whenever `OIDC_ENABLED=true`.** It's the base for the OIDC `redirect_uri` sent to your IdP and replayed at token exchange. Unset, that URI is derived from the incoming request's `Host` header — fine when your proxy overwrites `Host` with a trusted value, but a proxy that passes an attacker-supplied `Host` through would feed it straight into the redirect URI. Setting this pins the value regardless of what the proxy forwards. (Your IdP's own redirect-URI allowlist is a second line of defence, not a substitute.) (3) **Set this wherever email- or invite-gated publish links are used.** The magic link mailed by `POST /p/:slug/gate` is built from it; unset, it falls back to the request origin, so a proxy forwarding an attacker-supplied `Host` would send the recipient a one-time access token pointing at the attacker's domain — the classic reset-link poisoning. (4) **Set this wherever `POST /nests/:id/context` citations reach users.** The `url` on each node and the `_source:` line in each context block are built from it; unset, they fall back to the request origin, so a proxy forwarding an untrusted `Host` would put an attacker-influenced URL in front of both the model and the reader as a trustworthy citation. |
64
64
  | `OIDC_ENABLED` | `false` | Turn on generic OIDC single sign-on (`GET /auth/oidc/login` / `GET /auth/oidc/callback`). Requires `OIDC_ISSUER`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` — the login-page button only appears once all three are set. Also editable from Settings → Single sign-on. See [Single sign-on (OIDC)](#single-sign-on-oidc). |
65
65
  | `OIDC_ISSUER` | `""` | OIDC issuer URL, e.g. `https://login.microsoftonline.com/<tenant>/v2.0` (Microsoft Entra ID) or `https://accounts.google.com` (Google). **`https://` only** — a non-https value is rejected with a warning and SSO stays off. Must serve `<issuer>/.well-known/openid-configuration`. |
@@ -67,6 +67,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
67
67
  | `OIDC_CLIENT_SECRET` | `""` | Client secret from your IdP app registration. Write-only on the Settings API — `GET /admin/settings` reports only `oidc_client_secret_set: true/false`, never the value. |
68
68
  | `OIDC_ALLOWED_DOMAINS` | `""` (any) | Comma-separated email-domain allowlist, e.g. `acme.com, contractors.acme.com`. When set, only accounts whose asserted email is on a listed domain may sign in (others bounce with `domain_not_allowed`). Empty allows any domain the IdP asserts. |
69
69
  | `OIDC_AUTO_PROVISION` | `true` | Create a user automatically on first successful OIDC sign-in (display name from the `name` claim). Set `false` to allow only pre-existing (invited/registered) users — unknown emails bounce with `not_invited`. |
70
+ | `PEOPLE_SUGGEST_ALL_USERS` | `false` | Widen the add-a-person suggestions (`GET /people/suggest`, behind every "add a person" field) and the `@mention` pickers from *people the caller already shares a nest or team with* to **every registered account on this server**. **Leave this off on a server that hosts more than one organisation** — the PromptOwl-hosted deployment does, and turning it on there would offer one customer's staff another customer's email addresses. On a single-company self-hosted server the whole directory is the useful answer, which is what this is for. Suggestions never gate the invite either way: an address that appears in no list is still valid to type. In the `@` pickers the widening is an invite: a write+ author mentioning someone off the nest adds them as a `read` collaborator before notifying (a read-only author's mention of an outsider reaches nobody). A server admin always sees every account (they administer them). Also editable from **Settings → General → People directory**. |
70
71
  | `OIDC_DEPARTMENT_TAGGING` | `false` | Auto-tag newly **created** documents with the creator's directory department: `dept:<slugified-department>` (lowercase, spaces → dashes, e.g. `dept:customer-success`) is appended to the document's tags, deduped against user-supplied tags. Applies on create only — never on update, never retroactively — and a user without a stored department is a silent no-op. The department is captured from the OIDC `department` ID-token claim on every SSO login (a login without the claim clears it, so directory moves propagate), so this is only meaningful when your IdP emits that claim — see [Department auto-tagging](#department-auto-tagging). Also editable from Settings → Single sign-on. |
71
72
  | `SSO_TOKEN_EXCHANGE_ENABLED` | `false` | Master switch for `POST /auth/token-exchange` — an external agent exchanges an IdP ID token for a short-lived MCP bearer. Also needs `MCP_SIGNING_SECRET` and at least one provider; otherwise the endpoint returns `404`. Security-critical — see [Agent SSO (token exchange)](#agent-sso-token-exchange). |
72
73
  | `MCP_SIGNING_SECRET` | `""` | HMAC secret **this** server signs its minted MCP bearers with (distinct from `OFFICIAL_COMMUNITY_SSO_SECRET`, so self-hosted deployments can issue their own). Long random string. `/index` and `/mcp` accept a ticket signed with either secret. Write-only on the Settings API. **Clearing it is the revocation switch** — turning `SSO_TOKEN_EXCHANGE_ENABLED` off stops minting but already-minted bearers stay valid until they expire. |
@@ -76,6 +77,8 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
76
77
  | `ENV_FILE_PATH` | `$DATA_ROOT/.env` | Path to an optional `.env` file the server reads at boot (in addition to `$cwd/.env`). **No longer used for persistence** — the License Setup Page and Settings page now write to the database, not this file (see [Runtime settings persistence](#runtime-settings-persistence)). Kept for operators who bootstrap config from a mounted `.env`. |
77
78
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
78
79
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
80
+ | `POSTHOG_KEY` | `""` | PostHog project API key for product analytics in the UI. Empty = analytics off (the default — a self-hosted install brings its own project). Served to the browser via `/health`, but only while `TELEMETRY_ENABLED` is on, so that switch turns off everything this server sends outward. Also editable from Settings → Advanced. |
81
+ | `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host. Set it to your own region or self-hosted PostHog. Also editable from Settings → Advanced. |
79
82
  | `TRACE_RETENTION_DAYS` | `14` | Activity-trace retention window in days (the `api_events` rows behind `GET /admin/trace` and `GET /nests/:id/trace`). Rows older than this are pruned opportunistically (every ~500 inserts). `0` = keep forever (pruning is skipped entirely). Capped at `3650`; invalid/negative values fall back to `14`. Also editable from Settings → Advanced. |
80
83
  | `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). |
81
84
  | `FRAME_ANCESTORS` | `'self'` | Which origins may embed this server in an iframe, sent as CSP `frame-ancestors`. The default lets nothing but this origin frame the UI, which blocks clickjacking. Deployments that are meant to be embedded list the embedding origin — e.g. the PromptOwl Data Room iframes ContextNest, so that install sets `FRAME_ANCESTORS="https://app.promptowl.ai"`. Comma-separated; `'self'` is always included; `*` allows any site and disables the protection. Note the embedding page must be **same-site** (a sibling subdomain) for the session cookie to survive inside the frame — a genuinely cross-domain embed will render the login page no matter what this is set to. |
@@ -150,7 +153,8 @@ plain-text `fallbackText` for clients that cannot render cards.
150
153
  Per-nest connector rows post the same Adaptive Card format to a nest-specific
151
154
  webhook, with per-event filtering: `POST /nests/:id/connectors` with a JSON
152
155
  body like `{"channel": "teams", "url": "https://…", "events":
153
- ["review_requested", "review_rejected"]}` (`events` may also be `["*"]`; the
156
+ ["review_requested", "review_rejected"]}` (`events` is any of `review_requested`,
157
+ `review_approved`, `review_rejected`, `run_failed`, `mention`, or `["*"]`; the
154
158
  `url` may be an `env:KEY` reference into the nest's env store). Manage rows
155
159
  with `GET`/`PATCH`/`DELETE` on the same path.
156
160
 
package/README.md CHANGED
@@ -165,4 +165,4 @@ The Software injects content into large language models. AI output may be inaccu
165
165
  **Copyright © 2026 Promptowl LLC.** All rights reserved.
166
166
  "ContextNest" and "PromptOwl" are trademarks of Promptowl LLC.
167
167
 
168
- Promptowl LLC · 3060 Mercer University Dr Ste 110 · Atlanta, GA 30341 · USA
168
+ Promptowl LLC · 3060 Mercer University Dr Ste 110 · Atlanta, GA 30341 · USA
@@ -11,7 +11,7 @@ import {
11
11
  resolveNestPermission,
12
12
  resolveTeamRolesForUser,
13
13
  sendEmailToRecipient
14
- } from "./chunk-G4AL7ETF.js";
14
+ } from "./chunk-QLXC6542.js";
15
15
  import {
16
16
  ConflictError,
17
17
  ValidationError
@@ -20,7 +20,7 @@ import {
20
20
  config,
21
21
  getDb,
22
22
  isEmailish
23
- } from "./chunk-SKZ7YYT4.js";
23
+ } from "./chunk-QWNXWRWO.js";
24
24
 
25
25
  // src/governance/stewardship-service.ts
26
26
  import { v4 as uuid } from "uuid";
@@ -2,10 +2,10 @@ import {
2
2
  buildTitleMap,
3
3
  insertOrReplace,
4
4
  nowExpr
5
- } from "./chunk-G4AL7ETF.js";
5
+ } from "./chunk-QLXC6542.js";
6
6
  import {
7
7
  getDb
8
- } from "./chunk-SKZ7YYT4.js";
8
+ } from "./chunk-QWNXWRWO.js";
9
9
 
10
10
  // src/governance/version-service.ts
11
11
  import { createHash } from "crypto";
@@ -4,19 +4,19 @@ import {
4
4
  resolveNestWideRoles,
5
5
  resolveStewardsForNode,
6
6
  stewardCoverageForUser
7
- } from "./chunk-LL2JU5AA.js";
7
+ } from "./chunk-2YPY2HGZ.js";
8
8
  import {
9
9
  grantCoversNode,
10
10
  listUserGrants,
11
11
  resolveNodeGrant
12
- } from "./chunk-IVXVOXYN.js";
12
+ } from "./chunk-UOMW7TT6.js";
13
13
  import {
14
14
  createVersion,
15
15
  getApprovedVersion,
16
16
  getApprovedVersions,
17
17
  getCurrentVersion,
18
18
  setApprovedVersion
19
- } from "./chunk-FXYFLUE7.js";
19
+ } from "./chunk-5CVZMHHB.js";
20
20
  import {
21
21
  buildDocContext,
22
22
  buildTitleMap,
@@ -26,6 +26,7 @@ import {
26
26
  insertOrReplace,
27
27
  isPublicReader,
28
28
  isStewardshipEnabled,
29
+ markNotificationsReadBySubject,
29
30
  nestName,
30
31
  notifyEmailForNest,
31
32
  notifyReviewRequested,
@@ -36,7 +37,7 @@ import {
36
37
  resolveNestPermission,
37
38
  sendEmailToRecipient,
38
39
  titleForNode
39
- } from "./chunk-G4AL7ETF.js";
40
+ } from "./chunk-QLXC6542.js";
40
41
  import {
41
42
  ConflictError,
42
43
  NotFoundError,
@@ -45,7 +46,7 @@ import {
45
46
  import {
46
47
  config,
47
48
  getDb
48
- } from "./chunk-SKZ7YYT4.js";
49
+ } from "./chunk-QWNXWRWO.js";
49
50
 
50
51
  // src/governance/review-service.ts
51
52
  import { v4 as uuid2 } from "uuid";
@@ -131,7 +132,17 @@ async function nodeTrail(db, nestId, ids) {
131
132
  for (const r of rows) if (r.title) titles.set(r.node_id, r.title);
132
133
  } catch {
133
134
  }
134
- return ids.map((id) => `"${titles.get(id) ?? id}"`).join(" \u2192 ");
135
+ const label = (id) => titles.get(id) ?? id;
136
+ const idsPerLabel = /* @__PURE__ */ new Map();
137
+ for (const id of unique) {
138
+ const l = label(id);
139
+ (idsPerLabel.get(l) ?? idsPerLabel.set(l, /* @__PURE__ */ new Set()).get(l)).add(id);
140
+ }
141
+ return ids.map((id) => {
142
+ const l = label(id);
143
+ const ambiguous = (idsPerLabel.get(l)?.size ?? 0) > 1;
144
+ return ambiguous && l !== id ? `"${l}" (${id})` : `"${l}"`;
145
+ }).join(" \u2192 ");
135
146
  }
136
147
  async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
137
148
  const db = getDb();
@@ -147,7 +158,9 @@ async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
147
158
  for (const r of rows) {
148
159
  if (r.from_node === r.to_node) {
149
160
  throw new ConflictError(
150
- `cycle: ${await nodeTrail(db, nestId, [r.from_node])} cannot flow into itself`
161
+ `This step loops back on itself: ${await nodeTrail(db, nestId, [r.from_node])}.
162
+
163
+ A flow can't return to a step it already ran.`
151
164
  );
152
165
  }
153
166
  nodes.add(r.from_node);
@@ -170,7 +183,7 @@ async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
170
183
  }
171
184
  if (removed < nodes.size) {
172
185
  throw new ConflictError(
173
- "cycle: enabling flow on this edge type would create a cycle in the flow graph \u2014 flow edges must form a DAG"
186
+ "Making this connection type a flow would loop these steps back on each other. A flow can't return to a step it already ran."
174
187
  );
175
188
  }
176
189
  }
@@ -534,6 +547,7 @@ async function notifySlack(text) {
534
547
 
535
548
  // src/notify/dispatch.ts
536
549
  var EVENT_EMOJI = {
550
+ mention: ":speech_balloon:",
537
551
  review_requested: ":memo:",
538
552
  review_approved: ":white_check_mark:",
539
553
  review_rejected: ":x:",
@@ -600,6 +614,27 @@ async function resolveUrl(nestId, raw) {
600
614
  }
601
615
  return isSafeConnectorUrl(raw) ? raw : null;
602
616
  }
617
+ async function hasAnyConnector(nestId, kind) {
618
+ if ((config.SLACK_WEBHOOK_URL || config.MSTEAMS_WEBHOOK_URL) && !DIGEST_DELIVERED_KINDS.has(kind)) {
619
+ return true;
620
+ }
621
+ try {
622
+ const rows = await getDb().all(
623
+ "SELECT events FROM connectors WHERE nest_id = ? AND enabled = 1",
624
+ [nestId]
625
+ );
626
+ return rows.some((r) => {
627
+ try {
628
+ const kinds = JSON.parse(r.events);
629
+ return kinds.includes("*") || kinds.includes(kind);
630
+ } catch {
631
+ return false;
632
+ }
633
+ });
634
+ } catch {
635
+ return false;
636
+ }
637
+ }
603
638
  async function dispatchEvent(ev) {
604
639
  const db = getDb();
605
640
  const nestName2 = await resolveNestName(ev.nestId);
@@ -1201,7 +1236,8 @@ async function deletionTargetContext(request, baseUrl) {
1201
1236
  async function notifyDeletionRequested(request, baseUrl) {
1202
1237
  try {
1203
1238
  const ctx = await deletionTargetContext(request, baseUrl);
1204
- const line = `Deletion requested for ${ctx.noun} *${ctx.label}* by ${request.requestedBy} \u2014 "${request.reason}"`;
1239
+ const isArchiveRequest = request.targetType === "nest_archive";
1240
+ const line = isArchiveRequest ? `Archive requested for ${ctx.noun} *${ctx.label}* by ${request.requestedBy} \u2014 "${request.reason}"` : `Deletion requested for ${ctx.noun} *${ctx.label}* by ${request.requestedBy} \u2014 "${request.reason}"`;
1205
1241
  const admins = (await nestAdminEmails(request.nestId)).filter(
1206
1242
  (e) => e !== request.requestedBy.toLowerCase()
1207
1243
  );
@@ -1210,7 +1246,7 @@ async function notifyDeletionRequested(request, baseUrl) {
1210
1246
  admins,
1211
1247
  "deletion_requested",
1212
1248
  request.id,
1213
- `${ctx.noun === "document" ? "" : `${ctx.noun} `}"${ctx.docTitle}" was flagged for deletion by ${request.requestedBy} \u2014 "${request.reason}"`
1249
+ isArchiveRequest ? `${ctx.noun} "${ctx.docTitle}" was requested to be archived by ${request.requestedBy} \u2014 "${request.reason}"` : `${ctx.noun === "document" ? "" : `${ctx.noun} `}"${ctx.docTitle}" was flagged for deletion by ${request.requestedBy} \u2014 "${request.reason}"`
1214
1250
  );
1215
1251
  void dispatchEvent({
1216
1252
  kind: "deletion_requested",
@@ -1257,9 +1293,11 @@ async function notifyDeletionResolved(params) {
1257
1293
  try {
1258
1294
  const { outcome, resolvedBy, note } = params;
1259
1295
  const archivedInstead = outcome === "deleted" && params.targetType === "nest_archive";
1260
- const verb = archivedInstead ? "archived" : outcome === "deleted" ? "deleted" : "rejected the deletion of";
1296
+ const isArchiveRequest = params.targetType === "nest_archive";
1297
+ const verb = archivedInstead ? "archived" : outcome === "deleted" ? "deleted" : isArchiveRequest ? "declined the archive request for" : "rejected the deletion of";
1261
1298
  const line = `${resolvedBy} ${verb} *${params.docTitle}*${note ? ` \u2014 "${note}"` : ""}`;
1262
1299
  const recipient = params.requestedBy.toLowerCase();
1300
+ await markNotificationsReadBySubject(params.requestId, "deletion_requested");
1263
1301
  const canInbox = !(params.targetType === "nest" && outcome === "deleted");
1264
1302
  if (canInbox && recipient !== resolvedBy.toLowerCase()) {
1265
1303
  await inboxInsert(
@@ -1267,7 +1305,7 @@ async function notifyDeletionResolved(params) {
1267
1305
  [recipient],
1268
1306
  `deletion_${outcome}`,
1269
1307
  params.requestId,
1270
- outcome === "deleted" ? `"${params.docTitle}" was ${archivedInstead ? "archived" : "deleted"} by ${resolvedBy} \u2014 your request was accepted` : `Your deletion request for "${params.docTitle}" was rejected by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
1308
+ outcome === "deleted" ? `"${params.docTitle}" was ${archivedInstead ? "archived" : "deleted"} by ${resolvedBy} \u2014 your request was accepted` : `Your ${isArchiveRequest ? "archive" : "deletion"} request for "${params.docTitle}" was rejected by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
1271
1309
  );
1272
1310
  }
1273
1311
  void dispatchEvent({
@@ -1436,6 +1474,7 @@ async function declineDeletion(params) {
1436
1474
  requestedBy: declined.requestedBy,
1437
1475
  resolvedBy: params.declinedBy,
1438
1476
  requestId: declined.id,
1477
+ targetType: declined.targetType,
1439
1478
  note: params.note,
1440
1479
  baseUrl: params.baseUrl
1441
1480
  });
@@ -1497,6 +1536,7 @@ export {
1497
1536
  envRoutes,
1498
1537
  envValues,
1499
1538
  isSafeConnectorUrl,
1539
+ hasAnyConnector,
1500
1540
  dispatchEvent,
1501
1541
  sendTestNotification,
1502
1542
  notifyNestEvent,
@@ -8,7 +8,7 @@ import {
8
8
  config,
9
9
  getDb,
10
10
  isEmailish
11
- } from "./chunk-SKZ7YYT4.js";
11
+ } from "./chunk-QWNXWRWO.js";
12
12
  import {
13
13
  ANON_USER_ID
14
14
  } from "./chunk-YB3LKF7U.js";
@@ -138,6 +138,12 @@ function startTelemetryLoop() {
138
138
  function nowExpr(db) {
139
139
  return db.dialect === "sqlite" ? "datetime('now')" : "to_char((now() AT TIME ZONE 'utc'), 'YYYY-MM-DD HH24:MI:SS')";
140
140
  }
141
+ function suffixOf(db, column, length) {
142
+ return db.dialect === "sqlite" ? `substr(${column}, -${length})` : `right(${column}, ${length})`;
143
+ }
144
+ function nowMsExpr(db) {
145
+ return db.dialect === "sqlite" ? "strftime('%Y-%m-%d %H:%M:%f','now')" : "to_char((now() AT TIME ZONE 'utc'), 'YYYY-MM-DD HH24:MI:SS.MS')";
146
+ }
141
147
  function insertOrIgnore(db, insertSql) {
142
148
  return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR IGNORE INTO") : `${insertSql} ON CONFLICT DO NOTHING`;
143
149
  }
@@ -657,6 +663,7 @@ function renderText(message) {
657
663
  }
658
664
  var STATUS_META = {
659
665
  approved: { subjectWord: "approved", label: "Approved", color: "#16a34a" },
666
+ mention: { subjectWord: "mentioned you", label: "Mention", color: "#7c3aed" },
660
667
  rejected: { subjectWord: "rejected", label: "Rejected", color: "#dc2626" },
661
668
  pending_review: {
662
669
  subjectWord: "awaiting review",
@@ -715,6 +722,17 @@ var TEMPLATES = {
715
722
  lineH: (x) => `${x.doc}${x.vH} has been approved${x.byH}${x.noteH}.`,
716
723
  rows: governanceRows
717
724
  },
725
+ // A mention names one person, so this is the one template addressed to
726
+ // "you". `note` carries the comment's own words when there are any — a
727
+ // mention with no context reads as a summons.
728
+ mention: {
729
+ heading: (x) => `${x.d.actor || "Someone"} mentioned you in ${x.title}`,
730
+ detailsHeader: "Document details",
731
+ linkLabel: "Open document",
732
+ lineT: (x) => `You were mentioned in ${x.title}${x.byT}${x.noteT}.`,
733
+ lineH: (x) => `You were mentioned in ${x.doc}${x.byH}${x.noteH}.`,
734
+ rows: (x) => [plainRow("Nest", x.d.path)]
735
+ },
718
736
  rejected: {
719
737
  heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
720
738
  detailsHeader: "Document details",
@@ -1304,6 +1322,12 @@ async function markNotificationsRead(userEmail, ids) {
1304
1322
  );
1305
1323
  }
1306
1324
  }
1325
+ async function markNotificationsReadBySubject(subjectId, kind) {
1326
+ await getDb().run(
1327
+ "UPDATE notifications SET read_at = ? WHERE subject_id = ? AND kind = ? AND read_at IS NULL",
1328
+ [(/* @__PURE__ */ new Date()).toISOString(), subjectId, kind]
1329
+ );
1330
+ }
1307
1331
 
1308
1332
  // src/governance/teams-service.ts
1309
1333
  var VALID_TEAM_ROLES = ["admin", "editor", "viewer"];
@@ -1879,8 +1903,9 @@ async function resolveNestAccessRaw(nestId, userId) {
1879
1903
  return { ...base, permission: best, isPublicReader: false };
1880
1904
  }
1881
1905
  if (base.isSteward) best = higher(best, "read");
1882
- if (row.visibility === "public") best = higher(best, "read");
1883
- const isPublicReader2 = row.visibility === "public" && !row.collab_permission && !base.isSteward;
1906
+ const visibilityRead = row.visibility === "public" || row.visibility === "org" && userId !== ANON_USER_ID;
1907
+ if (visibilityRead) best = higher(best, "read");
1908
+ const isPublicReader2 = visibilityRead && !row.collab_permission && !base.isSteward;
1884
1909
  return { ...base, permission: best, isPublicReader: isPublicReader2 };
1885
1910
  }
1886
1911
  async function resolveNestPermission(nestId, userId) {
@@ -2194,22 +2219,23 @@ var LIVE_ONLY_N = "n.archived_at IS NULL";
2194
2219
  async function listNests(userId, opts = {}) {
2195
2220
  const db = getDb();
2196
2221
  const scope = opts.archived ? "archived_at IS NOT NULL" : LIVE_ONLY;
2222
+ const orderBy = opts.archived ? "archived_at DESC" : "created_at DESC";
2197
2223
  if (userId === ANON_USER_ID) {
2198
2224
  return await db.all(
2199
- `SELECT * FROM nests WHERE user_id = ? AND ${scope} ORDER BY created_at DESC`,
2225
+ `SELECT * FROM nests WHERE user_id = ? AND ${scope} ORDER BY ${orderBy}`,
2200
2226
  [ANON_USER_ID]
2201
2227
  );
2202
2228
  }
2203
2229
  const includeAnon = config.AUTH_MODE === "open" || await isLicenseAdminUserId(userId);
2204
2230
  if (!includeAnon) {
2205
2231
  return await db.all(
2206
- `SELECT * FROM nests WHERE user_id = ? AND ${scope} ORDER BY created_at DESC`,
2232
+ `SELECT * FROM nests WHERE user_id = ? AND ${scope} ORDER BY ${orderBy}`,
2207
2233
  [userId]
2208
2234
  );
2209
2235
  }
2210
2236
  return await db.all(
2211
2237
  `SELECT * FROM nests WHERE (user_id = ? OR user_id = ?) AND ${scope}
2212
- ORDER BY created_at DESC`,
2238
+ ORDER BY ${orderBy}`,
2213
2239
  [userId, ANON_USER_ID]
2214
2240
  );
2215
2241
  }
@@ -2309,11 +2335,13 @@ async function listSharedNests(userId) {
2309
2335
  async function listVisibleNests(userId, opts = {}) {
2310
2336
  const db = getDb();
2311
2337
  const includeAnon = config.AUTH_MODE === "open" || await isServerAdminUserId(userId);
2338
+ const visibleByVisibility = userId === ANON_USER_ID ? ["public"] : ["public", "org"];
2312
2339
  const params = [
2313
2340
  userId,
2314
2341
  userId,
2315
2342
  ...opts.readable ? [userId] : [],
2316
2343
  userId,
2344
+ ...opts.readable ? visibleByVisibility : [],
2317
2345
  ...includeAnon ? [ANON_USER_ID] : []
2318
2346
  ];
2319
2347
  const rows = await db.all(
@@ -2329,7 +2357,7 @@ async function listVisibleNests(userId, opts = {}) {
2329
2357
  AND (n.user_id = ?
2330
2358
  OR nc.user_id IS NOT NULL
2331
2359
  OR s.id IS NOT NULL
2332
- ${opts.readable ? "OR g.id IS NOT NULL OR n.visibility = 'public'" : ""}
2360
+ ${opts.readable ? `OR g.id IS NOT NULL OR n.visibility IN (${visibleByVisibility.map(() => "?").join(", ")})` : ""}
2333
2361
  ${includeAnon ? "OR n.user_id = ?" : ""})
2334
2362
  ORDER BY n.created_at DESC`,
2335
2363
  params
@@ -2345,9 +2373,10 @@ async function listVisibleNests(userId, opts = {}) {
2345
2373
  }
2346
2374
  async function listPublicNests(userId) {
2347
2375
  const db = getDb();
2376
+ const visibilities = userId === ANON_USER_ID ? ["public"] : ["public", "org"];
2348
2377
  return await db.all(
2349
2378
  `SELECT n.* FROM nests n
2350
- WHERE n.visibility = 'public'
2379
+ WHERE n.visibility IN (${visibilities.map(() => "?").join(", ")})
2351
2380
  AND ${LIVE_ONLY_N}
2352
2381
  AND n.user_id != ?
2353
2382
  AND NOT EXISTS (
@@ -2355,7 +2384,7 @@ async function listPublicNests(userId) {
2355
2384
  WHERE nc.nest_id = n.id AND nc.user_id = ?
2356
2385
  )
2357
2386
  ORDER BY n.created_at DESC`,
2358
- [userId, userId]
2387
+ [...visibilities, userId, userId]
2359
2388
  );
2360
2389
  }
2361
2390
  async function getNest(nestId) {
@@ -2587,8 +2616,8 @@ async function ensureNodeIndex(nestId) {
2587
2616
  return run;
2588
2617
  }
2589
2618
  async function rebuildNodeIndex(nestId) {
2590
- const { engineCache: engineCache2 } = await import("./engine-WXWER2V7.js");
2591
- const { documentsWithSuggestions } = await import("./external-edit-service-Y757EFMS.js");
2619
+ const { engineCache: engineCache2 } = await import("./engine-QFF2IA3G.js");
2620
+ const { documentsWithSuggestions } = await import("./external-edit-service-F7D3VSA2.js");
2592
2621
  const { storage, dropDiscoveryCache } = await engineCache2.get(nestId);
2593
2622
  dropDiscoveryCache();
2594
2623
  const startedAt = writeGeneration.get(nestId) ?? 0;
@@ -2822,6 +2851,8 @@ async function opContext(nestId, actor, onProgress) {
2822
2851
 
2823
2852
  export {
2824
2853
  nowExpr,
2854
+ suffixOf,
2855
+ nowMsExpr,
2825
2856
  insertOrIgnore,
2826
2857
  insertOrReplace,
2827
2858
  trackEvent,
@@ -2910,6 +2941,7 @@ export {
2910
2941
  notifyNestInvite,
2911
2942
  listNotifications,
2912
2943
  markNotificationsRead,
2944
+ markNotificationsReadBySubject,
2913
2945
  createTeam,
2914
2946
  findTeamByExternalId,
2915
2947
  listTeamsForUser,
@@ -334,6 +334,28 @@ var config = {
334
334
  get OIDC_DEPARTMENT_TAGGING() {
335
335
  return process.env.OIDC_DEPARTMENT_TAGGING === "true";
336
336
  },
337
+ /**
338
+ * Widen the add-a-person suggestions (GET /people/suggest) from "people you
339
+ * already work with" to every registered account on this server.
340
+ *
341
+ * OFF BY DEFAULT, and the default is the point. On a single-company
342
+ * self-hosted server every account is a colleague, so the whole directory is
343
+ * the useful answer and this saves asking someone for their address. On the
344
+ * PromptOwl-hosted deployment, one server holds many unrelated customers —
345
+ * so the same list is one customer's staff being offered another customer's
346
+ * email addresses. The hosted deployment leaves this unset; a self-hosted
347
+ * operator who knows their user table is their own staff turns it on.
348
+ *
349
+ * Narrows nothing when off: the default scoping (shared nests and teams,
350
+ * plus everything for a server admin, who administers the accounts anyway)
351
+ * is unchanged. Suggestions never gate the add either way — an address that
352
+ * appears in no list is still valid to type, so this only ever affects what
353
+ * is *offered*, never who can be invited. Superadmin-togglable at runtime
354
+ * via /admin/settings.
355
+ */
356
+ get PEOPLE_SUGGEST_ALL_USERS() {
357
+ return process.env.PEOPLE_SUGGEST_ALL_USERS === "true";
358
+ },
337
359
  /**
338
360
  * Path to the .env file the server reads its config from and the license
339
361
  * install flow persists PROMPTOWL_KEY into. Defaults UNDER DATA_ROOT (not
@@ -351,6 +373,19 @@ var config = {
351
373
  get TELEMETRY_INTERVAL_MS() {
352
374
  return parseInt(process.env.TELEMETRY_INTERVAL_MS || "3600000", 10);
353
375
  },
376
+ /**
377
+ * PostHog product analytics. Empty key = off, which is the default: a
378
+ * self-hosted operator brings their own project. The key is a public client
379
+ * key by design (the browser sends it), so /health may hand it out — but
380
+ * only while TELEMETRY_ENABLED is on, so one switch stops all outbound
381
+ * usage data. Editable at runtime from the admin Settings page.
382
+ */
383
+ get POSTHOG_KEY() {
384
+ return (process.env.POSTHOG_KEY || "").trim();
385
+ },
386
+ get POSTHOG_HOST() {
387
+ return (process.env.POSTHOG_HOST || "").trim() || "https://us.i.posthog.com";
388
+ },
354
389
  /**
355
390
  * Activity-trace retention window in days (the api_events table behind
356
391
  * GET /admin/trace and GET /nests/:id/trace). 0 = keep forever (pruning is
@@ -626,7 +661,7 @@ function runMigrations(db) {
626
661
  slug TEXT NOT NULL,
627
662
  description TEXT,
628
663
  visibility TEXT NOT NULL DEFAULT 'private'
629
- CHECK(visibility IN ('private', 'public')),
664
+ CHECK(visibility IN ('private', 'org', 'public')),
630
665
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
631
666
  UNIQUE(user_id, slug)
632
667
  );
@@ -1702,6 +1737,83 @@ function runMigrations(db) {
1702
1737
  })();
1703
1738
  recordMigration("039_nest_pins");
1704
1739
  }
1740
+ if (!hasMigration("041_nest_visibility_org")) {
1741
+ widenNestVisibilityWithGuard(db);
1742
+ recordMigration("041_nest_visibility_org");
1743
+ }
1744
+ if (!hasMigration("042_community_sites")) {
1745
+ db.transaction(() => {
1746
+ db.exec(`
1747
+ CREATE TABLE IF NOT EXISTS community_sites (
1748
+ id TEXT PRIMARY KEY,
1749
+ name TEXT NOT NULL,
1750
+ url TEXT NOT NULL UNIQUE,
1751
+ secret TEXT NOT NULL,
1752
+ is_active INTEGER NOT NULL DEFAULT 1,
1753
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1754
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1755
+ );
1756
+ `);
1757
+ recordMigration("042_community_sites");
1758
+ })();
1759
+ }
1760
+ }
1761
+ function fkViolationCounts(db) {
1762
+ const rows = db.pragma("foreign_key_check");
1763
+ const counts = /* @__PURE__ */ new Map();
1764
+ for (const r of rows) {
1765
+ const key = `${r.table}|${r.parent}|${r.fkid}`;
1766
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1767
+ }
1768
+ return counts;
1769
+ }
1770
+ function widenNestVisibilityWithGuard(db) {
1771
+ const before = fkViolationCounts(db);
1772
+ const fkWasOn = db.pragma("foreign_keys", { simple: true }) === 1;
1773
+ if (fkWasOn) db.pragma("foreign_keys = OFF");
1774
+ try {
1775
+ db.transaction(() => {
1776
+ widenNestVisibilityCheck(db);
1777
+ })();
1778
+ const after = fkViolationCounts(db);
1779
+ const introduced = [...after].filter(
1780
+ ([key, n]) => n > (before.get(key) ?? 0)
1781
+ );
1782
+ if (introduced.length > 0) {
1783
+ throw new Error(
1784
+ `041_nest_visibility_org introduced foreign-key violations: ${introduced.map(([key]) => key).join(", ")}`
1785
+ );
1786
+ }
1787
+ } finally {
1788
+ if (fkWasOn) db.pragma("foreign_keys = ON");
1789
+ }
1790
+ }
1791
+ function widenNestVisibilityCheck(db) {
1792
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'nests'").get();
1793
+ const ddl = row?.sql;
1794
+ if (!ddl) return;
1795
+ const checkRe = /CHECK\s*\(\s*visibility\s+IN\s*\([^)]*\)\s*\)/i;
1796
+ const current = ddl.match(checkRe);
1797
+ if (!current) return;
1798
+ if (/'org'/i.test(current[0])) return;
1799
+ const newDdl = ddl.replace(
1800
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`\[]?nests["'`\]]?/i,
1801
+ "CREATE TABLE nests_new"
1802
+ ).replace(checkRe, "CHECK(visibility IN ('private', 'org', 'public'))");
1803
+ if (!newDdl.startsWith("CREATE TABLE nests_new")) {
1804
+ throw new Error("041: could not rewrite the nests CREATE TABLE statement");
1805
+ }
1806
+ const cols = db.prepare("PRAGMA table_info(nests)").all().map((c) => `"${c.name}"`).join(", ");
1807
+ const indexes = db.prepare(
1808
+ "SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'nests' AND sql IS NOT NULL"
1809
+ ).all().map((r) => `${r.sql};`);
1810
+ db.exec(`
1811
+ ${newDdl};
1812
+ INSERT INTO nests_new (${cols}) SELECT ${cols} FROM nests;
1813
+ DROP TABLE nests;
1814
+ ALTER TABLE nests_new RENAME TO nests;
1815
+ ${indexes.join("\n ")}
1816
+ `);
1705
1817
  }
1706
1818
  function mergeCaseCollidingUsers(db) {
1707
1819
  const groups = db.prepare(
@@ -1919,7 +2031,7 @@ async function initDb() {
1919
2031
  if (config.DB_DRIVER === "postgres") {
1920
2032
  const { Pool } = await import("pg");
1921
2033
  const { PostgresAdapter } = await import("./adapter.postgres-6VZCMPQL.js");
1922
- const { runPostgresMigrations } = await import("./migrations.postgres-JA2YGLS3.js");
2034
+ const { runPostgresMigrations } = await import("./migrations.postgres-APCVSYUE.js");
1923
2035
  const pool = new Pool(buildPgConfig());
1924
2036
  adapter = new PostgresAdapter(pool);
1925
2037
  await runPostgresMigrations(adapter);
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-YVMSM7LS.js";
4
4
  import {
5
5
  getDb
6
- } from "./chunk-SKZ7YYT4.js";
6
+ } from "./chunk-QWNXWRWO.js";
7
7
 
8
8
  // src/governance/grants-service.ts
9
9
  import { v4 as uuid } from "uuid";
@@ -5,10 +5,10 @@ import {
5
5
  markIndexStale,
6
6
  resolveNestPath,
7
7
  setSuggestionFlag
8
- } from "./chunk-G4AL7ETF.js";
8
+ } from "./chunk-QLXC6542.js";
9
9
  import {
10
10
  getDb
11
- } from "./chunk-SKZ7YYT4.js";
11
+ } from "./chunk-QWNXWRWO.js";
12
12
 
13
13
  // src/governance/external-edit-service.ts
14
14
  import { readFile, readdir } from "fs/promises";
@@ -82,7 +82,7 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
82
82
  if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
83
83
  return null;
84
84
  }
85
- const { hasUnsealedDraft } = await import("./version-service-GWSDSUZR.js");
85
+ const { hasUnsealedDraft } = await import("./version-service-RJ6CWZ3O.js");
86
86
  if (await hasUnsealedDraft(nestId, documentId)) {
87
87
  return null;
88
88
  }
@@ -259,7 +259,7 @@ async function listExternalEditVerdicts(nestId, documentId) {
259
259
  async function mirrorVersion(input) {
260
260
  const { storage } = await engineCache.get(input.nestId);
261
261
  const node = await storage.readDocument(input.documentId);
262
- const { upsertVersion, setApprovedVersion } = await import("./version-service-GWSDSUZR.js");
262
+ const { upsertVersion, setApprovedVersion } = await import("./version-service-RJ6CWZ3O.js");
263
263
  await upsertVersion({
264
264
  nestId: input.nestId,
265
265
  nodeId: input.documentId,
@@ -2,7 +2,7 @@ import {
2
2
  getDb,
3
3
  initDb,
4
4
  resetDb
5
- } from "./chunk-SKZ7YYT4.js";
5
+ } from "./chunk-QWNXWRWO.js";
6
6
  import "./chunk-YB3LKF7U.js";
7
7
  export {
8
8
  getDb,
@@ -2,9 +2,9 @@ import {
2
2
  engineApi,
3
3
  engineCache,
4
4
  opContext
5
- } from "./chunk-G4AL7ETF.js";
5
+ } from "./chunk-QLXC6542.js";
6
6
  import "./chunk-YVMSM7LS.js";
7
- import "./chunk-SKZ7YYT4.js";
7
+ import "./chunk-QWNXWRWO.js";
8
8
  import "./chunk-YB3LKF7U.js";
9
9
  import "./chunk-FRQJWGN3.js";
10
10
  export {