@promptowl/contextnest-community 1.19.0 → 1.21.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 +83 -1
- package/README.md +1 -1
- package/dist/{chunk-FBDEESNJ.js → chunk-4KJGQHWB.js} +113 -218
- package/dist/{chunk-ENDHMQPD.js → chunk-6JGQX4GA.js} +415 -64
- package/dist/{chunk-TNAZJYL5.js → chunk-6PJQSKKV.js} +12 -4
- package/dist/{chunk-BXUVIAMP.js → chunk-IGPJ74O4.js} +60 -12
- package/dist/{chunk-HYSTFMFG.js → chunk-LPPKPEYI.js} +131 -1
- package/dist/{chunk-3GMGLYTZ.js → chunk-N2DTJFCD.js} +2 -2
- package/dist/{chunk-GSYMJ3A4.js → chunk-XWGEXQU3.js} +6 -2
- package/dist/{client-SW5I6ZBK.js → client-BD6PAUCZ.js} +1 -1
- package/dist/{engine-V4DY4PYN.js → engine-IKZQ46P7.js} +2 -2
- package/dist/{external-edit-service-3NK5DGLA.js → external-edit-service-LTON57HO.js} +3 -3
- package/dist/{grants-service-CADI6LIB.js → grants-service-LSNZN6NG.js} +2 -2
- package/dist/index.js +2704 -1156
- package/dist/{migrations.postgres-5VI4RDTS.js → migrations.postgres-JA2YGLS3.js} +66 -1
- package/dist/{review-service-LK4M2C43.js → review-service-6JK64IRT.js} +8 -6
- package/dist/{stewardship-service-JAFSU5GN.js → stewardship-service-2YKGBP63.js} +3 -3
- package/dist/{version-service-G7SFFKFO.js → version-service-PJV74PVQ.js} +11 -3
- package/dist/web3/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/web3/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/web3/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/web3/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/web3/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/web3/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/web3/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/web3/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/web3/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/web3/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/web3/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/web3/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/web3/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/web3/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/web3/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/web3/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/web3/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/web3/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/web3/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/web3/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/web3/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/web3/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/web3/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/web3/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/web3/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/web3/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/web3/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/web3/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/web3/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/web3/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/web3/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/web3/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/web3/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/web3/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/web3/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/web3/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/web3/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/web3/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/web3/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/web3/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/web3/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/web3/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/web3/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/web3/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/web3/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/web3/assets/index-T4pilFNL.css +1 -0
- package/dist/web3/assets/index-deyhV93k.js +1382 -0
- package/dist/web3/index.html +2 -2
- package/package.json +6 -1
- package/dist/web3/assets/index-C26nM5Kn.css +0 -1
- package/dist/web3/assets/index-C4oalc2K.js +0 -1065
package/CONFIGURATION.md
CHANGED
|
@@ -60,7 +60,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
|
|
|
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
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. |
|
|
63
|
-
| `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`).
|
|
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`. |
|
|
66
66
|
| `OIDC_CLIENT_ID` | `""` | Application (client) ID from your IdP app registration. |
|
|
@@ -68,6 +68,11 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
|
|
|
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
70
|
| `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
|
+
| `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
|
+
| `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. |
|
|
73
|
+
| `MCP_TOKEN_TTL_SECONDS` | `300` | Lifetime of a minted MCP bearer, clamped to 30–3600. |
|
|
74
|
+
| `MS_TOKEN_EXCHANGE_ENABLED` / `MS_CLIENT_ID` / `MS_TENANT_ID` | `false` / `""` / `""` | Microsoft Entra provider. `MS_CLIENT_ID` is the audience an inbound token must carry; `MS_TENANT_ID` must be the directory **GUID** (it builds the required issuer `https://login.microsoftonline.com/<tenant>/v2.0` — `common`/`organizations`/a domain name will not match the discovery document and every exchange returns `provider_misconfigured`). |
|
|
75
|
+
| `GOOGLE_TOKEN_EXCHANGE_ENABLED` / `GOOGLE_CLIENT_ID` | `false` / `""` | Google provider. Issuer is fixed (`https://accounts.google.com`); `GOOGLE_CLIENT_ID` is the audience an inbound token must carry. |
|
|
71
76
|
| `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`. |
|
|
72
77
|
| `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
|
|
73
78
|
| `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
|
|
@@ -264,6 +269,83 @@ a toast; codes: `disabled`, `not_configured`, `discovery_failed`,
|
|
|
264
269
|
|
|
265
270
|
---
|
|
266
271
|
|
|
272
|
+
## Agent SSO (token exchange)
|
|
273
|
+
|
|
274
|
+
`POST /auth/token-exchange` lets an external agent, acting for a user your IdP
|
|
275
|
+
(Microsoft Entra / Google) has **already** authenticated, exchange that IdP's ID
|
|
276
|
+
token for a short-lived MCP bearer — no second login, no static API key.
|
|
277
|
+
RFC 8693-shaped. **Off by default and security-critical**: it trusts a token
|
|
278
|
+
minted elsewhere and hands back access, so read this section before enabling it.
|
|
279
|
+
|
|
280
|
+
Configure it with the `SSO_TOKEN_EXCHANGE_ENABLED` / `MCP_SIGNING_SECRET` /
|
|
281
|
+
`MS_*` / `GOOGLE_*` vars above, or from **Settings → Single sign-on → Agent SSO**
|
|
282
|
+
(superadmin). It reuses `OIDC_ALLOWED_DOMAINS` and `OIDC_AUTO_PROVISION` — the
|
|
283
|
+
same who-may-sign-in policy as browser login.
|
|
284
|
+
|
|
285
|
+
**Who this is for — and who it is not.** The minted bearer is short-lived by
|
|
286
|
+
design (`MCP_TOKEN_TTL_SECONDS`, default 300s, hard ceiling 3600s), and there is
|
|
287
|
+
no refresh token: renewal means calling `POST /auth/token-exchange` again with
|
|
288
|
+
the IdP token. So this fits a **programmatic** client — an agent runtime, a
|
|
289
|
+
backend service, a desktop app that already did an MSAL/Google sign-in — that
|
|
290
|
+
can re-exchange in code before a session or on a `401`, with no human involved.
|
|
291
|
+
|
|
292
|
+
It does **not** fit a client whose credential is typed once into a config file
|
|
293
|
+
(Claude Desktop, Cursor, any `mcpServers` JSON block): nothing here rewrites that
|
|
294
|
+
file, and the ceiling is an hour, so the connection would break repeatedly. Those
|
|
295
|
+
clients keep using a long-lived API key (`cnst_…`) — this endpoint is additive
|
|
296
|
+
and changes nothing about API-key or browser sign-in.
|
|
297
|
+
|
|
298
|
+
**What is verified** on the inbound `subject_token`, before anything is minted:
|
|
299
|
+
|
|
300
|
+
- **signature** against the provider's JWKS, algorithm pinned to `RS256`
|
|
301
|
+
- **issuer** — exact per provider (the tenant-scoped Entra issuer, or Google's)
|
|
302
|
+
- **audience** — your configured client id, so a token minted for *another*
|
|
303
|
+
application cannot be replayed here
|
|
304
|
+
- **expiry** (30s clock tolerance)
|
|
305
|
+
- the same `email_verified` + domain-allowlist policy as browser login
|
|
306
|
+
(see [Email-verification trust assumption](#email-verification-trust-assumption))
|
|
307
|
+
|
|
308
|
+
**Trust model / documented tradeoff.** Unlike the browser flow there is no
|
|
309
|
+
session to bind, so the OIDC `nonce` binding is intentionally dropped; trust
|
|
310
|
+
rests on signature + issuer + audience + expiry + `email_verified`. That is the
|
|
311
|
+
standard token-exchange model, and it is why the audience check (and a
|
|
312
|
+
correctly scoped IdP app registration) carries the weight here.
|
|
313
|
+
|
|
314
|
+
**Provisioning fails closed.** JIT-provisioning from a token minted elsewhere is
|
|
315
|
+
only allowed when `OIDC_ALLOWED_DOMAINS` bounds who may self-create. With
|
|
316
|
+
auto-provision on and an empty allowlist, every exchange for an unknown email is
|
|
317
|
+
refused with `no_account`, and the Settings page refuses to save that
|
|
318
|
+
combination — otherwise any account at a public IdP (Google = the whole
|
|
319
|
+
internet) could sign itself up and mint a bearer.
|
|
320
|
+
|
|
321
|
+
**Entra caveat (`preferred_username`).** Entra v2 tokens often omit `email` and
|
|
322
|
+
carry the address in `preferred_username`, which is a directory attribute — in a
|
|
323
|
+
tenant where guests or external identities can set their own mail attribute, a
|
|
324
|
+
token can assert an address that matches an existing local account. Pinning
|
|
325
|
+
`MS_TENANT_ID` to your own directory bounds that to identities your tenant
|
|
326
|
+
admits; treat inviting a guest as granting whatever that email address owns here.
|
|
327
|
+
|
|
328
|
+
**Rate limiting.** The per-IP cap on this endpoint reads the client IP from
|
|
329
|
+
`X-Forwarded-For`, so it is only a real cap behind a proxy that overwrites that
|
|
330
|
+
header. Deploy accordingly — an internet-facing server with a pass-through
|
|
331
|
+
header can be sprayed from a single host.
|
|
332
|
+
|
|
333
|
+
**Scope.** The minted bearer carries `scope: "mcp"`. That is a *route*
|
|
334
|
+
allowlist, not a reduced permission set: it reaches `/index`, `/mcp`, and the
|
|
335
|
+
ticket-eligible `/nests/:id/*` routes (including MCP write tools) with the
|
|
336
|
+
user's full stewardship rights. Keep `MCP_TOKEN_TTL_SECONDS` short.
|
|
337
|
+
|
|
338
|
+
**Revocation.** Turning the master flag off stops *minting* but does not revoke
|
|
339
|
+
bearers already issued — they remain valid until they expire (≤ 1h). Clear
|
|
340
|
+
`MCP_SIGNING_SECRET` to invalidate every outstanding bearer at once.
|
|
341
|
+
|
|
342
|
+
Error codes returned by the endpoint: `disabled` (404), `rate_limited` (429),
|
|
343
|
+
`bad_request`, `provider_disabled`, `provider_misconfigured` (400),
|
|
344
|
+
`invalid_subject_token` (401), `email_not_verified`, `domain_not_allowed`,
|
|
345
|
+
`no_account` (403), `server_error` (500).
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
267
349
|
## Database backends
|
|
268
350
|
|
|
269
351
|
The governance/auth metadata (users, sessions, nests registry, stewards, reviews,
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ ContextNest Community Edition is a self-hosted server that lets you:
|
|
|
15
15
|
- Export a nest as a portable bundle and re-import it on another self-hosted host
|
|
16
16
|
- Apply stewardship workflows — draft, pending review, approved
|
|
17
17
|
- Share nests with collaborators or publish them read-only to the public
|
|
18
|
-
- Serve approved context to AI agents via MCP or HTTP — connect one by pasting a generated setup prompt or downloading a ready `.env`
|
|
18
|
+
- Serve approved context to AI agents via MCP or HTTP — connect one by pasting a generated setup prompt or downloading a ready `.env`, or register the nest in the ctx CLI (`ctx vault add <alias> --url <server>/nests/<id>/mcp --bearer-env CONTEXTNEST_API_KEY`, then `ctx query "#tag" --vault <alias>`)
|
|
19
19
|
- Sync with the PromptOwl hosted platform for multi-user collaboration
|
|
20
20
|
|
|
21
21
|
The server runs locally or on your own infrastructure. Your PromptOwl account handles authentication, entitlement, and governance metadata.
|
|
@@ -4,19 +4,19 @@ import {
|
|
|
4
4
|
resolveNestWideRoles,
|
|
5
5
|
resolveStewardsForNode,
|
|
6
6
|
stewardCoverageForUser
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-N2DTJFCD.js";
|
|
8
8
|
import {
|
|
9
9
|
grantCoversNode,
|
|
10
10
|
listUserGrants,
|
|
11
11
|
resolveNodeGrant
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-XWGEXQU3.js";
|
|
13
13
|
import {
|
|
14
14
|
createVersion,
|
|
15
15
|
getApprovedVersion,
|
|
16
16
|
getApprovedVersions,
|
|
17
17
|
getCurrentVersion,
|
|
18
18
|
setApprovedVersion
|
|
19
|
-
} from "./chunk-
|
|
19
|
+
} from "./chunk-IGPJ74O4.js";
|
|
20
20
|
import {
|
|
21
21
|
buildDocContext,
|
|
22
22
|
buildTitleMap,
|
|
@@ -28,14 +28,15 @@ import {
|
|
|
28
28
|
isStewardshipEnabled,
|
|
29
29
|
nestName,
|
|
30
30
|
notifyEmailForNest,
|
|
31
|
+
notifyReviewRequested,
|
|
32
|
+
notifyReviewResolved,
|
|
31
33
|
nowExpr,
|
|
32
34
|
opContext,
|
|
33
|
-
permissionLabel,
|
|
34
35
|
prettyFolderPath,
|
|
35
36
|
resolveNestPermission,
|
|
36
37
|
sendEmailToRecipient,
|
|
37
38
|
titleForNode
|
|
38
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-6JGQX4GA.js";
|
|
39
40
|
import {
|
|
40
41
|
ConflictError,
|
|
41
42
|
NotFoundError,
|
|
@@ -44,175 +45,10 @@ import {
|
|
|
44
45
|
import {
|
|
45
46
|
config,
|
|
46
47
|
getDb
|
|
47
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-LPPKPEYI.js";
|
|
48
49
|
|
|
49
50
|
// src/governance/review-service.ts
|
|
50
|
-
import { v4 as
|
|
51
|
-
|
|
52
|
-
// src/governance/notify-service.ts
|
|
53
|
-
import { v4 as uuid } from "uuid";
|
|
54
|
-
async function listWatchers(nestId, nodeId) {
|
|
55
|
-
return await getDb().all(
|
|
56
|
-
`SELECT id, node_id, user_email, created_by, created_at
|
|
57
|
-
FROM watchers WHERE nest_id = ? AND node_id = ? ORDER BY created_at`,
|
|
58
|
-
[nestId, nodeId]
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
async function addWatcher(nestId, nodeId, userEmail, createdBy) {
|
|
62
|
-
await getDb().run(
|
|
63
|
-
`INSERT INTO watchers (id, nest_id, node_id, user_email, created_by, created_at)
|
|
64
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
65
|
-
ON CONFLICT(nest_id, node_id, user_email) DO NOTHING`,
|
|
66
|
-
[uuid(), nestId, nodeId, userEmail.toLowerCase(), createdBy, (/* @__PURE__ */ new Date()).toISOString()]
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
async function removeWatcher(nestId, nodeId, userEmail) {
|
|
70
|
-
const db = getDb();
|
|
71
|
-
const row = await db.get(
|
|
72
|
-
"SELECT id FROM watchers WHERE nest_id = ? AND node_id = ? AND LOWER(user_email) = LOWER(?)",
|
|
73
|
-
[nestId, nodeId, userEmail]
|
|
74
|
-
);
|
|
75
|
-
if (!row) return false;
|
|
76
|
-
await db.run("DELETE FROM watchers WHERE id = ?", [row.id]);
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
async function notifyReviewRequested(params) {
|
|
80
|
-
const { nestId, nodeId, requestedBy, reviewId } = params;
|
|
81
|
-
const db = getDb();
|
|
82
|
-
try {
|
|
83
|
-
const rows = await db.all(
|
|
84
|
-
`SELECT DISTINCT w.user_email, w.node_id AS via
|
|
85
|
-
FROM watchers w
|
|
86
|
-
WHERE w.nest_id = ?
|
|
87
|
-
AND (w.node_id = ?
|
|
88
|
-
OR w.node_id IN (
|
|
89
|
-
SELECT e.from_node FROM edges e
|
|
90
|
-
JOIN edge_types t ON t.id = e.type_id
|
|
91
|
-
WHERE e.nest_id = ? AND e.to_node = ? AND t.is_flow = 1
|
|
92
|
-
))`,
|
|
93
|
-
[nestId, nodeId, nestId, nodeId]
|
|
94
|
-
);
|
|
95
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
96
|
-
const label = await describeNode(nestId, nodeId);
|
|
97
|
-
let created = 0;
|
|
98
|
-
for (const r of rows) {
|
|
99
|
-
if (r.user_email.toLowerCase() === requestedBy.toLowerCase()) continue;
|
|
100
|
-
await db.run(
|
|
101
|
-
`INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
|
|
102
|
-
VALUES (?, ?, ?, 'review_requested', ?, ?, ?)`,
|
|
103
|
-
[
|
|
104
|
-
uuid(),
|
|
105
|
-
nestId,
|
|
106
|
-
r.user_email.toLowerCase(),
|
|
107
|
-
reviewId,
|
|
108
|
-
r.via === nodeId ? `"${label}" has a new version awaiting review (submitted by ${requestedBy})` : `Agent "${await describeNode(nestId, r.via)}" produced output on "${label}" \u2014 awaiting review`,
|
|
109
|
-
now
|
|
110
|
-
]
|
|
111
|
-
);
|
|
112
|
-
created++;
|
|
113
|
-
}
|
|
114
|
-
return created;
|
|
115
|
-
} catch (err) {
|
|
116
|
-
console.error("[notify] review fan-out failed", nestId, nodeId, err);
|
|
117
|
-
return 0;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
async function notifyReviewResolved(params) {
|
|
121
|
-
const { nestId, nodeId, status, resolvedBy, requestedBy, reviewId } = params;
|
|
122
|
-
const db = getDb();
|
|
123
|
-
try {
|
|
124
|
-
const rows = await db.all(
|
|
125
|
-
`SELECT DISTINCT w.user_email
|
|
126
|
-
FROM watchers w
|
|
127
|
-
WHERE w.nest_id = ?
|
|
128
|
-
AND (w.node_id = ?
|
|
129
|
-
OR w.node_id IN (
|
|
130
|
-
SELECT e.from_node FROM edges e
|
|
131
|
-
JOIN edge_types t ON t.id = e.type_id
|
|
132
|
-
WHERE e.nest_id = ? AND e.to_node = ? AND t.is_flow = 1
|
|
133
|
-
))`,
|
|
134
|
-
[nestId, nodeId, nestId, nodeId]
|
|
135
|
-
);
|
|
136
|
-
const recipients = new Set(rows.map((r) => r.user_email.toLowerCase()));
|
|
137
|
-
recipients.add(requestedBy.toLowerCase());
|
|
138
|
-
recipients.delete(resolvedBy.toLowerCase());
|
|
139
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
140
|
-
const label = await describeNode(nestId, nodeId);
|
|
141
|
-
let created = 0;
|
|
142
|
-
for (const email of recipients) {
|
|
143
|
-
await db.run(
|
|
144
|
-
`INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
|
|
145
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
146
|
-
[
|
|
147
|
-
uuid(),
|
|
148
|
-
nestId,
|
|
149
|
-
email,
|
|
150
|
-
`review_${status}`,
|
|
151
|
-
reviewId,
|
|
152
|
-
`"${label}" was ${status} by ${resolvedBy}`,
|
|
153
|
-
now
|
|
154
|
-
]
|
|
155
|
-
);
|
|
156
|
-
created++;
|
|
157
|
-
}
|
|
158
|
-
return created;
|
|
159
|
-
} catch (err) {
|
|
160
|
-
console.error("[notify] resolution fan-out failed", nestId, nodeId, err);
|
|
161
|
-
return 0;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
async function notifyNestInvite(params) {
|
|
165
|
-
const { nestId, inviteeEmail, permission, nestName: nestName2 } = params;
|
|
166
|
-
try {
|
|
167
|
-
await getDb().run(
|
|
168
|
-
`INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
|
|
169
|
-
VALUES (?, ?, ?, 'nest_invite', ?, ?, ?)`,
|
|
170
|
-
[
|
|
171
|
-
uuid(),
|
|
172
|
-
nestId,
|
|
173
|
-
inviteeEmail.toLowerCase(),
|
|
174
|
-
nestId,
|
|
175
|
-
`You were added to "${nestName2}" as ${permissionLabel(permission)}`,
|
|
176
|
-
(/* @__PURE__ */ new Date()).toISOString()
|
|
177
|
-
]
|
|
178
|
-
);
|
|
179
|
-
} catch (err) {
|
|
180
|
-
console.error("[notify] invite inbox insert failed", nestId, inviteeEmail, err);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
async function listNotifications(userEmail, opts = {}) {
|
|
184
|
-
const limit = Math.min(opts.limit ?? 50, 200);
|
|
185
|
-
return await getDb().all(
|
|
186
|
-
`SELECT n.id, n.nest_id, n.kind, n.subject_id, n.message, n.created_at, n.read_at,
|
|
187
|
-
COALESCE(
|
|
188
|
-
rr.node_id,
|
|
189
|
-
CASE WHEN dr.target_type = 'document' THEN dr.node_id END
|
|
190
|
-
) AS node_id
|
|
191
|
-
FROM notifications n
|
|
192
|
-
LEFT JOIN review_requests rr ON rr.id = n.subject_id
|
|
193
|
-
LEFT JOIN deletion_requests dr ON dr.id = n.subject_id
|
|
194
|
-
WHERE LOWER(n.user_email) = LOWER(?)${opts.unreadOnly ? " AND n.read_at IS NULL" : ""}
|
|
195
|
-
ORDER BY n.created_at DESC LIMIT ${limit}`,
|
|
196
|
-
[userEmail]
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
async function markNotificationsRead(userEmail, ids) {
|
|
200
|
-
const db = getDb();
|
|
201
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
202
|
-
if (ids === "all") {
|
|
203
|
-
await db.run(
|
|
204
|
-
"UPDATE notifications SET read_at = ? WHERE LOWER(user_email) = LOWER(?) AND read_at IS NULL",
|
|
205
|
-
[now, userEmail]
|
|
206
|
-
);
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
for (const id of ids.slice(0, 200)) {
|
|
210
|
-
await db.run(
|
|
211
|
-
"UPDATE notifications SET read_at = ? WHERE id = ? AND LOWER(user_email) = LOWER(?)",
|
|
212
|
-
[now, id, userEmail]
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
51
|
+
import { v4 as uuid2 } from "uuid";
|
|
216
52
|
|
|
217
53
|
// src/workflow/env-routes.ts
|
|
218
54
|
import { Hono as Hono2 } from "hono";
|
|
@@ -270,7 +106,7 @@ async function filterAccessible(nestId, userId, userEmail, nodes, approvedVersio
|
|
|
270
106
|
|
|
271
107
|
// src/workflow/edge-type-routes.ts
|
|
272
108
|
import { Hono } from "hono";
|
|
273
|
-
import { v4 as
|
|
109
|
+
import { v4 as uuid } from "uuid";
|
|
274
110
|
|
|
275
111
|
// src/shared/json.ts
|
|
276
112
|
var safeJson = (s) => {
|
|
@@ -283,6 +119,20 @@ var safeJson = (s) => {
|
|
|
283
119
|
};
|
|
284
120
|
|
|
285
121
|
// src/workflow/flow-graph.ts
|
|
122
|
+
async function nodeTrail(db, nestId, ids) {
|
|
123
|
+
const unique = [...new Set(ids)];
|
|
124
|
+
const titles = /* @__PURE__ */ new Map();
|
|
125
|
+
try {
|
|
126
|
+
const rows = await db.all(
|
|
127
|
+
`SELECT node_id, title FROM node_index
|
|
128
|
+
WHERE nest_id = ? AND node_id IN (${unique.map(() => "?").join(",")})`,
|
|
129
|
+
[nestId, ...unique]
|
|
130
|
+
);
|
|
131
|
+
for (const r of rows) if (r.title) titles.set(r.node_id, r.title);
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
return ids.map((id) => `"${titles.get(id) ?? id}"`).join(" \u2192 ");
|
|
135
|
+
}
|
|
286
136
|
async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
|
|
287
137
|
const db = getDb();
|
|
288
138
|
const rows = await db.all(
|
|
@@ -297,7 +147,7 @@ async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
|
|
|
297
147
|
for (const r of rows) {
|
|
298
148
|
if (r.from_node === r.to_node) {
|
|
299
149
|
throw new ConflictError(
|
|
300
|
-
`cycle:
|
|
150
|
+
`cycle: ${await nodeTrail(db, nestId, [r.from_node])} cannot flow into itself`
|
|
301
151
|
);
|
|
302
152
|
}
|
|
303
153
|
nodes.add(r.from_node);
|
|
@@ -363,7 +213,7 @@ async function seedDefaultEdgeTypes(nestId) {
|
|
|
363
213
|
(id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
|
|
364
214
|
VALUES (?, ?, ?, ?, 'directed', ?, NULL, ?, ?, ?, ?)
|
|
365
215
|
ON CONFLICT DO NOTHING`,
|
|
366
|
-
[
|
|
216
|
+
[uuid(), nestId, name, description, isFlow, color, createdBy, now, now]
|
|
367
217
|
);
|
|
368
218
|
}
|
|
369
219
|
}
|
|
@@ -451,7 +301,7 @@ edgeTypeRoutes.post("/", requireWorkflowPlane, async (c) => {
|
|
|
451
301
|
`INSERT INTO edge_types
|
|
452
302
|
(id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
|
|
453
303
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
454
|
-
[
|
|
304
|
+
[uuid(), nestId, name, body.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
|
|
455
305
|
);
|
|
456
306
|
}
|
|
457
307
|
});
|
|
@@ -862,31 +712,60 @@ function withNodeWriteLock(key, fn) {
|
|
|
862
712
|
// src/governance/review-service.ts
|
|
863
713
|
async function submitForReview(params) {
|
|
864
714
|
const db = getDb();
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
715
|
+
const { id, version } = await withNodeWriteLock(
|
|
716
|
+
nodeWriteKey(params.nestId, params.nodeId),
|
|
717
|
+
async () => {
|
|
718
|
+
const existing = await db.get(
|
|
719
|
+
"SELECT id FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending'",
|
|
720
|
+
[params.nestId, params.nodeId]
|
|
721
|
+
);
|
|
722
|
+
if (existing) {
|
|
723
|
+
throw new Error("A review is already pending for this node");
|
|
724
|
+
}
|
|
725
|
+
const version2 = await getCurrentVersion(params.nestId, params.nodeId) || params.version;
|
|
726
|
+
const id2 = uuid2();
|
|
727
|
+
await db.run(
|
|
728
|
+
`INSERT INTO review_requests
|
|
875
729
|
(id, nest_id, node_id, version, requested_by, request_note, priority)
|
|
876
730
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
731
|
+
[
|
|
732
|
+
id2,
|
|
733
|
+
params.nestId,
|
|
734
|
+
params.nodeId,
|
|
735
|
+
version2,
|
|
736
|
+
params.requestedBy,
|
|
737
|
+
params.note || null,
|
|
738
|
+
params.priority || "normal"
|
|
739
|
+
]
|
|
740
|
+
);
|
|
741
|
+
await db.run(
|
|
742
|
+
"UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
|
|
743
|
+
[params.nestId, params.nodeId, version2]
|
|
744
|
+
);
|
|
745
|
+
try {
|
|
746
|
+
const { storage, versions: versionManager } = await engineCache.get(
|
|
747
|
+
params.nestId
|
|
748
|
+
);
|
|
749
|
+
const node = await storage.readDocument(params.nodeId);
|
|
750
|
+
const history = await versionManager.getHistory(params.nodeId);
|
|
751
|
+
const alreadySealed = (history?.versions ?? []).some(
|
|
752
|
+
(v) => v.version === (node.frontmatter.version ?? 1)
|
|
753
|
+
);
|
|
754
|
+
if (!alreadySealed) {
|
|
755
|
+
await versionManager.createVersion(node, params.requestedBy, {
|
|
756
|
+
note: params.note || "Submitted for review"
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
} catch (err) {
|
|
760
|
+
console.error(
|
|
761
|
+
"VersionManager.createVersion failed (submit for review)",
|
|
762
|
+
params.nestId,
|
|
763
|
+
params.nodeId,
|
|
764
|
+
err
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
return { id: id2, version: version2 };
|
|
768
|
+
}
|
|
890
769
|
);
|
|
891
770
|
const reviewCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
|
|
892
771
|
void dispatchEvent({
|
|
@@ -894,18 +773,18 @@ async function submitForReview(params) {
|
|
|
894
773
|
nestId: params.nestId,
|
|
895
774
|
subjectId: params.nodeId,
|
|
896
775
|
actor: params.requestedBy,
|
|
897
|
-
message: `Review requested on *${reviewCtx.label}* (v${
|
|
776
|
+
message: `Review requested on *${reviewCtx.label}* (v${version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
|
|
898
777
|
});
|
|
899
778
|
void notifyNestEvent(
|
|
900
779
|
params.nestId,
|
|
901
|
-
`:memo: Review requested on *${reviewCtx.label}* (v${
|
|
780
|
+
`:memo: Review requested on *${reviewCtx.label}* (v${version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`,
|
|
902
781
|
{
|
|
903
782
|
status: "pending_review",
|
|
904
783
|
docTitle: reviewCtx.docTitle,
|
|
905
784
|
path: reviewCtx.path,
|
|
906
785
|
link: reviewCtx.link,
|
|
907
786
|
actor: params.requestedBy,
|
|
908
|
-
version
|
|
787
|
+
version,
|
|
909
788
|
note: params.note || null
|
|
910
789
|
}
|
|
911
790
|
);
|
|
@@ -929,7 +808,7 @@ async function submitForReview(params) {
|
|
|
929
808
|
path: reviewCtx.path,
|
|
930
809
|
link: reviewCtx.link,
|
|
931
810
|
actor: params.requestedBy,
|
|
932
|
-
version
|
|
811
|
+
version,
|
|
933
812
|
note: params.note || null
|
|
934
813
|
});
|
|
935
814
|
}
|
|
@@ -1168,6 +1047,18 @@ async function cancelReview(params) {
|
|
|
1168
1047
|
[params.nestId, params.nodeId]
|
|
1169
1048
|
);
|
|
1170
1049
|
if (!pending) return null;
|
|
1050
|
+
if (String(pending.requested_by).toLowerCase() !== params.cancelledBy.toLowerCase()) {
|
|
1051
|
+
const verdict = await canUserApprove(
|
|
1052
|
+
params.nestId,
|
|
1053
|
+
params.nodeId,
|
|
1054
|
+
params.cancelledBy
|
|
1055
|
+
);
|
|
1056
|
+
if (!verdict.allowed) {
|
|
1057
|
+
throw new Error(
|
|
1058
|
+
"Only the submitter or a reviewer can withdraw this review"
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1171
1062
|
await db.run(
|
|
1172
1063
|
`UPDATE review_requests
|
|
1173
1064
|
SET status = 'cancelled', resolved_by = ?, resolved_at = ${nowExpr(db)}
|
|
@@ -1258,9 +1149,12 @@ async function getPendingReview(nestId, nodeId) {
|
|
|
1258
1149
|
);
|
|
1259
1150
|
return row ? rowToReviewRequest(row) : null;
|
|
1260
1151
|
}
|
|
1152
|
+
function isNestTarget(t) {
|
|
1153
|
+
return t === "nest" || t === "nest_archive";
|
|
1154
|
+
}
|
|
1261
1155
|
async function deletionTargetTitle(request, titles) {
|
|
1262
1156
|
if (request.targetType === "folder") return prettyFolderPath(request.nodeId);
|
|
1263
|
-
if (request.targetType
|
|
1157
|
+
if (isNestTarget(request.targetType)) return await nestName(request.nestId);
|
|
1264
1158
|
return titles?.get(request.nodeId) ?? await titleForNode(request.nestId, request.nodeId);
|
|
1265
1159
|
}
|
|
1266
1160
|
async function nestAdminEmails(nestId) {
|
|
@@ -1282,7 +1176,7 @@ async function inboxInsert(nestId, recipients, kind, subjectId, message) {
|
|
|
1282
1176
|
await db.run(
|
|
1283
1177
|
`INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
|
|
1284
1178
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
1285
|
-
[
|
|
1179
|
+
[uuid2(), nestId, email.toLowerCase(), kind, subjectId, message, now]
|
|
1286
1180
|
);
|
|
1287
1181
|
} catch (err) {
|
|
1288
1182
|
console.error("[notify] deletion inbox insert failed", nestId, email, err);
|
|
@@ -1297,7 +1191,7 @@ async function deletionTargetContext(request, baseUrl) {
|
|
|
1297
1191
|
const name = await nestName(request.nestId);
|
|
1298
1192
|
const label = request.targetType === "folder" ? prettyFolderPath(request.nodeId) : name;
|
|
1299
1193
|
return {
|
|
1300
|
-
noun: request.targetType,
|
|
1194
|
+
noun: request.targetType === "nest_archive" ? "nest" : request.targetType,
|
|
1301
1195
|
label,
|
|
1302
1196
|
docTitle: label,
|
|
1303
1197
|
path: name,
|
|
@@ -1362,7 +1256,8 @@ async function notifyDeletionRequested(request, baseUrl) {
|
|
|
1362
1256
|
async function notifyDeletionResolved(params) {
|
|
1363
1257
|
try {
|
|
1364
1258
|
const { outcome, resolvedBy, note } = params;
|
|
1365
|
-
const
|
|
1259
|
+
const archivedInstead = outcome === "deleted" && params.targetType === "nest_archive";
|
|
1260
|
+
const verb = archivedInstead ? "archived" : outcome === "deleted" ? "deleted" : "rejected the deletion of";
|
|
1366
1261
|
const line = `${resolvedBy} ${verb} *${params.docTitle}*${note ? ` \u2014 "${note}"` : ""}`;
|
|
1367
1262
|
const recipient = params.requestedBy.toLowerCase();
|
|
1368
1263
|
const canInbox = !(params.targetType === "nest" && outcome === "deleted");
|
|
@@ -1372,7 +1267,7 @@ async function notifyDeletionResolved(params) {
|
|
|
1372
1267
|
[recipient],
|
|
1373
1268
|
`deletion_${outcome}`,
|
|
1374
1269
|
params.requestId,
|
|
1375
|
-
outcome === "deleted" ? `"${params.docTitle}" was deleted by ${resolvedBy} \u2014 your request was accepted` : `Your deletion request for "${params.docTitle}" was rejected by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
|
|
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}"` : ""}`
|
|
1376
1271
|
);
|
|
1377
1272
|
}
|
|
1378
1273
|
void dispatchEvent({
|
|
@@ -1392,7 +1287,10 @@ async function notifyDeletionResolved(params) {
|
|
|
1392
1287
|
if (recipient !== resolvedBy.toLowerCase()) {
|
|
1393
1288
|
const link = params.baseUrl ? `${params.baseUrl}/?nest=${encodeURIComponent(params.nestId)}` : "";
|
|
1394
1289
|
void sendEmailToRecipient(recipient, {
|
|
1395
|
-
|
|
1290
|
+
// The email has to agree with the inbox row and the channel line above:
|
|
1291
|
+
// an honoured archive is not a deletion, and the deletion template says
|
|
1292
|
+
// the nest is gone.
|
|
1293
|
+
status: archivedInstead ? "deletion_archived" : outcome === "deleted" ? "deletion_deleted" : "deletion_declined",
|
|
1396
1294
|
docTitle: params.docTitle,
|
|
1397
1295
|
path: params.nestId,
|
|
1398
1296
|
link: outcome === "deleted" ? link : void 0,
|
|
@@ -1413,14 +1311,15 @@ async function requestDeletion(params) {
|
|
|
1413
1311
|
const db = getDb();
|
|
1414
1312
|
const targetType = params.targetType || "document";
|
|
1415
1313
|
const noun = targetType === "document" ? "node" : targetType === "folder" ? "folder" : "nest";
|
|
1314
|
+
const kind = targetType === "nest_archive" ? "An archive request" : "A deletion request";
|
|
1416
1315
|
const existing = await db.get(
|
|
1417
1316
|
"SELECT id FROM deletion_requests WHERE nest_id = ? AND node_id = ? AND target_type = ? AND status = 'pending'",
|
|
1418
1317
|
[params.nestId, params.nodeId, targetType]
|
|
1419
1318
|
);
|
|
1420
1319
|
if (existing) {
|
|
1421
|
-
throw new ConflictError(
|
|
1320
|
+
throw new ConflictError(`${kind} is already pending for this ${noun}`);
|
|
1422
1321
|
}
|
|
1423
|
-
const id =
|
|
1322
|
+
const id = uuid2();
|
|
1424
1323
|
try {
|
|
1425
1324
|
await db.run(
|
|
1426
1325
|
`INSERT INTO deletion_requests (id, nest_id, node_id, target_type, requested_by, reason)
|
|
@@ -1441,7 +1340,7 @@ async function requestDeletion(params) {
|
|
|
1441
1340
|
params.nodeId,
|
|
1442
1341
|
err
|
|
1443
1342
|
);
|
|
1444
|
-
throw new ConflictError(
|
|
1343
|
+
throw new ConflictError(`${kind} is already pending for this ${noun}`);
|
|
1445
1344
|
}
|
|
1446
1345
|
const request = await getDeletionRequest(id);
|
|
1447
1346
|
await notifyDeletionRequested(request, params.baseUrl);
|
|
@@ -1584,17 +1483,12 @@ function rowToReviewRequest(row) {
|
|
|
1584
1483
|
}
|
|
1585
1484
|
|
|
1586
1485
|
export {
|
|
1587
|
-
listWatchers,
|
|
1588
|
-
addWatcher,
|
|
1589
|
-
removeWatcher,
|
|
1590
|
-
notifyNestInvite,
|
|
1591
|
-
listNotifications,
|
|
1592
|
-
markNotificationsRead,
|
|
1593
1486
|
resolveCallerEmail,
|
|
1594
1487
|
canReadNode,
|
|
1595
1488
|
canRequestDeletion,
|
|
1596
1489
|
filterAccessible,
|
|
1597
1490
|
safeJson,
|
|
1491
|
+
nodeTrail,
|
|
1598
1492
|
requireWorkflowPlane,
|
|
1599
1493
|
seedDefaultEdgeTypes,
|
|
1600
1494
|
parseConditionSchema,
|
|
@@ -1616,6 +1510,7 @@ export {
|
|
|
1616
1510
|
getReviewQueue,
|
|
1617
1511
|
getReviewHistory,
|
|
1618
1512
|
getPendingReview,
|
|
1513
|
+
isNestTarget,
|
|
1619
1514
|
deletionTargetTitle,
|
|
1620
1515
|
notifyDeletionResolved,
|
|
1621
1516
|
requestDeletion,
|