@bluefields/cli 0.2.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/LICENSE +661 -0
- package/README.md +181 -0
- package/dist/anchor.d.ts +24 -0
- package/dist/anchor.js +48 -0
- package/dist/anchor.js.map +1 -0
- package/dist/attestation.d.ts +50 -0
- package/dist/attestation.js +90 -0
- package/dist/attestation.js.map +1 -0
- package/dist/bluefield.d.ts +180 -0
- package/dist/bluefield.js +382 -0
- package/dist/bluefield.js.map +1 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +310 -0
- package/dist/cli.js.map +1 -0
- package/dist/constants.d.ts +28 -0
- package/dist/constants.js +30 -0
- package/dist/constants.js.map +1 -0
- package/dist/fs-storage.d.ts +20 -0
- package/dist/fs-storage.js +60 -0
- package/dist/fs-storage.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/keystore.d.ts +40 -0
- package/dist/keystore.js +77 -0
- package/dist/keystore.js.map +1 -0
- package/dist/mcp-server.d.ts +40 -0
- package/dist/mcp-server.js +252 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/paths.d.ts +52 -0
- package/dist/paths.js +77 -0
- package/dist/paths.js.map +1 -0
- package/dist/quiet.d.ts +11 -0
- package/dist/quiet.js +14 -0
- package/dist/quiet.js.map +1 -0
- package/dist/store.d.ts +46 -0
- package/dist/store.js +233 -0
- package/dist/store.js.map +1 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
|
2
|
+
|
|
3
|
+
# Bluefield — git for web data
|
|
4
|
+
|
|
5
|
+
Snapshot web pages into a **content-addressed store on your own disk**, and
|
|
6
|
+
sign every capture with an **offline-verifiable Ed25519 provenance manifest**
|
|
7
|
+
(the *Bluefield Provenance Manifest / BPM*). Track changes over time, diff any
|
|
8
|
+
two snapshots, and prove — cryptographically, months later, with no network —
|
|
9
|
+
exactly what a URL served and when.
|
|
10
|
+
|
|
11
|
+
- 🗄️ **Local-first.** Snapshots live in `~/.bluefield` (or a project-local
|
|
12
|
+
`./.bf`). No server. No account. No hosted database.
|
|
13
|
+
- 🔏 **Signed & tamper-evident.** Each capture is content-addressed by SHA-256
|
|
14
|
+
and signed with a local key. `bf verify` re-hashes the bytes and checks the
|
|
15
|
+
signature — entirely offline.
|
|
16
|
+
- 🕰️ **History & diffs.** `bf log`, `bf diff`, and `bf watch` give you a
|
|
17
|
+
git-like history of any page.
|
|
18
|
+
- 🤝 **BYO everything.** Proxy and LLM are yours, configured by env. There is
|
|
19
|
+
**no telemetry and no phone-home** — grep the source.
|
|
20
|
+
- 🧰 **Library + CLI + MCP.** Use `bf` on the command line, `import` the
|
|
21
|
+
library, or expose it to an AI agent over MCP.
|
|
22
|
+
|
|
23
|
+
It is the same audited engine that powers the hosted Bluefield service
|
|
24
|
+
(`@bluefields/fetcher` / `extractor` / `differ` / `attest`), wired to a local
|
|
25
|
+
embedded database (PGlite) and a filesystem object store — so nothing leaves
|
|
26
|
+
your machine.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 60-second quickstart
|
|
31
|
+
|
|
32
|
+
Install from a checkout (the CLI is not yet published on npm):
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
$ git clone <repository-url> && cd bluefield
|
|
36
|
+
$ pnpm install && pnpm -r build
|
|
37
|
+
$ node packages/cli/dist/cli.js --help # or link the `bf` bin onto your PATH
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> Once it is published, `npm install -g @bluefields/cli` will provide the `bf`
|
|
41
|
+
> command directly. The examples below use `bf` for brevity.
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
# 1. Capture a page. Needs network, and the URL must be https://
|
|
45
|
+
# (the fetcher is https-only and blocks localhost/file:// by design).
|
|
46
|
+
# First run generates your signing key in ~/.bluefield/keys.
|
|
47
|
+
$ bf scrape https://example.com/
|
|
48
|
+
captured https://example.com/
|
|
49
|
+
snapshot 3f9a2b18-...-a7c1
|
|
50
|
+
sha256 e722a1f6...b952df
|
|
51
|
+
signed yes changed: yes
|
|
52
|
+
|
|
53
|
+
# 2. Prove it — recomputes the hash and checks the signature, fully offline.
|
|
54
|
+
$ bf verify 3f9a2b
|
|
55
|
+
✓ VERIFIED 3f9a2b18
|
|
56
|
+
content hash matches : yes
|
|
57
|
+
signature valid : yes
|
|
58
|
+
|
|
59
|
+
# 3. Capture again later; Bluefield tells you if it changed and shows the diff.
|
|
60
|
+
$ bf scrape https://example.com/
|
|
61
|
+
$ bf diff https://example.com/
|
|
62
|
+
--- 3f9a2b18 2026-07-06T10:00:00.000Z
|
|
63
|
+
+++ 9c04 e1af 2026-07-06T18:00:00.000Z
|
|
64
|
+
(content) +2 -1
|
|
65
|
+
- <p>old price: $9</p>
|
|
66
|
+
+ <p>new price: $12</p>
|
|
67
|
+
|
|
68
|
+
# 4. History, and a portable proof bundle anyone can re-verify.
|
|
69
|
+
$ bf log https://example.com/
|
|
70
|
+
$ bf export 3f9a2b --out ./proof # writes <id>.content + <id>.bundle.json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
No API key. The only network is fetching the page itself in step 1 — once a
|
|
74
|
+
capture exists, `verify`, `log`, `diff`, and `export` all run fully offline.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Commands
|
|
79
|
+
|
|
80
|
+
| Command | Does |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `bf scrape <url>` | Capture a URL: fetch → content-address on disk → sign → record |
|
|
83
|
+
| `bf log [url]` | Snapshot history, newest first (all, or one URL) |
|
|
84
|
+
| `bf diff <a> [b]` | Line-diff two snapshots (or the last two of a URL) |
|
|
85
|
+
| `bf watch <url>` | Poll a URL on an interval and report changes |
|
|
86
|
+
| `bf verify <ref>` | Recompute the content hash + verify the signature (offline) |
|
|
87
|
+
| `bf export <ref>` | Write raw content + a portable proof bundle |
|
|
88
|
+
| `bf key` | Show this machine's public signing identity (safe to share) |
|
|
89
|
+
| `bf where` | Print the resolved store + keys locations |
|
|
90
|
+
| `bf mcp` | Run the local MCP server over stdio |
|
|
91
|
+
|
|
92
|
+
A `<ref>` is a **snapshot-id prefix** (e.g. `3f9a2b`) or a **URL** (resolves to
|
|
93
|
+
its latest snapshot). Run `bf help` for all flags.
|
|
94
|
+
|
|
95
|
+
### Structured extraction (optional, BYO LLM)
|
|
96
|
+
|
|
97
|
+
`bf scrape <url> --extract --schema schema.json` runs the extractor to pull
|
|
98
|
+
structured JSON. This is the one feature that calls out to an LLM — set
|
|
99
|
+
`ANTHROPIC_API_KEY` yourself. Everything else is fully offline.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Where things live
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
~/.bluefield/keys/ed25519.key.jwk # PRIVATE signing key, chmod 0600 — never printed
|
|
107
|
+
~/.bluefield/keys/ed25519.pub.jwk # public key + keyId (shareable)
|
|
108
|
+
|
|
109
|
+
<store>/objects/<ab>/<sha256> # content-addressed raw bodies (the "git objects")
|
|
110
|
+
<store>/manifests/<snapshotId>.json # portable proof bundle per snapshot
|
|
111
|
+
<store>/db/ # embedded PGlite metadata (history)
|
|
112
|
+
<store>/trusted/*.jwk # extra public keys you trust for `verify`
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`<store>` resolves like git: `$BF_HOME` if set, else the nearest `./.bf`
|
|
116
|
+
walking up from the cwd, else the global `~/.bluefield`. Your **signing key
|
|
117
|
+
always stays in `~/.bluefield/keys`** (relocatable with `$BF_KEYS_DIR`), so a
|
|
118
|
+
project-local `.bf/` never contains private material and is safe to inspect or
|
|
119
|
+
share.
|
|
120
|
+
|
|
121
|
+
## Verifying someone else's capture
|
|
122
|
+
|
|
123
|
+
`bf verify` fails **closed**: a signature is only accepted if its key is one you
|
|
124
|
+
trust — your own local key, anything in `<store>/trusted/`, or (with explicit
|
|
125
|
+
`--trust-embedded`) the key carried in the bundle. An unknown key is rejected,
|
|
126
|
+
so an exported bundle can't self-authorize.
|
|
127
|
+
|
|
128
|
+
## Transparency anchoring (optional)
|
|
129
|
+
|
|
130
|
+
`bf scrape --anchor` will submit the bundle to a public transparency log when
|
|
131
|
+
the optional anchoring module is present. It is strictly additive: if the
|
|
132
|
+
module isn't in your build, anchoring is skipped with a note and your capture is
|
|
133
|
+
still fully signed and locally verifiable.
|
|
134
|
+
|
|
135
|
+
## Configuration (all optional)
|
|
136
|
+
|
|
137
|
+
| Env | Effect |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| `BF_HOME` | Override the store root outright |
|
|
140
|
+
| `BF_KEYS_DIR` | Relocate the signing-keys directory |
|
|
141
|
+
| `ANTHROPIC_API_KEY` | Enable `--extract` structured extraction |
|
|
142
|
+
| `IPROYAL_*` | Route fetches through your proxy (Patchright tier) |
|
|
143
|
+
| `LOG_LEVEL` | `info`/`debug` to surface the engine's structured logs (default: silent) |
|
|
144
|
+
|
|
145
|
+
## Library
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { openBluefield } from '@bluefields/cli';
|
|
149
|
+
|
|
150
|
+
const bf = await openBluefield(); // opens ~/.bluefield
|
|
151
|
+
const r = await bf.scrape('https://example.com/');
|
|
152
|
+
if (r.ok) {
|
|
153
|
+
const v = await bf.verify(r.snapshotId);
|
|
154
|
+
console.log(v.ok ? 'authentic' : 'FAILED');
|
|
155
|
+
}
|
|
156
|
+
await bf.close();
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## MCP
|
|
160
|
+
|
|
161
|
+
Expose the local store to an AI agent (Claude Desktop, etc.):
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{ "mcpServers": { "bluefield": { "command": "bf", "args": ["mcp"] } } }
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Tools: `scrape`, `log`, `diff`, `verify`, `export`, `identity` — all local, no
|
|
168
|
+
API key.
|
|
169
|
+
|
|
170
|
+
## Development (from the monorepo)
|
|
171
|
+
|
|
172
|
+
```console
|
|
173
|
+
pnpm install
|
|
174
|
+
pnpm --filter @bluefields/cli test # offline fixture tests (PGlite + fake fetcher)
|
|
175
|
+
pnpm --filter @bluefields/cli typecheck
|
|
176
|
+
pnpm --filter @bluefields/cli build
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
AGPL-3.0-or-later. See [LICENSE](./LICENSE).
|
package/dist/anchor.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional transparency anchoring (`--anchor`).
|
|
3
|
+
*
|
|
4
|
+
* The Rekor/OpenTimestamps anchoring lives in BF-rekor's `transparency.ts`,
|
|
5
|
+
* which ships on a separate branch and is NOT present in every build. This
|
|
6
|
+
* module feature-detects it at runtime via a computed import specifier (so the
|
|
7
|
+
* type checker doesn't hard-require the module) and degrades gracefully to a
|
|
8
|
+
* clear "unavailable" result when it is absent — anchoring is strictly
|
|
9
|
+
* additive and never blocks a capture.
|
|
10
|
+
*/
|
|
11
|
+
import type { AttestationBundle } from '@bluefields/attest';
|
|
12
|
+
export interface AnchorResult {
|
|
13
|
+
anchored: boolean;
|
|
14
|
+
/** Present when anchoring succeeded — the transparency proof/receipt. */
|
|
15
|
+
proof?: unknown;
|
|
16
|
+
/** Human-readable reason, especially when `anchored` is false. */
|
|
17
|
+
detail: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Attempt to anchor a bundle in a transparency log. Returns
|
|
21
|
+
* `{ anchored: false }` (never throws) when the optional module is absent or
|
|
22
|
+
* anchoring fails — the caller keeps its offline, locally-verifiable capture.
|
|
23
|
+
*/
|
|
24
|
+
export declare function tryAnchor(bundle: AttestationBundle): Promise<AnchorResult>;
|
package/dist/anchor.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Optional transparency anchoring (`--anchor`).
|
|
4
|
+
*
|
|
5
|
+
* The Rekor/OpenTimestamps anchoring lives in BF-rekor's `transparency.ts`,
|
|
6
|
+
* which ships on a separate branch and is NOT present in every build. This
|
|
7
|
+
* module feature-detects it at runtime via a computed import specifier (so the
|
|
8
|
+
* type checker doesn't hard-require the module) and degrades gracefully to a
|
|
9
|
+
* clear "unavailable" result when it is absent — anchoring is strictly
|
|
10
|
+
* additive and never blocks a capture.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Attempt to anchor a bundle in a transparency log. Returns
|
|
14
|
+
* `{ anchored: false }` (never throws) when the optional module is absent or
|
|
15
|
+
* anchoring fails — the caller keeps its offline, locally-verifiable capture.
|
|
16
|
+
*/
|
|
17
|
+
export async function tryAnchor(bundle) {
|
|
18
|
+
const mod = await loadTransparency();
|
|
19
|
+
if (!mod || typeof mod.anchorBundle !== 'function') {
|
|
20
|
+
return {
|
|
21
|
+
anchored: false,
|
|
22
|
+
detail: 'transparency anchoring is not available in this build (BF-rekor absent)',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const proof = await mod.anchorBundle(bundle);
|
|
27
|
+
return { anchored: true, proof, detail: 'anchored in transparency log' };
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return {
|
|
31
|
+
anchored: false,
|
|
32
|
+
detail: `anchoring failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Dynamically resolve the optional transparency module; null if absent. */
|
|
37
|
+
async function loadTransparency() {
|
|
38
|
+
// Computed specifier: keeps NodeNext from statically requiring a module that
|
|
39
|
+
// is not part of this build's dependency graph.
|
|
40
|
+
const spec = `@bluefields/${'attest'}/transparency`;
|
|
41
|
+
try {
|
|
42
|
+
return (await import(spec));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=anchor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"anchor.js","sourceRoot":"","sources":["../src/anchor.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C;;;;;;;;;GASG;AAYH;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,MAAyB;IACvD,MAAM,GAAG,GAAG,MAAM,gBAAgB,EAAE,CAAC;IACrC,IAAI,CAAC,GAAG,IAAI,OAAO,GAAG,CAAC,YAAY,KAAK,UAAU,EAAE,CAAC;QACnD,OAAO;YACL,QAAQ,EAAE,KAAK;YACf,MAAM,EAAE,yEAAyE;SAClF,CAAC;IACJ,CAAC;IACD,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;QAC7C,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,8BAA8B,EAAE,CAAC;IAC3E,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO;YACL,QAAQ,EAAE,KAAK;YACf,MAAM,EAAE,qBAAqB,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE;SAChF,CAAC;IACJ,CAAC;AACH,CAAC;AAMD,4EAA4E;AAC5E,KAAK,UAAU,gBAAgB;IAC7B,6EAA6E;IAC7E,gDAAgD;IAChD,MAAM,IAAI,GAAG,eAAe,QAAQ,eAAe,CAAC;IACpD,IAAI,CAAC;QACH,OAAO,CAAC,MAAM,MAAM,CAAC,IAAI,CAAC,CAAuB,CAAC;IACpD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provenance glue between the snapshot store and `@bluefields/attest`.
|
|
3
|
+
*
|
|
4
|
+
* The engine signs a JCS-canonical v1 manifest and persists the detached
|
|
5
|
+
* signature + keyId on the snapshot row (not the manifest itself). This module
|
|
6
|
+
* rebuilds the exact signed manifest from a row so a capture can be verified
|
|
7
|
+
* offline, packages it as a portable `AttestationBundle` (manifest + signature
|
|
8
|
+
* + public JWK) for `export`, and builds a LOCAL trust store so `verifyBundle`
|
|
9
|
+
* stays fail-closed against local keys instead of the production trust store.
|
|
10
|
+
*
|
|
11
|
+
* Reconstruction is byte-exact because the manifest never stores the headers
|
|
12
|
+
* themselves — only `sha256(canonicalize(headers))`, and JCS canonicalization
|
|
13
|
+
* sorts keys, so a Postgres `jsonb` round-trip (which may reorder keys) yields
|
|
14
|
+
* the same hash. The signed bytes therefore reproduce identically.
|
|
15
|
+
*/
|
|
16
|
+
import { type AttestationBundle, type Ed25519Jwk, type Signature, type SignedManifest, type TrustStore } from '@bluefields/attest';
|
|
17
|
+
import type { Snapshot } from '@bluefields/db';
|
|
18
|
+
/** Rebuild the exact `ManifestInput` the engine signed, from a snapshot row. */
|
|
19
|
+
export declare function manifestInputFromRow(row: Snapshot): {
|
|
20
|
+
urlCanonical: string;
|
|
21
|
+
canonVersion: number;
|
|
22
|
+
contentHash: string;
|
|
23
|
+
rawHtmlSizeBytes: number;
|
|
24
|
+
fetchedAt: string;
|
|
25
|
+
fetchDurationMs: number;
|
|
26
|
+
fetcherId: string;
|
|
27
|
+
proxyEgressIp: string;
|
|
28
|
+
userAgent: string;
|
|
29
|
+
responseStatus: number;
|
|
30
|
+
responseHeadersHash: string;
|
|
31
|
+
};
|
|
32
|
+
/** The detached signature as stored on the row. */
|
|
33
|
+
export declare function signatureFromRow(row: Snapshot): Signature;
|
|
34
|
+
/** True when the row was actually signed (vs the no-attestation placeholder). */
|
|
35
|
+
export declare function isSigned(row: Snapshot): boolean;
|
|
36
|
+
/** Reconstruct the `SignedManifest` (manifest + signature) from a row. */
|
|
37
|
+
export declare function signedManifestFromRow(row: Snapshot): SignedManifest;
|
|
38
|
+
/** Package a signed manifest + public key as a portable, self-contained bundle. */
|
|
39
|
+
export declare function toBundle(signed: SignedManifest, jwk: Ed25519Jwk): AttestationBundle;
|
|
40
|
+
/** Freeze a bundle to `<store>/manifests/<snapshotId>.json`. */
|
|
41
|
+
export declare function writeBundleSidecar(manifestsDir: string, snapshotId: string, bundle: AttestationBundle): void;
|
|
42
|
+
/** Read a frozen bundle sidecar; null if none exists. */
|
|
43
|
+
export declare function readBundleSidecar(manifestsDir: string, snapshotId: string): AttestationBundle | null;
|
|
44
|
+
/**
|
|
45
|
+
* Build a trust store from a set of public JWKs (keyed by their `kid`). This is
|
|
46
|
+
* the root of trust for local `verify`: only keys explicitly present here are
|
|
47
|
+
* accepted, so an exported bundle's embedded key still can't self-authorize —
|
|
48
|
+
* it must match a key the verifier already trusts.
|
|
49
|
+
*/
|
|
50
|
+
export declare function localTrustStore(jwks: Ed25519Jwk[]): TrustStore;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Provenance glue between the snapshot store and `@bluefields/attest`.
|
|
4
|
+
*
|
|
5
|
+
* The engine signs a JCS-canonical v1 manifest and persists the detached
|
|
6
|
+
* signature + keyId on the snapshot row (not the manifest itself). This module
|
|
7
|
+
* rebuilds the exact signed manifest from a row so a capture can be verified
|
|
8
|
+
* offline, packages it as a portable `AttestationBundle` (manifest + signature
|
|
9
|
+
* + public JWK) for `export`, and builds a LOCAL trust store so `verifyBundle`
|
|
10
|
+
* stays fail-closed against local keys instead of the production trust store.
|
|
11
|
+
*
|
|
12
|
+
* Reconstruction is byte-exact because the manifest never stores the headers
|
|
13
|
+
* themselves — only `sha256(canonicalize(headers))`, and JCS canonicalization
|
|
14
|
+
* sorts keys, so a Postgres `jsonb` round-trip (which may reorder keys) yields
|
|
15
|
+
* the same hash. The signed bytes therefore reproduce identically.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { buildManifest, canonicalize, } from '@bluefields/attest';
|
|
21
|
+
/** Rebuild the exact `ManifestInput` the engine signed, from a snapshot row. */
|
|
22
|
+
export function manifestInputFromRow(row) {
|
|
23
|
+
const headers = (row.responseHeaders ?? {});
|
|
24
|
+
return {
|
|
25
|
+
urlCanonical: row.urlCanonical,
|
|
26
|
+
canonVersion: row.canonVersion,
|
|
27
|
+
contentHash: row.contentHash,
|
|
28
|
+
rawHtmlSizeBytes: row.rawHtmlSizeBytes,
|
|
29
|
+
fetchedAt: row.fetchedAt.toISOString(),
|
|
30
|
+
fetchDurationMs: row.fetchDurationMs,
|
|
31
|
+
fetcherId: row.fetcherId,
|
|
32
|
+
proxyEgressIp: String(row.proxyEgressIp),
|
|
33
|
+
userAgent: row.userAgent,
|
|
34
|
+
responseStatus: row.responseStatus,
|
|
35
|
+
responseHeadersHash: createHash('sha256').update(canonicalize(headers)).digest('hex'),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** The detached signature as stored on the row. */
|
|
39
|
+
export function signatureFromRow(row) {
|
|
40
|
+
return {
|
|
41
|
+
signature: row.attestationSignature,
|
|
42
|
+
keyId: row.attestationKeyId,
|
|
43
|
+
algorithm: row.attestationAlgorithm,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** True when the row was actually signed (vs the no-attestation placeholder). */
|
|
47
|
+
export function isSigned(row) {
|
|
48
|
+
return row.attestationSignature.length > 0 && row.attestationKeyId !== 'v0-no-attestation';
|
|
49
|
+
}
|
|
50
|
+
/** Reconstruct the `SignedManifest` (manifest + signature) from a row. */
|
|
51
|
+
export function signedManifestFromRow(row) {
|
|
52
|
+
return { manifest: buildManifest(manifestInputFromRow(row)), signature: signatureFromRow(row) };
|
|
53
|
+
}
|
|
54
|
+
/** Package a signed manifest + public key as a portable, self-contained bundle. */
|
|
55
|
+
export function toBundle(signed, jwk) {
|
|
56
|
+
return { manifest: signed.manifest, signature: signed.signature, jwk };
|
|
57
|
+
}
|
|
58
|
+
/** Sidecar path for a snapshot's bundle. */
|
|
59
|
+
function sidecarPath(manifestsDir, snapshotId) {
|
|
60
|
+
return join(manifestsDir, `${snapshotId}.json`);
|
|
61
|
+
}
|
|
62
|
+
/** Freeze a bundle to `<store>/manifests/<snapshotId>.json`. */
|
|
63
|
+
export function writeBundleSidecar(manifestsDir, snapshotId, bundle) {
|
|
64
|
+
mkdirSync(manifestsDir, { recursive: true });
|
|
65
|
+
writeFileSync(sidecarPath(manifestsDir, snapshotId), `${JSON.stringify(bundle, null, 2)}\n`, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
/** Read a frozen bundle sidecar; null if none exists. */
|
|
68
|
+
export function readBundleSidecar(manifestsDir, snapshotId) {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(sidecarPath(manifestsDir, snapshotId), 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build a trust store from a set of public JWKs (keyed by their `kid`). This is
|
|
78
|
+
* the root of trust for local `verify`: only keys explicitly present here are
|
|
79
|
+
* accepted, so an exported bundle's embedded key still can't self-authorize —
|
|
80
|
+
* it must match a key the verifier already trusts.
|
|
81
|
+
*/
|
|
82
|
+
export function localTrustStore(jwks) {
|
|
83
|
+
const store = {};
|
|
84
|
+
for (const jwk of jwks) {
|
|
85
|
+
if (jwk.kid)
|
|
86
|
+
store[jwk.kid] = jwk;
|
|
87
|
+
}
|
|
88
|
+
return store;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=attestation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attestation.js","sourceRoot":"","sources":["../src/attestation.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Bluefield local library — "git for web data".
|
|
3
|
+
*
|
|
4
|
+
* A thin orchestration layer over the reused engine packages:
|
|
5
|
+
* - `@bluefields/fetcher` → fetch + content-address + sign (fetchAndStore)
|
|
6
|
+
* - `@bluefields/extractor` → optional structured extraction (BYO LLM key)
|
|
7
|
+
* - `@bluefields/differ` → line/structural diffs between snapshots
|
|
8
|
+
* - `@bluefields/attest` → offline signature verification
|
|
9
|
+
*
|
|
10
|
+
* It injects a LOCAL `DbClient` (PGlite) and a LOCAL filesystem
|
|
11
|
+
* `StorageAdapter` so the whole engine runs with no server, no Postgres, no
|
|
12
|
+
* KMS, and no network beyond the fetch itself. There is NO phone-home and NO
|
|
13
|
+
* telemetry anywhere in this package.
|
|
14
|
+
*
|
|
15
|
+
* Everything the CLI and the MCP server do goes through this class, so both
|
|
16
|
+
* surfaces stay thin.
|
|
17
|
+
*/
|
|
18
|
+
import { type Extraction } from '@bluefields/db';
|
|
19
|
+
import { type FetchOptions, type FetcherAdapter } from '@bluefields/fetcher';
|
|
20
|
+
import { type AnchorResult } from './anchor.js';
|
|
21
|
+
import { createFsStorageAdapter } from './fs-storage.js';
|
|
22
|
+
import { type LocalIdentity } from './keystore.js';
|
|
23
|
+
import { type StorePaths } from './paths.js';
|
|
24
|
+
import { type Store } from './store.js';
|
|
25
|
+
/** Options for opening a Bluefield store. */
|
|
26
|
+
export interface OpenOptions {
|
|
27
|
+
/** Override the resolved store root (default: `$BF_HOME` / nearest `.bf` / `~/.bluefield`). */
|
|
28
|
+
storeRoot?: string;
|
|
29
|
+
/** Override the keys directory (default: `~/.bluefield/keys`). Tests pass a temp dir. */
|
|
30
|
+
keysDir?: string;
|
|
31
|
+
/** Environment for fetcher/proxy/LLM config (default: `process.env`). */
|
|
32
|
+
env?: NodeJS.ProcessEnv;
|
|
33
|
+
/** Inject a fetcher adapter (test seam / BYO). Default: `getDefaultFetcher(env)`. */
|
|
34
|
+
fetcher?: FetcherAdapter;
|
|
35
|
+
/** Respect robots.txt on capture (default: true). */
|
|
36
|
+
respectRobotsTxt?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** A capture request. */
|
|
39
|
+
export interface ScrapeOptions {
|
|
40
|
+
/** Sign the manifest with the local key (default: true). */
|
|
41
|
+
sign?: boolean;
|
|
42
|
+
/** Run structured extraction after capture (needs `ANTHROPIC_API_KEY` unless schema is null → markdown-only). */
|
|
43
|
+
extract?: boolean;
|
|
44
|
+
/** JSON schema for structured extraction (null/omitted → markdown-only). */
|
|
45
|
+
schema?: Record<string, unknown> | null;
|
|
46
|
+
/** One-sentence extraction intent. */
|
|
47
|
+
intent?: string | null;
|
|
48
|
+
/** Attempt transparency anchoring (best-effort; no-op if BF-rekor absent). */
|
|
49
|
+
anchor?: boolean;
|
|
50
|
+
/** Per-fetch options forwarded to the fetcher adapter. */
|
|
51
|
+
fetchOptions?: FetchOptions;
|
|
52
|
+
}
|
|
53
|
+
/** Outcome of a capture. */
|
|
54
|
+
export type ScrapeResult = {
|
|
55
|
+
ok: true;
|
|
56
|
+
snapshotId: string;
|
|
57
|
+
contentHash: string;
|
|
58
|
+
url: string;
|
|
59
|
+
signed: boolean;
|
|
60
|
+
/** True when this capture's content hash differs from the previous snapshot of the URL. */
|
|
61
|
+
changed: boolean;
|
|
62
|
+
/** Present when `extract` was requested. */
|
|
63
|
+
extraction?: Extraction;
|
|
64
|
+
/** Present when `anchor` was requested. */
|
|
65
|
+
anchor?: AnchorResult;
|
|
66
|
+
} | {
|
|
67
|
+
ok: false;
|
|
68
|
+
reason: 'robots_disallowed';
|
|
69
|
+
url: string;
|
|
70
|
+
};
|
|
71
|
+
/** A compact snapshot summary for `log`. */
|
|
72
|
+
export interface SnapshotSummary {
|
|
73
|
+
snapshotId: string;
|
|
74
|
+
url: string;
|
|
75
|
+
contentHash: string;
|
|
76
|
+
fetchedAt: Date;
|
|
77
|
+
responseStatus: number;
|
|
78
|
+
sizeBytes: number;
|
|
79
|
+
signed: boolean;
|
|
80
|
+
keyId: string;
|
|
81
|
+
}
|
|
82
|
+
/** Result of a diff between two snapshots. */
|
|
83
|
+
export interface DiffResult {
|
|
84
|
+
from: SnapshotSummary;
|
|
85
|
+
to: SnapshotSummary;
|
|
86
|
+
mode: 'content' | 'structured';
|
|
87
|
+
/** Unified-diff text (content mode) or a JSON string of the patch ops (structured mode). */
|
|
88
|
+
text: string;
|
|
89
|
+
added: number;
|
|
90
|
+
removed: number;
|
|
91
|
+
/** Structured-mode JSON Patch ops (empty in content mode). */
|
|
92
|
+
ops: unknown[];
|
|
93
|
+
identical: boolean;
|
|
94
|
+
}
|
|
95
|
+
/** Result of verifying a snapshot or bundle. */
|
|
96
|
+
export interface VerifyResult {
|
|
97
|
+
ok: boolean;
|
|
98
|
+
snapshotId?: string;
|
|
99
|
+
keyId: string;
|
|
100
|
+
/** True when the on-disk content re-hashes to the manifest's content hash. */
|
|
101
|
+
contentHashMatches: boolean;
|
|
102
|
+
/** True when the detached signature verifies against a trusted key. */
|
|
103
|
+
signatureValid: boolean;
|
|
104
|
+
detail: string;
|
|
105
|
+
}
|
|
106
|
+
/** Result of exporting a snapshot to a directory. */
|
|
107
|
+
export interface ExportResult {
|
|
108
|
+
snapshotId: string;
|
|
109
|
+
dir: string;
|
|
110
|
+
contentFile: string;
|
|
111
|
+
bundleFile: string;
|
|
112
|
+
}
|
|
113
|
+
/** Open (or create) a local Bluefield store. Call `close()` when done. */
|
|
114
|
+
export declare function openBluefield(opts?: OpenOptions): Promise<Bluefield>;
|
|
115
|
+
export declare class Bluefield {
|
|
116
|
+
readonly paths: StorePaths;
|
|
117
|
+
readonly identity: LocalIdentity;
|
|
118
|
+
private readonly store;
|
|
119
|
+
private readonly storage;
|
|
120
|
+
private readonly fetcher;
|
|
121
|
+
private readonly env;
|
|
122
|
+
private readonly respectRobotsTxt;
|
|
123
|
+
constructor(paths: StorePaths, identity: LocalIdentity, store: Store, storage: ReturnType<typeof createFsStorageAdapter>, fetcher: FetcherAdapter, env: NodeJS.ProcessEnv, respectRobotsTxt: boolean);
|
|
124
|
+
private get db();
|
|
125
|
+
/** Capture a URL: fetch → content-address on disk → sign → record → sidecar. */
|
|
126
|
+
scrape(url: string, opts?: ScrapeOptions): Promise<ScrapeResult>;
|
|
127
|
+
/** Run the extractor over a snapshot's HTML (BYO LLM key via env). */
|
|
128
|
+
private runExtract;
|
|
129
|
+
/** History: newest-first snapshots, optionally filtered to one URL. */
|
|
130
|
+
log(url?: string | null, opts?: {
|
|
131
|
+
limit?: number;
|
|
132
|
+
}): Promise<SnapshotSummary[]>;
|
|
133
|
+
/**
|
|
134
|
+
* Diff two snapshots. With one URL and no explicit `to`, diffs the two most
|
|
135
|
+
* recent snapshots of that URL. `content` mode line-diffs the raw bodies;
|
|
136
|
+
* `structured` mode JSON-patch-diffs their extractions' structured data.
|
|
137
|
+
*/
|
|
138
|
+
diff(fromRef: string, toRef?: string, opts?: {
|
|
139
|
+
mode?: 'content' | 'structured';
|
|
140
|
+
}): Promise<DiffResult>;
|
|
141
|
+
/**
|
|
142
|
+
* Poll a URL on an interval, capturing each tick and reporting whether the
|
|
143
|
+
* content changed. Runs `times` iterations (default: forever) or until the
|
|
144
|
+
* `signal` aborts. `onTick` is invoked after every capture.
|
|
145
|
+
*/
|
|
146
|
+
watch(url: string, opts?: {
|
|
147
|
+
intervalMs?: number;
|
|
148
|
+
times?: number;
|
|
149
|
+
signal?: AbortSignal;
|
|
150
|
+
onTick?: (r: ScrapeResult, iteration: number) => void;
|
|
151
|
+
scrape?: ScrapeOptions;
|
|
152
|
+
}): Promise<ScrapeResult[]>;
|
|
153
|
+
/**
|
|
154
|
+
* Verify a snapshot (by ref) or a previously-exported bundle. Recomputes the
|
|
155
|
+
* content hash from the stored object AND checks the signature against a
|
|
156
|
+
* trusted key (the local identity plus any keys under `<store>/trusted`, plus
|
|
157
|
+
* `extraKeys`). Fails closed on an unknown key unless `trustEmbedded` is set.
|
|
158
|
+
*/
|
|
159
|
+
verify(ref: string, opts?: {
|
|
160
|
+
trustEmbedded?: boolean;
|
|
161
|
+
extraKeys?: import('@bluefields/attest').Ed25519Jwk[];
|
|
162
|
+
}): Promise<VerifyResult>;
|
|
163
|
+
/** Write a snapshot's raw content + portable bundle into `destDir`. */
|
|
164
|
+
export(ref: string, destDir: string): Promise<ExportResult>;
|
|
165
|
+
/** Retrieve the raw stored content for a snapshot ref. */
|
|
166
|
+
getContent(ref: string): Promise<string>;
|
|
167
|
+
/** The public identity (safe to share). */
|
|
168
|
+
publicIdentity(): {
|
|
169
|
+
keyId: string;
|
|
170
|
+
publicJwk: import("@bluefields/attest").Ed25519Jwk;
|
|
171
|
+
};
|
|
172
|
+
close(): Promise<void>;
|
|
173
|
+
private loadTrustedKeys;
|
|
174
|
+
private snapshotById;
|
|
175
|
+
private latestForUrl;
|
|
176
|
+
/** Resolve a ref (snapshot-id prefix, or URL → latest) to a row, or null. */
|
|
177
|
+
private resolve;
|
|
178
|
+
private mustResolve;
|
|
179
|
+
private extractionFor;
|
|
180
|
+
}
|