daftari 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +21 -0
  3. package/README.md +259 -0
  4. package/dist/access/locks.d.ts +19 -0
  5. package/dist/access/locks.d.ts.map +1 -0
  6. package/dist/access/locks.js +112 -0
  7. package/dist/access/locks.js.map +1 -0
  8. package/dist/access/rbac.d.ts +18 -0
  9. package/dist/access/rbac.d.ts.map +1 -0
  10. package/dist/access/rbac.js +48 -0
  11. package/dist/access/rbac.js.map +1 -0
  12. package/dist/cli.d.ts +4 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +216 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/curation/lint.d.ts +20 -0
  17. package/dist/curation/lint.d.ts.map +1 -0
  18. package/dist/curation/lint.js +176 -0
  19. package/dist/curation/lint.js.map +1 -0
  20. package/dist/curation/provenance.d.ts +21 -0
  21. package/dist/curation/provenance.d.ts.map +1 -0
  22. package/dist/curation/provenance.js +80 -0
  23. package/dist/curation/provenance.js.map +1 -0
  24. package/dist/curation/staleness.d.ts +19 -0
  25. package/dist/curation/staleness.d.ts.map +1 -0
  26. package/dist/curation/staleness.js +67 -0
  27. package/dist/curation/staleness.js.map +1 -0
  28. package/dist/curation/tension.d.ts +20 -0
  29. package/dist/curation/tension.d.ts.map +1 -0
  30. package/dist/curation/tension.js +134 -0
  31. package/dist/curation/tension.js.map +1 -0
  32. package/dist/frontmatter/parser.d.ts +10 -0
  33. package/dist/frontmatter/parser.d.ts.map +1 -0
  34. package/dist/frontmatter/parser.js +29 -0
  35. package/dist/frontmatter/parser.js.map +1 -0
  36. package/dist/frontmatter/schema.d.ts +7 -0
  37. package/dist/frontmatter/schema.d.ts.map +1 -0
  38. package/dist/frontmatter/schema.js +115 -0
  39. package/dist/frontmatter/schema.js.map +1 -0
  40. package/dist/frontmatter/types.d.ts +41 -0
  41. package/dist/frontmatter/types.d.ts.map +1 -0
  42. package/dist/frontmatter/types.js +8 -0
  43. package/dist/frontmatter/types.js.map +1 -0
  44. package/dist/index.d.ts +4 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +94 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/search/bm25.d.ts +19 -0
  49. package/dist/search/bm25.d.ts.map +1 -0
  50. package/dist/search/bm25.js +115 -0
  51. package/dist/search/bm25.js.map +1 -0
  52. package/dist/search/hybrid.d.ts +38 -0
  53. package/dist/search/hybrid.d.ts.map +1 -0
  54. package/dist/search/hybrid.js +162 -0
  55. package/dist/search/hybrid.js.map +1 -0
  56. package/dist/search/reindex.d.ts +15 -0
  57. package/dist/search/reindex.d.ts.map +1 -0
  58. package/dist/search/reindex.js +189 -0
  59. package/dist/search/reindex.js.map +1 -0
  60. package/dist/search/vector.d.ts +9 -0
  61. package/dist/search/vector.d.ts.map +1 -0
  62. package/dist/search/vector.js +128 -0
  63. package/dist/search/vector.js.map +1 -0
  64. package/dist/server.d.ts +6 -0
  65. package/dist/server.d.ts.map +1 -0
  66. package/dist/server.js +72 -0
  67. package/dist/server.js.map +1 -0
  68. package/dist/storage/index-db.d.ts +37 -0
  69. package/dist/storage/index-db.d.ts.map +1 -0
  70. package/dist/storage/index-db.js +145 -0
  71. package/dist/storage/index-db.js.map +1 -0
  72. package/dist/storage/local.d.ts +6 -0
  73. package/dist/storage/local.d.ts.map +1 -0
  74. package/dist/storage/local.js +57 -0
  75. package/dist/storage/local.js.map +1 -0
  76. package/dist/tools/curation.d.ts +22 -0
  77. package/dist/tools/curation.d.ts.map +1 -0
  78. package/dist/tools/curation.js +202 -0
  79. package/dist/tools/curation.js.map +1 -0
  80. package/dist/tools/read.d.ts +74 -0
  81. package/dist/tools/read.d.ts.map +1 -0
  82. package/dist/tools/read.js +254 -0
  83. package/dist/tools/read.js.map +1 -0
  84. package/dist/tools/search.d.ts +13 -0
  85. package/dist/tools/search.d.ts.map +1 -0
  86. package/dist/tools/search.js +190 -0
  87. package/dist/tools/search.js.map +1 -0
  88. package/dist/tools/write.d.ts +18 -0
  89. package/dist/tools/write.d.ts.map +1 -0
  90. package/dist/tools/write.js +465 -0
  91. package/dist/tools/write.js.map +1 -0
  92. package/dist/utils/config.d.ts +12 -0
  93. package/dist/utils/config.d.ts.map +1 -0
  94. package/dist/utils/config.js +94 -0
  95. package/dist/utils/config.js.map +1 -0
  96. package/dist/utils/git.d.ts +23 -0
  97. package/dist/utils/git.d.ts.map +1 -0
  98. package/dist/utils/git.js +114 -0
  99. package/dist/utils/git.js.map +1 -0
  100. package/package.json +69 -0
  101. package/templates/config.yaml +31 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-05-17
