@promptowl/contextnest-community 1.16.1 → 1.18.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 +415 -415
- package/LICENSE.md +142 -142
- package/README.md +168 -169
- package/dist/{chunk-D6HICFVW.js → chunk-34UXRZXD.js} +59 -14
- package/dist/chunk-F66VCBLA.js +136 -0
- package/dist/{chunk-L7UCMGWZ.js → chunk-FUUJG6XP.js} +641 -285
- package/dist/{chunk-QJVRZT6M.js → chunk-HGN45I5P.js} +59 -21
- package/dist/{chunk-JMJLNEXE.js → chunk-HYSTFMFG.js} +43 -2
- package/dist/chunk-OOBZG6BW.js +379 -0
- package/dist/{chunk-OTGZQZKK.js → chunk-QUZXRLUA.js} +11 -10
- package/dist/{chunk-CLKZZZS2.js → chunk-TQACHVVA.js} +35 -4
- package/dist/{chunk-SLTQACJW.js → chunk-YB3LKF7U.js} +3 -1
- package/dist/{client-OBS5HOTA.js → client-SW5I6ZBK.js} +2 -2
- package/dist/engine-34E3YNX7.js +14 -0
- package/dist/external-edit-service-6Q6SHEOF.js +33 -0
- package/dist/{grants-service-I535QVTW.js → grants-service-ME7HIVHU.js} +5 -5
- package/dist/index.js +700 -526
- package/dist/{keys-73STFJJB.js → keys-CTOGAG3W.js} +5 -1
- package/dist/{migrations.postgres-CYKO5CFV.js → migrations.postgres-5VI4RDTS.js} +41 -4
- package/dist/{review-service-V2GR6VM3.js → review-service-H2M5ACPQ.js} +11 -9
- package/dist/{stewardship-service-LTREGVSJ.js → stewardship-service-U4CKL7Y5.js} +8 -4
- package/dist/{version-service-V6K5SZ3P.js → version-service-JWEN5UUW.js} +6 -4
- package/dist/web3/assets/{hootie-C2ocYkn4.svg → hootie-_U3ECslt.svg} +8 -8
- package/dist/web3/assets/index-DONv5_Ni.css +1 -0
- package/dist/web3/assets/index-jSGrnboh.js +1065 -0
- package/dist/web3/index.html +14 -14
- package/package.json +165 -165
- package/dist/chunk-XRK6SQSC.js +0 -58
- package/dist/web3/assets/index-DnQARUL3.css +0 -1
- package/dist/web3/assets/index-_m7WyozM.js +0 -1051
package/README.md
CHANGED
|
@@ -1,169 +1,168 @@
|
|
|
1
|
-
# ContextNest Community Edition
|
|
2
|
-
|
|
3
|
-
**Self-hosted context governance server for AI workflows.** Part of the [PromptOwl](https://promptowl.ai) platform.
|
|
4
|
-
|
|
5
|
-
> ⚠️ **Commercial Software.** ContextNest Community Edition is proprietary software licensed by Promptowl LLC. A free [PromptOwl account](https://app.promptowl.ai) is required to use it. Redistribution, hosting-as-a-service, and competitive use are prohibited. See [LICENSE.md](./LICENSE.md) for full terms.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## What it is
|
|
10
|
-
|
|
11
|
-
ContextNest Community Edition is a self-hosted server that lets you:
|
|
12
|
-
|
|
13
|
-
- Store, version, and govern markdown-based context documents ("nests")
|
|
14
|
-
- Import an existing folder or vault of markdown files in one step
|
|
15
|
-
- Export a nest as a portable bundle and re-import it on another self-hosted host
|
|
16
|
-
- Apply stewardship workflows — draft, pending review, approved
|
|
17
|
-
- Share nests with collaborators or publish them read-only to the public
|
|
18
|
-
- Serve approved context to AI agents via MCP
|
|
19
|
-
- Sync with the PromptOwl hosted platform for multi-user collaboration
|
|
20
|
-
|
|
21
|
-
The server runs locally or on your own infrastructure. Your PromptOwl account handles authentication, entitlement, and governance metadata.
|
|
22
|
-
|
|
23
|
-
## Quickstart
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
# 1. Run the community server
|
|
27
|
-
npx @promptowl/contextnest-community
|
|
28
|
-
|
|
29
|
-
# 2. Open the server in your browser
|
|
30
|
-
# http://localhost:3838
|
|
31
|
-
# On first boot with no license, it lands on the License Setup Page.
|
|
32
|
-
|
|
33
|
-
# 3. Paste your PromptOwl license key (pk_...) — see "License setup" below
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
The server listens on `http://localhost:3838` by default. Without a valid license the server still runs and serves reads, but write actions return `503` until you activate. See [CONFIGURATION.md](./CONFIGURATION.md) for all environment variables (port, auth mode, storage, telemetry).
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
|
91
|
-
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
|
109
|
-
|
|
|
110
|
-
|
|
|
111
|
-
|
|
|
112
|
-
|
|
|
113
|
-
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
-
|
|
128
|
-
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
134
|
-
-
|
|
135
|
-
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
- **
|
|
147
|
-
- **
|
|
148
|
-
- **
|
|
149
|
-
- **
|
|
150
|
-
- **
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
- **
|
|
156
|
-
- **
|
|
157
|
-
- **
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
Promptowl LLC · 3060 Mercer University Dr Ste 110 · Atlanta, GA 30341 · USA
|
|
1
|
+
# ContextNest Community Edition
|
|
2
|
+
|
|
3
|
+
**Self-hosted context governance server for AI workflows.** Part of the [PromptOwl](https://promptowl.ai) platform.
|
|
4
|
+
|
|
5
|
+
> ⚠️ **Commercial Software.** ContextNest Community Edition is proprietary software licensed by Promptowl LLC. A free [PromptOwl account](https://app.promptowl.ai) is required to use it. Redistribution, hosting-as-a-service, and competitive use are prohibited. See [LICENSE.md](./LICENSE.md) for full terms.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What it is
|
|
10
|
+
|
|
11
|
+
ContextNest Community Edition is a self-hosted server that lets you:
|
|
12
|
+
|
|
13
|
+
- Store, version, and govern markdown-based context documents ("nests"), filed in folders you can reorganize as the vault grows
|
|
14
|
+
- Import an existing folder or vault of markdown files in one step
|
|
15
|
+
- Export a nest as a portable bundle and re-import it on another self-hosted host
|
|
16
|
+
- Apply stewardship workflows — draft, pending review, approved
|
|
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`
|
|
19
|
+
- Sync with the PromptOwl hosted platform for multi-user collaboration
|
|
20
|
+
|
|
21
|
+
The server runs locally or on your own infrastructure. Your PromptOwl account handles authentication, entitlement, and governance metadata.
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# 1. Run the community server
|
|
27
|
+
npx @promptowl/contextnest-community
|
|
28
|
+
|
|
29
|
+
# 2. Open the server in your browser
|
|
30
|
+
# http://localhost:3838
|
|
31
|
+
# On first boot with no license, it lands on the License Setup Page.
|
|
32
|
+
|
|
33
|
+
# 3. Paste your PromptOwl license key (pk_...) — see "License setup" below
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The server listens on `http://localhost:3838` by default. Without a valid license the server still runs and serves reads, but write actions return `503` until you activate. See [CONFIGURATION.md](./CONFIGURATION.md) for all environment variables (port, auth mode, storage, telemetry).
|
|
37
|
+
|
|
38
|
+
## License setup
|
|
39
|
+
|
|
40
|
+
ContextNest Community Edition requires a PromptOwl Community License key (`pk_...`). Getting and activating one:
|
|
41
|
+
|
|
42
|
+
### 1. Create the key (free)
|
|
43
|
+
|
|
44
|
+
1. Sign up or log in at <https://app.promptowl.ai>
|
|
45
|
+
2. Open the **Overview** menu → **Community License**
|
|
46
|
+
3. Click **Create a Community License key**
|
|
47
|
+
4. Copy the generated key — it starts with `pk_`
|
|
48
|
+
|
|
49
|
+
### 2. Activate the server
|
|
50
|
+
|
|
51
|
+
Pick **one** of two ways:
|
|
52
|
+
|
|
53
|
+
**A. Browser setup page (recommended for first run)**
|
|
54
|
+
|
|
55
|
+
1. Start the server: `npx @promptowl/contextnest-community`
|
|
56
|
+
2. Open <http://localhost:3838> — with no license installed, the server boots into **setup mode** and shows the **License Setup Page**
|
|
57
|
+
3. Paste your `pk_...` key and submit
|
|
58
|
+
4. The server validates it against PromptOwl, writes it to your `.env`, and exits setup mode — no restart needed
|
|
59
|
+
|
|
60
|
+
**B. Environment variable (recommended for Docker / CI / scripted deploys)**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
PROMPTOWL_KEY=pk_... npx @promptowl/contextnest-community
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The key is read at boot. The server validates against PromptOwl on startup; if valid, it goes straight into licensed mode.
|
|
67
|
+
|
|
68
|
+
### 3. How licensing behaves at runtime
|
|
69
|
+
|
|
70
|
+
- **Unlicensed / setup mode** — reads work; every non-GET (write) request returns `503` until a valid key is installed.
|
|
71
|
+
- **Live revocation** — a long-poll watcher tracks license state against PromptOwl. If your key is revoked, the server blocks writes within seconds (no restart required) and returns to setup mode.
|
|
72
|
+
- **Admin identity follows the license** — the admin user is whichever PromptOwl account owns the installed key, resolved live per request. Transferring the license to another account immediately promotes the new owner and demotes the old one.
|
|
73
|
+
|
|
74
|
+
For redistribution, hosted-service, OEM, or regulated-industry licensing, contact **hoot@promptowl.ai**.
|
|
75
|
+
|
|
76
|
+
## System requirements
|
|
77
|
+
|
|
78
|
+
- **Node.js** 20.x or later
|
|
79
|
+
- **PromptOwl account** — free signup at <https://app.promptowl.ai/signup>
|
|
80
|
+
- **OS:** Windows, macOS, or Linux
|
|
81
|
+
- **Disk:** ~200 MB for the server, plus storage for your nests
|
|
82
|
+
|
|
83
|
+
## What you get
|
|
84
|
+
|
|
85
|
+
| Feature | Community Edition | Enterprise |
|
|
86
|
+
|---|:---:|:---:|
|
|
87
|
+
| Self-hosted context server | ✅ | ✅ |
|
|
88
|
+
| SQLite or PostgreSQL / Cloud SQL backend | ✅ | ✅ |
|
|
89
|
+
| Configurable nest storage (external / GCS volume mount) | ✅ | ✅ |
|
|
90
|
+
| Markdown + YAML frontmatter vaults | ✅ | ✅ |
|
|
91
|
+
| Import existing folder / vault | ✅ | ✅ |
|
|
92
|
+
| Export nest as portable bundle (.zip) | ✅ | ✅ |
|
|
93
|
+
| Markdown rendering + wiki cross-linking | ✅ | ✅ |
|
|
94
|
+
| External-edit detection + version diff | ✅ | ✅ |
|
|
95
|
+
| Stewardship workflow (draft/review/approve) | ✅ | ✅ |
|
|
96
|
+
| Per-nest sharing + collaborators | ✅ | ✅ |
|
|
97
|
+
| Public read-only nests | ✅ | ✅ |
|
|
98
|
+
| Custom logo / branding | ✅ | ✅ |
|
|
99
|
+
| Admin password reset + user removal (in-platform) | ✅ | ✅ |
|
|
100
|
+
| Wiki backlinks, outline, hover-preview, link health | ✅ | ✅ |
|
|
101
|
+
| Rich editor — tables, callouts, toggles, code highlight, find/replace, image & video upload | ✅ | ✅ |
|
|
102
|
+
| Folder organization — nested folders, move documents, lazy folder tree | ✅ | ✅ |
|
|
103
|
+
| Scales to large vaults — nest listings served from a document index, not a disk crawl | ✅ | ✅ |
|
|
104
|
+
| Steward version revert | ✅ | ✅ |
|
|
105
|
+
| MCP server for AI agents | ✅ | ✅ |
|
|
106
|
+
| One-shot agent setup — generated connect prompt + `.env` download | ✅ | ✅ |
|
|
107
|
+
| Several API keys per account — one credential per client, rotate one at a time | ✅ | ✅ |
|
|
108
|
+
| Centralized multi-tenant admin console | — | ✅ |
|
|
109
|
+
| Single sign-on (OIDC — Entra ID, Google, Okta, Keycloak) | ✅ | ✅ |
|
|
110
|
+
| SAML / SCIM provisioning | — | ✅ |
|
|
111
|
+
| Audit log streaming | — | ✅ |
|
|
112
|
+
| Policy transforms (redaction, summarization) | — | ✅ |
|
|
113
|
+
| Priority support and SLA | — | ✅ |
|
|
114
|
+
|
|
115
|
+
For Enterprise pricing and features, contact **hoot@promptowl.ai** or visit <https://promptowl.ai/contextnest/>.
|
|
116
|
+
|
|
117
|
+
## What's new
|
|
118
|
+
|
|
119
|
+
Release notes live in [CHANGELOG.md](./CHANGELOG.md).
|
|
120
|
+
|
|
121
|
+
## Licensing
|
|
122
|
+
|
|
123
|
+
ContextNest Community Edition is **commercial software**. It is **not open source**.
|
|
124
|
+
|
|
125
|
+
**You may:**
|
|
126
|
+
- Install and run the Software on devices You own or control
|
|
127
|
+
- Use the Software for internal business purposes, tied to a valid PromptOwl account
|
|
128
|
+
- Make backup and archival copies
|
|
129
|
+
|
|
130
|
+
**You may not:**
|
|
131
|
+
- Redistribute, resell, rent, lease, or sublicense the Software
|
|
132
|
+
- Offer the Software as a hosted, managed, or software-as-a-service product to third parties
|
|
133
|
+
- Reverse engineer, decompile, or create derivative works
|
|
134
|
+
- Use the Software to build a competing product or service
|
|
135
|
+
- Remove copyright, trademark, or license notices
|
|
136
|
+
|
|
137
|
+
Full license text: [LICENSE.md](./LICENSE.md)
|
|
138
|
+
|
|
139
|
+
**For redistribution, hosted-service, OEM, or regulated-industry use,** contact **hoot@promptowl.ai** for a commercial license agreement.
|
|
140
|
+
|
|
141
|
+
## Platform terms
|
|
142
|
+
|
|
143
|
+
Because the Software requires a PromptOwl account, the following terms also apply to Your use:
|
|
144
|
+
|
|
145
|
+
- **End User License Agreement** — <https://promptowl.ai/eula/>
|
|
146
|
+
- **Terms of Service** — <https://promptowl.ai/terms-of-service/>
|
|
147
|
+
- **Privacy Policy** — <https://promptowl.ai/privacy-policy/>
|
|
148
|
+
- **Acceptable Use Policy** — <https://promptowl.ai/acceptable-use/>
|
|
149
|
+
- **Disclaimer** — <https://promptowl.ai/disclaimer/>
|
|
150
|
+
- **Cookie Policy** — <https://promptowl.ai/cookies/>
|
|
151
|
+
|
|
152
|
+
## Support
|
|
153
|
+
|
|
154
|
+
- **Documentation:** <https://promptowl.ai/contextnest/>
|
|
155
|
+
- **Product questions:** <https://promptowl.ai/contact-us/>
|
|
156
|
+
- **Support & bugs:** `hoot@promptowl.ai`
|
|
157
|
+
- **Commercial licensing:** `hoot@promptowl.ai` (subject: *ContextNest Commercial License*)
|
|
158
|
+
|
|
159
|
+
## AI output disclaimer
|
|
160
|
+
|
|
161
|
+
The Software injects content into large language models. AI output may be inaccurate, incomplete, or inappropriate for your use case. You are responsible for reviewing and validating any AI-generated content before relying on it, particularly in business-critical or regulated contexts. Do not deploy the Software in medical, legal, financial-advisory, or safety-critical environments without appropriate human oversight.
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
**Copyright © 2026 Promptowl LLC.** All rights reserved.
|
|
166
|
+
"ContextNest" and "PromptOwl" are trademarks of Promptowl LLC.
|
|
167
|
+
|
|
168
|
+
Promptowl LLC · 3060 Mercer University Dr Ste 110 · Atlanta, GA 30341 · USA
|
|
@@ -2,10 +2,10 @@ import {
|
|
|
2
2
|
buildTitleMap,
|
|
3
3
|
insertOrReplace,
|
|
4
4
|
nowExpr
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-FUUJG6XP.js";
|
|
6
6
|
import {
|
|
7
7
|
getDb
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-HYSTFMFG.js";
|
|
9
9
|
|
|
10
10
|
// src/governance/version-service.ts
|
|
11
11
|
import { createHash } from "crypto";
|
|
@@ -178,32 +178,76 @@ async function getNodeTags(nestId, nodeId) {
|
|
|
178
178
|
return [];
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
+
function decideDisplayStatus(hasPendingReview, current, approvedVersion) {
|
|
182
|
+
if (hasPendingReview) return "pending_review";
|
|
183
|
+
if (!current) return "draft";
|
|
184
|
+
if (current.status === "published" || current.status === "approved") {
|
|
185
|
+
if (approvedVersion === current.version) {
|
|
186
|
+
return current.status === "published" ? "published" : "approved";
|
|
187
|
+
}
|
|
188
|
+
return "draft";
|
|
189
|
+
}
|
|
190
|
+
if (current.status === "rejected") return "rejected";
|
|
191
|
+
return "draft";
|
|
192
|
+
}
|
|
181
193
|
async function getDisplayStatus(nestId, nodeId) {
|
|
182
194
|
const db = getDb();
|
|
183
195
|
const pending = await db.get(
|
|
184
196
|
"SELECT 1 FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' LIMIT 1",
|
|
185
197
|
[nestId, nodeId]
|
|
186
198
|
);
|
|
187
|
-
if (pending) return "pending_review";
|
|
188
199
|
const current = await db.get(
|
|
189
200
|
`SELECT version, status FROM node_versions
|
|
190
201
|
WHERE nest_id = ? AND node_id = ?
|
|
191
202
|
ORDER BY version DESC LIMIT 1`,
|
|
192
203
|
[nestId, nodeId]
|
|
193
204
|
);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
205
|
+
const approved = current && (current.status === "published" || current.status === "approved") ? await db.get(
|
|
206
|
+
"SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?",
|
|
207
|
+
[nestId, nodeId]
|
|
208
|
+
) : void 0;
|
|
209
|
+
return decideDisplayStatus(!!pending, current, approved?.approved_version);
|
|
210
|
+
}
|
|
211
|
+
async function getDisplayStatuses(nestId) {
|
|
212
|
+
const db = getDb();
|
|
213
|
+
const [pendingRows, currentRows, approvedRows] = await Promise.all([
|
|
214
|
+
db.all(
|
|
215
|
+
"SELECT DISTINCT node_id FROM review_requests WHERE nest_id = ? AND status = 'pending'",
|
|
216
|
+
[nestId]
|
|
217
|
+
),
|
|
218
|
+
// The latest version per node, via a grouped self-join — portable across
|
|
219
|
+
// the SQLite and Postgres adapters (no window functions, no row values).
|
|
220
|
+
db.all(
|
|
221
|
+
`SELECT nv.node_id, nv.version, nv.status
|
|
222
|
+
FROM node_versions nv
|
|
223
|
+
JOIN (SELECT node_id, MAX(version) AS max_version
|
|
224
|
+
FROM node_versions WHERE nest_id = ? GROUP BY node_id) latest
|
|
225
|
+
ON latest.node_id = nv.node_id AND latest.max_version = nv.version
|
|
226
|
+
WHERE nv.nest_id = ?`,
|
|
227
|
+
[nestId, nestId]
|
|
228
|
+
),
|
|
229
|
+
db.all(
|
|
230
|
+
"SELECT node_id, approved_version FROM approved_versions WHERE nest_id = ?",
|
|
231
|
+
[nestId]
|
|
232
|
+
)
|
|
233
|
+
]);
|
|
234
|
+
const pending = new Set(pendingRows.map((r) => r.node_id));
|
|
235
|
+
const approved = new Map(
|
|
236
|
+
approvedRows.map((r) => [r.node_id, r.approved_version])
|
|
237
|
+
);
|
|
238
|
+
const current = new Map(currentRows.map((r) => [r.node_id, r]));
|
|
239
|
+
const statuses = /* @__PURE__ */ new Map();
|
|
240
|
+
for (const nodeId of /* @__PURE__ */ new Set([...pending, ...current.keys()])) {
|
|
241
|
+
statuses.set(
|
|
242
|
+
nodeId,
|
|
243
|
+
decideDisplayStatus(
|
|
244
|
+
pending.has(nodeId),
|
|
245
|
+
current.get(nodeId),
|
|
246
|
+
approved.get(nodeId)
|
|
247
|
+
)
|
|
199
248
|
);
|
|
200
|
-
if (approved?.approved_version === current.version) {
|
|
201
|
-
return current.status === "published" ? "published" : "approved";
|
|
202
|
-
}
|
|
203
|
-
return "draft";
|
|
204
249
|
}
|
|
205
|
-
|
|
206
|
-
return "draft";
|
|
250
|
+
return statuses;
|
|
207
251
|
}
|
|
208
252
|
async function listMyDrafts(params) {
|
|
209
253
|
if (params.nestIds.length === 0) return { drafts: [], total: 0 };
|
|
@@ -286,5 +330,6 @@ export {
|
|
|
286
330
|
checkConflict,
|
|
287
331
|
getNodeTags,
|
|
288
332
|
getDisplayStatus,
|
|
333
|
+
getDisplayStatuses,
|
|
289
334
|
listMyDrafts
|
|
290
335
|
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// src/auth/keys.ts
|
|
2
|
+
import { randomBytes, createHash, timingSafeEqual } from "crypto";
|
|
3
|
+
import bcrypt from "bcryptjs";
|
|
4
|
+
var KEY_PREFIX = "cnst_";
|
|
5
|
+
var BCRYPT_ROUNDS = process.env.VITEST ? 4 : 12;
|
|
6
|
+
function generateApiKey() {
|
|
7
|
+
return KEY_PREFIX + randomBytes(32).toString("hex");
|
|
8
|
+
}
|
|
9
|
+
function hashApiKey(key) {
|
|
10
|
+
return createHash("sha256").update(key).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
function getKeyPrefix(key) {
|
|
13
|
+
return key.slice(0, 12) + "...";
|
|
14
|
+
}
|
|
15
|
+
function parseBearerToken(header) {
|
|
16
|
+
if (!header) return null;
|
|
17
|
+
const BEARER = "Bearer ";
|
|
18
|
+
if (!header.startsWith(BEARER)) return null;
|
|
19
|
+
const token = header.slice(BEARER.length);
|
|
20
|
+
if (!token.startsWith(KEY_PREFIX)) return null;
|
|
21
|
+
if (token.length < KEY_PREFIX.length + 32) return null;
|
|
22
|
+
return token;
|
|
23
|
+
}
|
|
24
|
+
async function hashPassword(password) {
|
|
25
|
+
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
26
|
+
}
|
|
27
|
+
async function verifyPassword(password, stored) {
|
|
28
|
+
if (isBcrypt(stored)) {
|
|
29
|
+
return { ok: await bcrypt.compare(password, stored), needsRehash: false };
|
|
30
|
+
}
|
|
31
|
+
const [salt, hash] = stored.split(":");
|
|
32
|
+
if (!salt || !hash) return { ok: false, needsRehash: false };
|
|
33
|
+
const attempt = createHash("sha256").update(salt + password).digest("hex");
|
|
34
|
+
return {
|
|
35
|
+
ok: timingSafeEqualHex(hash, attempt),
|
|
36
|
+
needsRehash: true
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function isBcrypt(stored) {
|
|
40
|
+
return stored.startsWith("$2a$") || stored.startsWith("$2b$") || stored.startsWith("$2y$");
|
|
41
|
+
}
|
|
42
|
+
function timingSafeEqualHex(a, b) {
|
|
43
|
+
if (a.length !== b.length) return false;
|
|
44
|
+
try {
|
|
45
|
+
return timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
var MAX_API_KEYS_PER_USER = 20;
|
|
51
|
+
var MAX_LABEL_LENGTH = 60;
|
|
52
|
+
async function verifyKeyRequest(db, userId, opts) {
|
|
53
|
+
const label = normalizeKeyLabel(opts.label);
|
|
54
|
+
if (label === INVALID_LABEL) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
status: 400,
|
|
58
|
+
error: `Label must be a string of at most ${MAX_LABEL_LENGTH} characters.`
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (opts.action === "fetch") return { ok: true, label, key: null };
|
|
62
|
+
const rows = await db.all(
|
|
63
|
+
"SELECT id, key_prefix, nest_id, label, created_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at ASC, id ASC",
|
|
64
|
+
[userId]
|
|
65
|
+
);
|
|
66
|
+
if (opts.action === "generate") {
|
|
67
|
+
if (rows.length >= MAX_API_KEYS_PER_USER) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
status: 409,
|
|
71
|
+
error: `You already have ${MAX_API_KEYS_PER_USER} API keys. Revoke one before creating another.`
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!label) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
status: 400,
|
|
78
|
+
error: "Name this key so you can tell it apart later."
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (rows.some((r) => r.label?.trim().toLowerCase() === label.toLowerCase())) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
status: 409,
|
|
85
|
+
error: `You already have a key named "${label}". Pick a different name.`
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
return { ok: true, label, key: null };
|
|
89
|
+
}
|
|
90
|
+
const renameCollides = (targetId) => !!label && rows.some(
|
|
91
|
+
(r) => r.id !== targetId && r.label?.trim().toLowerCase() === label.toLowerCase()
|
|
92
|
+
);
|
|
93
|
+
const collision = {
|
|
94
|
+
ok: false,
|
|
95
|
+
status: 409,
|
|
96
|
+
error: `You already have a key named "${label}". Pick a different name.`
|
|
97
|
+
};
|
|
98
|
+
if (opts.keyId) {
|
|
99
|
+
const match = rows.find((r) => r.id === opts.keyId);
|
|
100
|
+
if (!match) return { ok: false, status: 404, error: "Key not found" };
|
|
101
|
+
if (renameCollides(match.id)) return collision;
|
|
102
|
+
return { ok: true, label, key: match };
|
|
103
|
+
}
|
|
104
|
+
if (rows.length === 1) {
|
|
105
|
+
if (renameCollides(rows[0].id)) return collision;
|
|
106
|
+
return { ok: true, label, key: rows[0] };
|
|
107
|
+
}
|
|
108
|
+
if (rows.length === 0) {
|
|
109
|
+
return { ok: false, status: 404, error: "No API key to rotate." };
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
status: 400,
|
|
114
|
+
error: "You have several API keys \u2014 say which one to rotate (key_id)."
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
var INVALID_LABEL = /* @__PURE__ */ Symbol("invalid-label");
|
|
118
|
+
function normalizeKeyLabel(raw) {
|
|
119
|
+
if (raw === void 0 || raw === null || raw === "") return null;
|
|
120
|
+
if (typeof raw !== "string") return INVALID_LABEL;
|
|
121
|
+
const trimmed = raw.trim();
|
|
122
|
+
if (!trimmed) return null;
|
|
123
|
+
if (trimmed.length > MAX_LABEL_LENGTH) return INVALID_LABEL;
|
|
124
|
+
return trimmed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export {
|
|
128
|
+
generateApiKey,
|
|
129
|
+
hashApiKey,
|
|
130
|
+
getKeyPrefix,
|
|
131
|
+
parseBearerToken,
|
|
132
|
+
hashPassword,
|
|
133
|
+
verifyPassword,
|
|
134
|
+
MAX_API_KEYS_PER_USER,
|
|
135
|
+
verifyKeyRequest
|
|
136
|
+
};
|