9
+
10
+ First public release. Daftari is an MCP server that exposes a curated markdown
11
+ vault to AI agents, exposing 13 tools over stdio.
12
+
13
+ ### Added
14
+
15
+ - **Read path** — `vault_read`, `vault_index`, and `vault_status` for reading
16
+ documents, listing them by collection/status/domain/tags, and reporting vault
17
+ health (file counts, invalid frontmatter, staleness distribution, unresolved
18
+ tensions, recent writes).
19
+ - **Hybrid search** — `vault_search`, `vault_search_related`, and
20
+ `vault_reindex`. BM25 lexical ranking fused with vector semantic similarity,
21
+ with tunable weights and graceful fallback to lexical-only when embeddings are
22
+ unavailable.
23
+ - **Write path** — `vault_write`, `vault_append`, `vault_promote`, and
24
+ `vault_deprecate`. File-level write locks (SQLite-backed, 60-second TTL),
25
+ every write auto-committed to git, and a provenance log of who wrote what.
26
+ - **Curation engine** — `vault_lint`, `vault_tension_log`, and
27
+ `vault_provenance`. Advisory TTL-based staleness detection, contradiction
28
+ (tension) logging, lint checks, and per-document write history. Reports
29
+ problems; does not auto-fix.
30
+ - **Config-driven RBAC** — roles and per-collection read/write/promote
31
+ permissions declared in `.daftari/config.yaml`; enforced across every tool.
32
+ Unknown or absent roles fall back to a deny-all guest.
33
+ - **CLI** — `daftari --init` scaffolds a new vault (collections, RBAC config,
34
+ example documents, git history, search index); `daftari --vault` serves it.
35
+ - 160 tests covering all 13 tools and their supporting modules.
36
+
37
+ [1.0.0]: https://github.com/mavaali/daftari/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mihir Wagle (mavaali)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # Daftari
2
+
3
+ [![CI](https://github.com/mavaali/daftari/actions/workflows/ci.yml/badge.svg)](https://github.com/mavaali/daftari/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/daftari.svg)](https://www.npmjs.com/package/daftari)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ **An MCP server that exposes a curated markdown vault to AI agents.**
8
+
9
+ Daftari is not RAG. It is not a chatbot. It is a *living, agent-maintained
10
+ knowledge vault* — a directory of markdown files that an AI agent reads from,
11
+ writes to, and curates over time, so that knowledge **compounds** instead of
12
+ being re-derived on every query.
13
+
14
+ RAG retrieves chunks and hopes the model stitches them together. Daftari takes
15
+ the other path: the agent does the stitching *once*, writes the synthesized
16
+ result back as a durable document, and every later read starts from that
17
+ compiled answer. Karpathy's framing fits — **compilation over retrieval**. The
18
+ vault gets better the more it is used.
19
+
20
+ A vault is just markdown. You can read it, `git log` it, and edit it by hand.
21
+ Daftari adds the machinery an agent needs to treat it as a shared workspace:
22
+ access control, write arbitration, provenance, and curation.
23
+
24
+ ---
25
+
26
+ ## The four-layer model
27
+
28
+ Daftari is built in four layers. The first two are table stakes. **The moat is
29
+ layers 3 and 4** — anyone can store markdown and check a permission; arbitrating
30
+ concurrent agent writes and managing knowledge decay is the hard part.
31
+
32
+ | Layer | Concern | What Daftari provides |
33
+ |------:|---------|-----------------------|
34
+ | 1 | **Storage** | Markdown + YAML frontmatter on disk, a git history, a rebuildable SQLite index for hybrid BM25 + vector search. |
35
+ | 2 | **Multi-tenant ACL** | Config-driven RBAC. Roles and per-collection read/write/promote permissions declared in `.daftari/config.yaml`. |
36
+ | 3 | **Write arbitration** ⭐ | File-level write locks (SQLite-backed, 60s TTL), every write auto-committed to git, a provenance log of who wrote what and when. |
37
+ | 4 | **Curation decay** ⭐ | The draft → canonical → deprecated lifecycle, TTL-based staleness, tension logging for contradictions, and an advisory linter. Knowledge that stops being true is surfaced, not silently trusted. |
38
+
39
+ Layers 1–2 keep the vault *stored and scoped*. Layers 3–4 keep it *coherent as
40
+ it grows* — which is the entire point of a vault that compounds.
41
+
42
+ ---
43
+
44
+ ## Quickstart
45
+
46
+ ```bash
47
+ # 1. Scaffold a new vault (collections, config, example documents, git, index)
48
+ npx daftari --init ./my-vault
49
+
50
+ # 2. Start the MCP server against it, as an identity with a role
51
+ npx daftari --vault ./my-vault --user me --role admin
52
+ ```
53
+
54
+ The server speaks the Model Context Protocol over stdio. Point any MCP client
55
+ (Claude Desktop, an agent SDK, your own harness) at it. See
56
+ [docs/getting-started.md](docs/getting-started.md) for the full walkthrough,
57
+ including a `claude_desktop_config.json` snippet.
58
+
59
+ ---
60
+
61
+ ## The MCP tools
62
+
63
+ Daftari exposes 13 tools, grouped by layer.
64
+
65
+ **Read path**
66
+
67
+ | Tool | Description |
68
+ |------|-------------|
69
+ | `vault_read` | Read one document: markdown body, parsed frontmatter, and an advisory validation report. |
70
+ | `vault_index` | List documents, filterable by collection, status, domain, or tags. |
71
+ | `vault_status` | Vault health dashboard: total file count, per-collection counts, count of documents with invalid frontmatter, a staleness distribution (fresh/aging/stale), unresolved tensions, and recent write history. |
72
+
73
+ **Search**
74
+
75
+ | Tool | Description |
76
+ |------|-------------|
77
+ | `vault_search` | Hybrid BM25 + vector search across the vault, with tunable ranking weights. |
78
+ | `vault_search_related` | Find documents thematically related to a given document. |
79
+ | `vault_reindex` | Rebuild the SQLite search index from the markdown files. |
80
+
81
+ **Write arbitration**
82
+
83
+ | Tool | Description |
84
+ |------|-------------|
85
+ | `vault_write` | Create or overwrite a document. Stamps `updated`/`updated_by`, preserves `created`, auto-commits. |
86
+ | `vault_append` | Append a markdown section to a document. Re-stamps metadata, auto-commits. |
87
+ | `vault_promote` | Promote a draft to canonical — refuses unless the draft's frontmatter is complete. |
88
+ | `vault_deprecate` | Mark a document deprecated with a required reason and an optional `superseded_by`. |
89
+
90
+ **Curation**
91
+
92
+ | Tool | Description |
93
+ |------|-------------|
94
+ | `vault_tension_log` | Record a contradiction between two documents to the advisory tension log. Records; does not resolve. |
95
+ | `vault_lint` | Run advisory curation checks: stale-past-TTL, orphans, old drafts, stagnant low-confidence files, deprecated-but-linked. |
96
+ | `vault_provenance` | Return a single document's full write history from the provenance log. |
97
+
98
+ The curation engine is **advisory**: `vault_lint` reports problems and
99
+ `vault_tension_log` records contradictions — neither auto-fixes anything. A
100
+ human or a deliberate agent decision drives every change.
101
+
102
+ ---
103
+
104
+ ## What an agent call looks like
105
+
106
+ Daftari speaks the Model Context Protocol over stdio. An agent invokes a tool
107
+ by name with JSON arguments; the server replies with a JSON text block. Here is
108
+ `vault_search` against a freshly scaffolded vault (`npx daftari --init`):
109
+
110
+ **Request**
111
+
112
+ ```json
113
+ { "method": "tools/call", "params": {
114
+ "name": "vault_search",
115
+ "arguments": { "query": "consumption pricing", "limit": 1 } } }
116
+ ```
117
+
118
+ **Response** — `content[0].text`, parsed:
119
+
120
+ ```json
121
+ {
122
+ "query": "consumption pricing",
123
+ "count": 1,
124
+ "vectorUsed": true,
125
+ "weights": { "bm25": 0.5, "vector": 0.5 },
126
+ "hits": [
127
+ {
128
+ "path": "pricing/helios-consumption-pricing.md",
129
+ "title": "Helios Consumption Pricing (Compute Credit Model)",
130
+ "collection": "pricing", "status": "canonical",
131
+ "score": 1, "bm25Score": 1, "vectorScore": 1,
132
+ "snippet": "# Helios Consumption Pricing (Compute Credit Model) Helios is a fictional platform…"
133
+ }
134
+ ]
135
+ }
136
+ ```
137
+
138
+ ---
139
+
140
+ ## RBAC
141
+
142
+ Access is config-driven. There is no user-management system — roles and their
143
+ per-collection permissions live in `.daftari/config.yaml`, and the server is
144
+ started with `--role <name>` to select one:
145
+
146
+ ```yaml
147
+ version: 1
148
+ vault_name: my-vault
149
+
150
+ roles:
151
+ analyst:
152
+ read: [competitive-intel, pricing]
153
+ write: [competitive-intel, _drafts]
154
+ researcher:
155
+ read: ["*"] # "*" matches every collection
156
+ write: [moonshot, _drafts]
157
+ admin:
158
+ read: ["*"]
159
+ write: ["*"]
160
+ promote: true # only this role may promote drafts to canonical
161
+ ```
162
+
163
+ - `read` — collections the role may read and search
164
+ - `write` — collections the role may create, append to, or deprecate in
165
+ - `promote` — whether the role may promote a draft to canonical (default `false`)
166
+
167
+ Starting the server with no `--role`, or with a name not in the config, falls
168
+ back to a deny-all **guest**: every tool is denied.
169
+
170
+ ---
171
+
172
+ ## File format
173
+
174
+ Every document is a markdown file with a YAML frontmatter block. Frontmatter
175
+ *is* the metadata layer — there is no separate database of record.
176
+
177
+ ```markdown
178
+ ---
179
+ title: "Aurora Pipelines — Positioning Overview"
180
+ domain: accumulation
181
+ collection: competitive-intel
182
+ status: canonical
183
+ confidence: medium
184
+ created: 2026-05-17
185
+ updated: 2026-05-17
186
+ updated_by: agent:claude-code
187
+ provenance: direct
188
+ sources:
189
+ - aurora-product-page
190
+ superseded_by: null
191
+ ttl_days: 120
192
+ tags: [aurora, ingestion, competitive]
193
+ ---
194
+
195
+ # Aurora Pipelines — Positioning Overview
196
+
197
+ Aurora Pipelines treats ingestion as an authored, version-controlled artifact
198
+ rather than a managed black box.
199
+
200
+ ## Questions Answered
201
+ - How does Aurora frame the ingestion-vs-transformation boundary?
202
+
203
+ ## Questions Raised
204
+ - Does an authored-pipeline model slow teams down at small scale?
205
+ ```
206
+
207
+ The `## Questions Answered` / `## Questions Raised` pattern is a convention,
208
+ not a requirement: it makes a document's epistemic edges explicit so the next
209
+ agent knows what is settled and what is still open. Full field reference in
210
+ [docs/file-format.md](docs/file-format.md).
211
+
212
+ ---
213
+
214
+ ## What's not in v1
215
+
216
+ A few capabilities were deliberately deferred so v1 ships with a tight,
217
+ defensible surface — a server that does its core job well rather than a wide
218
+ one that does many jobs partially. Not in this release:
219
+
220
+ - **Cloud-hosted multi-tenant server** — an S3/GCS storage backend with
221
+ auth-token identity. v1 runs against a local filesystem as a single process.
222
+ - **Conflict resolution beyond file-level locks** — CRDTs or semantic merge for
223
+ concurrent edits to the same document. v1 arbitrates with 60-second write locks.
224
+ - **Background curation agent** — a scheduler that runs `vault_lint` on a
225
+ cadence. v1's linter is advisory and invoked on demand.
226
+ - **LLM reranking of search results** — a model pass over the BM25 + vector
227
+ candidate set. v1 ships hybrid ranking without a rerank stage.
228
+ - **Enforced domain separation** — v1 *documents* the convention that
229
+ generative-domain documents are not cross-referenced into accumulation pages;
230
+ the write tools do not yet enforce it. v2 will.
231
+
232
+ Each of these is a clean increment on top of a surface that already works —
233
+ deliberately deferred, not forgotten.
234
+
235
+ ---
236
+
237
+ ## Documentation
238
+
239
+ - [docs/getting-started.md](docs/getting-started.md) — end-to-end walkthrough: scaffold, write, search, lint, promote, deprecate, and connect from Claude Desktop.
240
+ - [docs/architecture.md](docs/architecture.md) — the layered architecture, the request path, and the accumulation-vs-generative domain split.
241
+ - [docs/file-format.md](docs/file-format.md) — the complete frontmatter reference and markdown body conventions.
242
+
243
+ ---
244
+
245
+ ## Development
246
+
247
+ ```bash
248
+ npm install
249
+ npm run build # compile TypeScript to dist/
250
+ npm test # run the vitest suite
251
+ npm run dev # run the server in watch mode against the sample vault
252
+ ```
253
+
254
+ Design tenets: functions and types, no classes; tool handlers return
255
+ `Result<T, Error>` rather than throwing; tests mirror the `src/` structure.
256
+
257
+ ## License
258
+
259
+ MIT. Open source — `daftari` on npm, [`mavaali/daftari`](https://github.com/mavaali/daftari) on GitHub.
@@ -0,0 +1,19 @@
1
+ import Database from "better-sqlite3";
2
+ import { type Result } from "../frontmatter/types.js";
3
+ export type LockDb = Database.Database;
4
+ export declare const LOCK_TTL_MS = 60000;
5
+ export interface Lock {
6
+ path: string;
7
+ holder: string;
8
+ acquiredAt: number;
9
+ expiresAt: number;
10
+ }
11
+ export declare function lockDbPath(vaultRoot: string): string;
12
+ export declare function openLockDb(vaultRoot: string): Result<LockDb, Error>;
13
+ export declare function acquireLock(db: LockDb, path: string, holder: string, now?: number): Result<Lock, Error>;
14
+ export declare function releaseLock(db: LockDb, path: string, holder: string): Result<{
15
+ released: boolean;
16
+ }, Error>;
17
+ export declare function isLocked(db: LockDb, path: string, now?: number): boolean;
18
+ export declare function getLock(db: LockDb, path: string, now?: number): Lock | null;
19
+ //# sourceMappingURL=locks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locks.d.ts","sourceRoot":"","sources":["../../src/access/locks.ts"],"names":[],"mappings":"AAcA,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AACtC,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAE/D,MAAM,MAAM,MAAM,GAAG,QAAQ,CAAC,QAAQ,CAAC;AAEvC,eAAO,MAAM,WAAW,QAAS,CAAC;AAElC,MAAM,WAAW,IAAI;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAEpD;AAWD,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAWnE;AA4BD,wBAAgB,WAAW,CACzB,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,GAAG,GAAE,MAAmB,GACvB,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,CAmCrB;AAKD,wBAAgB,WAAW,CACzB,EAAE,EAAE,MAAM,EACV,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,GACb,MAAM,CAAC;IAAE,QAAQ,EAAE,OAAO,CAAA;CAAE,EAAE,KAAK,CAAC,CAQtC;AAGD,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,OAAO,CAKpF;AAGD,wBAAgB,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,GAAE,MAAmB,GAAG,IAAI,GAAG,IAAI,CAIvF"}
@@ -0,0 +1,112 @@
1
+ // File-level write locks, SQLite-backed.
2
+ //
3
+ // A write to a vault document acquires an exclusive lock on its path for the
4
+ // duration of the operation. Locks carry a 60-second TTL: a lock whose
5
+ // expires_at has passed is treated as released, so a crashed or hung writer
6
+ // can never wedge a file permanently. There is no background reaper — TTL is
7
+ // enforced lazily, on every acquire/isLocked check.
8
+ //
9
+ // Locks live in their own .daftari/locks.db (separate from the search index)
10
+ // so a reindex never disturbs them. The file is still ephemeral: every lock
11
+ // expires within a minute, so a lost locks.db costs nothing.
12
+ import { mkdirSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import Database from "better-sqlite3";
15
+ import { err, ok } from "../frontmatter/types.js";
16
+ export const LOCK_TTL_MS = 60_000;
17
+ export function lockDbPath(vaultRoot) {
18
+ return join(vaultRoot, ".daftari", "locks.db");
19
+ }
20
+ const SCHEMA = `
21
+ CREATE TABLE IF NOT EXISTS locks (
22
+ path TEXT PRIMARY KEY,
23
+ holder TEXT NOT NULL,
24
+ acquired_at INTEGER NOT NULL,
25
+ expires_at INTEGER NOT NULL
26
+ );
27
+ `;
28
+ export function openLockDb(vaultRoot) {
29
+ try {
30
+ mkdirSync(join(vaultRoot, ".daftari"), { recursive: true });
31
+ const db = new Database(lockDbPath(vaultRoot));
32
+ db.pragma("journal_mode = WAL");
33
+ db.exec(SCHEMA);
34
+ return ok(db);
35
+ }
36
+ catch (e) {
37
+ const reason = e instanceof Error ? e.message : String(e);
38
+ return err(new Error(`cannot open lock db: ${reason}`));
39
+ }
40
+ }
41
+ function rowToLock(row) {
42
+ return {
43
+ path: row.path,
44
+ holder: row.holder,
45
+ acquiredAt: row.acquired_at,
46
+ expiresAt: row.expires_at,
47
+ };
48
+ }
49
+ // Drops every lock whose TTL has passed. Called before each acquire so an
50
+ // expired lock is auto-released without a separate reaper.
51
+ function purgeExpired(db, now) {
52
+ db.prepare("DELETE FROM locks WHERE expires_at <= ?").run(now);
53
+ }
54
+ // Acquires an exclusive lock on `path` for `holder`. Fails if the file is held
55
+ // by a *different* holder under a still-live TTL. Re-acquiring a lock the same
56
+ // holder already owns succeeds and refreshes the TTL. `now` is injectable for
57
+ // deterministic tests.
58
+ export function acquireLock(db, path, holder, now = Date.now()) {
59
+ if (typeof path !== "string" || path.length === 0) {
60
+ return err(new Error("acquireLock requires a non-empty path"));
61
+ }
62
+ if (typeof holder !== "string" || holder.length === 0) {
63
+ return err(new Error("acquireLock requires a non-empty holder"));
64
+ }
65
+ try {
66
+ purgeExpired(db, now);
67
+ const existing = db.prepare("SELECT * FROM locks WHERE path = ?").get(path);
68
+ if (existing && existing.holder !== holder) {
69
+ return err(new Error(`file is locked by ${existing.holder}: ${path} ` +
70
+ `(expires in ${Math.max(0, existing.expires_at - now)}ms)`));
71
+ }
72
+ const lock = {
73
+ path,
74
+ holder,
75
+ acquiredAt: now,
76
+ expiresAt: now + LOCK_TTL_MS,
77
+ };
78
+ db.prepare(`INSERT OR REPLACE INTO locks (path, holder, acquired_at, expires_at)
79
+ VALUES (?, ?, ?, ?)`).run(lock.path, lock.holder, lock.acquiredAt, lock.expiresAt);
80
+ return ok(lock);
81
+ }
82
+ catch (e) {
83
+ const reason = e instanceof Error ? e.message : String(e);
84
+ return err(new Error(`cannot acquire lock: ${reason}`));
85
+ }
86
+ }
87
+ // Releases a lock. Only the holder may release its own lock; releasing a lock
88
+ // held by someone else (or one that no longer exists) is a no-op that reports
89
+ // `released: false` rather than an error.
90
+ export function releaseLock(db, path, holder) {
91
+ try {
92
+ const info = db.prepare("DELETE FROM locks WHERE path = ? AND holder = ?").run(path, holder);
93
+ return ok({ released: info.changes > 0 });
94
+ }
95
+ catch (e) {
96
+ const reason = e instanceof Error ? e.message : String(e);
97
+ return err(new Error(`cannot release lock: ${reason}`));
98
+ }
99
+ }
100
+ // True if `path` carries a lock whose TTL has not yet passed.
101
+ export function isLocked(db, path, now = Date.now()) {
102
+ const row = db.prepare("SELECT expires_at FROM locks WHERE path = ?").get(path);
103
+ return row !== undefined && row.expires_at > now;
104
+ }
105
+ // Returns the live lock on `path`, or null if none / expired.
106
+ export function getLock(db, path, now = Date.now()) {
107
+ const row = db.prepare("SELECT * FROM locks WHERE path = ?").get(path);
108
+ if (!row || row.expires_at <= now)
109
+ return null;
110
+ return rowToLock(row);
111
+ }
112
+ //# sourceMappingURL=locks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locks.js","sourceRoot":"","sources":["../../src/access/locks.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import type { DaftariConfig, RoleConfig } from "../utils/config.js";
2
+ export declare const GUEST_ROLE = "guest";
3
+ export declare const WILDCARD = "*";
4
+ export interface AccessContext {
5
+ user: string;
6
+ roleName: string;
7
+ role: RoleConfig | null;
8
+ }
9
+ export declare function resolveAccess(config: DaftariConfig, user: string, roleName: string): AccessContext;
10
+ export declare function guestAccess(user?: string): AccessContext;
11
+ export declare function canRead(role: RoleConfig | null, collection: string): boolean;
12
+ export declare function canWrite(role: RoleConfig | null, collection: string): boolean;
13
+ export declare function canPromote(role: RoleConfig | null): boolean;
14
+ export declare function hasAnyRead(role: RoleConfig | null): boolean;
15
+ export declare function filterByReadPermission<T extends {
16
+ collection: string;
17
+ }>(role: RoleConfig | null, items: T[]): T[];
18
+ //# sourceMappingURL=rbac.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rbac.d.ts","sourceRoot":"","sources":["../../src/access/rbac.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEpE,eAAO,MAAM,UAAU,UAAU,CAAC;AAClC,eAAO,MAAM,QAAQ,MAAM,CAAC;AAI5B,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,UAAU,GAAG,IAAI,CAAC;CACzB;AAKD,wBAAgB,aAAa,CAC3B,MAAM,EAAE,aAAa,EACrB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,GACf,aAAa,CAEf;AAID,wBAAgB,WAAW,CAAC,IAAI,SAAU,GAAG,aAAa,CAEzD;AAOD,wBAAgB,OAAO,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAE5E;AAGD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAE7E;AAGD,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,GAAG,OAAO,CAE3D;AAID,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,GAAG,OAAO,CAE3D;AAID,wBAAgB,sBAAsB,CAAC,CAAC,SAAS;IAAE,UAAU,EAAE,MAAM,CAAA;CAAE,EACrE,IAAI,EAAE,UAAU,GAAG,IAAI,EACvB,KAAK,EAAE,CAAC,EAAE,GACT,CAAC,EAAE,CAEL"}
@@ -0,0 +1,48 @@
1
+ // Role-based access control.
2
+ //
3
+ // Permissions are config-driven (.daftari/config.yaml) — Daftari has no user
4
+ // management system of its own. A running server holds one AccessContext: the
5
+ // --user / --role it was started with, resolved against the loaded config.
6
+ //
7
+ // The model fails safe. A role that does not exist in the config, or a server
8
+ // started without --role, resolves to a null role — the implicit "guest" —
9
+ // which is denied everything. Tools never grant access on a missing rule.
10
+ export const GUEST_ROLE = "guest";
11
+ export const WILDCARD = "*";
12
+ // Resolves a --user / --role pair against the config into an AccessContext. An
13
+ // unknown role name yields a null role rather than an error: unknown ⇒ guest
14
+ // ⇒ denied, never granted.
15
+ export function resolveAccess(config, user, roleName) {
16
+ return { user, roleName, role: config.roles[roleName] ?? null };
17
+ }
18
+ // A guest AccessContext — no role, no permissions. Used when the server is
19
+ // started without --role.
20
+ export function guestAccess(user = "guest") {
21
+ return { user, roleName: GUEST_ROLE, role: null };
22
+ }
23
+ function permits(list, collection) {
24
+ return list.includes(WILDCARD) || list.includes(collection);
25
+ }
26
+ // True if the role may read documents in `collection`.
27
+ export function canRead(role, collection) {
28
+ return role !== null && permits(role.read, collection);
29
+ }
30
+ // True if the role may create/modify documents in `collection`.
31
+ export function canWrite(role, collection) {
32
+ return role !== null && permits(role.write, collection);
33
+ }
34
+ // True if the role may promote a draft to canonical.
35
+ export function canPromote(role) {
36
+ return role?.promote ?? false;
37
+ }
38
+ // True if the role has read access to at least one collection. Curation tools
39
+ // (lint, tension log, provenance) are open to anyone with any read grant.
40
+ export function hasAnyRead(role) {
41
+ return role !== null && role.read.length > 0;
42
+ }
43
+ // Keeps only the items in collections the role may read. Each item must carry
44
+ // a `collection` field.
45
+ export function filterByReadPermission(role, items) {
46
+ return items.filter((item) => canRead(role, item.collection));
47
+ }
48
+ //# sourceMappingURL=rbac.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rbac.js","sourceRoot":"","sources":["../../src/access/rbac.ts"],"names":[],"mappings":"AAAA,6BAA6B;AAC7B,EAAE;AACF,6EAA6E;AAC7E,8EAA8E;AAC9E,2EAA2E;AAC3E,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,0EAA0E;AAI1E,MAAM,CAAC,MAAM,UAAU,GAAG,OAAO,CAAC;AAClC,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAG,CAAC;AAU5B,+EAA+E;AAC/E,6EAA6E;AAC7E,2BAA2B;AAC3B,MAAM,UAAU,aAAa,CAC3B,MAAqB,EACrB,IAAY,EACZ,QAAgB;IAEhB,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,IAAI,EAAE,CAAC;AAClE,CAAC;AAED,2EAA2E;AAC3E,0BAA0B;AAC1B,MAAM,UAAU,WAAW,CAAC,IAAI,GAAG,OAAO;IACxC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;AACpD,CAAC;AAED,SAAS,OAAO,CAAC,IAAc,EAAE,UAAkB;IACjD,OAAO,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC;AAC9D,CAAC;AAED,uDAAuD;AACvD,MAAM,UAAU,OAAO,CAAC,IAAuB,EAAE,UAAkB;IACjE,OAAO,IAAI,KAAK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;AACzD,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,QAAQ,CAAC,IAAuB,EAAE,UAAkB;IAClE,OAAO,IAAI,KAAK,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,EAAE,UAAU,CAAC,CAAC;AAC1D,CAAC;AAED,qDAAqD;AACrD,MAAM,UAAU,UAAU,CAAC,IAAuB;IAChD,OAAO,IAAI,EAAE,OAAO,IAAI,KAAK,CAAC;AAChC,CAAC;AAED,8EAA8E;AAC9E,0EAA0E;AAC1E,MAAM,UAAU,UAAU,CAAC,IAAuB;IAChD,OAAO,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;AAC/C,CAAC;AAED,8EAA8E;AAC9E,wBAAwB;AACxB,MAAM,UAAU,sBAAsB,CACpC,IAAuB,EACvB,KAAU;IAEV,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;AAChE,CAAC"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ export declare function initVault(targetPath: string): Promise<number>;
3
+ export declare function run(argv: string[]): Promise<void>;
4
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAsJA,wBAAsB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA8DnE;AAED,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBvD"}