@gmickel/gno 0.42.0 → 1.0.1
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/README.md +22 -11
- package/package.json +1 -1
- package/src/cli/commands/publish.ts +2 -0
- package/src/cli/program.ts +14 -1
- package/src/converters/adapters/markitdownTs/adapter.ts +141 -6
- package/src/converters/errors.ts +23 -0
- package/src/publish/artifact.ts +46 -1
- package/src/publish/encrypted-export.ts +275 -0
- package/src/publish/export-service.ts +70 -3
- package/src/serve/routes/api.ts +8 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://discord.gg/nHEmyJB5tg)
|
|
10
10
|
|
|
11
11
|
> [!TIP]
|
|
12
|
-
> **[gno.sh/publish](https://gno.sh/publish) is live.** Turn any GNO note or collection into a polished, reader-first URL — editorial typography, scoped search, and four visibility modes from public to
|
|
12
|
+
> **[gno.sh/publish](https://gno.sh/publish) is live.** Turn any GNO note or collection into a polished, reader-first URL — editorial typography, scoped search, and four visibility modes from public to encrypted-before-upload. **[See the reader →](#publish-to-gnosh)**
|
|
13
13
|
|
|
14
14
|
> **ClawdHub**: GNO skills bundled for Clawdbot — [clawdhub.com/gmickel/gno](https://clawdhub.com/gmickel/gno)
|
|
15
15
|
|
|
@@ -30,7 +30,7 @@ Use it when:
|
|
|
30
30
|
- **Real retrieval surfaces**: CLI, Web UI, REST API, MCP, SDK
|
|
31
31
|
- **Local-first answers**: grounded synthesis with citations when you want answers, raw retrieval when you do not
|
|
32
32
|
- **Connected knowledge**: backlinks, related notes, graph view, cross-collection navigation
|
|
33
|
-
- **Shareable, not synced**: export a note or collection to [gno.sh](https://gno.sh/publish) as a polished reader page — public, secret, invite-only, or
|
|
33
|
+
- **Shareable, not synced**: export a note or collection to [gno.sh](https://gno.sh/publish) as a polished reader page — public, secret, invite-only, or locally encrypted before upload
|
|
34
34
|
- **Operational fit**: daemon mode, model presets, remote GPU backends, safe config/state on disk
|
|
35
35
|
|
|
36
36
|
### One-Minute Tour
|
|
@@ -92,10 +92,10 @@ gno daemon
|
|
|
92
92
|
|
|
93
93
|
## What's New
|
|
94
94
|
|
|
95
|
-
> Latest release: [
|
|
95
|
+
> Latest release: [v1.0.0](./CHANGELOG.md#100---2026-04-16)
|
|
96
96
|
> Full release history: [CHANGELOG.md](./CHANGELOG.md)
|
|
97
97
|
|
|
98
|
-
- **Publish to [gno.sh](https://gno.sh/publish)**: new `gno publish export` CLI and Web UI action produce a self-contained artifact you upload to the hosted reader — public, secret, invite-only, or
|
|
98
|
+
- **Publish to [gno.sh](https://gno.sh/publish)**: new `gno publish export` CLI and Web UI action produce a self-contained artifact you upload to the hosted reader — public, secret, invite-only, or locally encrypted before upload
|
|
99
99
|
- **Retrieval Quality Upgrade**: stronger BM25 lexical handling, code-aware chunking, terminal result hyperlinks, and per-collection model overrides
|
|
100
100
|
- **Code Embedding Benchmarks**: new benchmark workflow across canonical, real-GNO, and pinned OSS slices for comparing alternate embedding models
|
|
101
101
|
- **Default Embed Model**: built-in presets now use `Qwen3-Embedding-0.6B-GGUF` after it beat `bge-m3` on both code and multilingual prose benchmark lanes
|
|
@@ -625,6 +625,12 @@ gno publish export "gno://work-docs/runbooks/deploy.md" --out ~/Downloads/deploy
|
|
|
625
625
|
# Export a whole collection
|
|
626
626
|
gno publish export work-docs --out ~/Downloads/work-docs.json
|
|
627
627
|
|
|
628
|
+
# Export an encrypted note (ciphertext is created locally before upload)
|
|
629
|
+
gno publish export "gno://work-docs/runbooks/deploy.md" \
|
|
630
|
+
--visibility encrypted \
|
|
631
|
+
--passphrase "correct horse battery staple" \
|
|
632
|
+
--out ~/Downloads/deploy-encrypted.json
|
|
633
|
+
|
|
628
634
|
# Let GNO pick the path (~/Downloads/<slug>-<YYYYMMDD>.json)
|
|
629
635
|
gno publish export work-docs
|
|
630
636
|
```
|
|
@@ -636,16 +642,21 @@ Or use the Web UI:
|
|
|
636
642
|
|
|
637
643
|
Upload the artifact at [gno.sh/studio](https://gno.sh/studio) and pick a visibility mode:
|
|
638
644
|
|
|
639
|
-
| Mode
|
|
640
|
-
|
|
|
641
|
-
| **Public**
|
|
642
|
-
| **Secret link**
|
|
643
|
-
| **Invite-only**
|
|
644
|
-
| **
|
|
645
|
+
| Mode | Use When |
|
|
646
|
+
| :-------------- | :------------------------------------------------------------- |
|
|
647
|
+
| **Public** | Open URL, indexable — talks, blog posts, portfolios |
|
|
648
|
+
| **Secret link** | Unguessable token, rotate / revoke / expire |
|
|
649
|
+
| **Invite-only** | Private space for specific people |
|
|
650
|
+
| **Encrypted** | GNO encrypts locally before upload; readers decrypt in-browser |
|
|
645
651
|
|
|
646
652
|
**Reader experience**: editorial serif typography, drop caps, hanging punctuation, table of contents, keyboard shortcuts (`j/k`, `/`), scoped Pagefind-style search, and backlinks restricted to the published subset. Nothing leaks that you didn't publish.
|
|
647
653
|
|
|
648
|
-
Republishing
|
|
654
|
+
Republishing a public, secret-link, or invite-only artifact updates the same URL. Encrypted shares should be replaced from a fresh local export so the server never needs your plaintext.
|
|
655
|
+
|
|
656
|
+
Encrypted source-backed publish on `gno.sh` is intentionally disabled. For encrypted shares, use:
|
|
657
|
+
|
|
658
|
+
- `gno publish export --visibility encrypted --passphrase ...`, or
|
|
659
|
+
- the browser-side encrypted markdown upload path in `gno.sh/studio`
|
|
649
660
|
|
|
650
661
|
> **Full story**: [gno.sh/publish](https://gno.sh/publish) · **Try it**: [gno.sh/studio](https://gno.sh/studio)
|
|
651
662
|
|
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ import { initStore } from "./shared";
|
|
|
20
20
|
|
|
21
21
|
export interface PublishExportOptions {
|
|
22
22
|
configPath?: string;
|
|
23
|
+
encryptionPassphrase?: string;
|
|
23
24
|
json?: boolean;
|
|
24
25
|
out?: string;
|
|
25
26
|
slug?: string;
|
|
@@ -76,6 +77,7 @@ export async function publishExport(
|
|
|
76
77
|
collections,
|
|
77
78
|
options: {
|
|
78
79
|
routeSlug: options.slug,
|
|
80
|
+
encryptionPassphrase: options.encryptionPassphrase,
|
|
79
81
|
summary: options.summary,
|
|
80
82
|
title: options.title,
|
|
81
83
|
visibility: options.visibility,
|
package/src/cli/program.ts
CHANGED
|
@@ -1641,9 +1641,13 @@ function wirePublishCommand(program: Command): void {
|
|
|
1641
1641
|
.option("--out <path>", "output artifact path")
|
|
1642
1642
|
.option(
|
|
1643
1643
|
"--visibility <mode>",
|
|
1644
|
-
"visibility
|
|
1644
|
+
"visibility mode (public, secret-link, invite-only, encrypted)",
|
|
1645
1645
|
"public"
|
|
1646
1646
|
)
|
|
1647
|
+
.option(
|
|
1648
|
+
"--passphrase <value>",
|
|
1649
|
+
"required for encrypted export; encrypts locally before upload"
|
|
1650
|
+
)
|
|
1647
1651
|
.option("--slug <slug>", "route slug override")
|
|
1648
1652
|
.option("--title <title>", "space title override")
|
|
1649
1653
|
.option("--summary <summary>", "space summary override")
|
|
@@ -1654,6 +1658,8 @@ function wirePublishCommand(program: Command): void {
|
|
|
1654
1658
|
const globals = getGlobals();
|
|
1655
1659
|
|
|
1656
1660
|
const visibility = cmdOpts.visibility as string;
|
|
1661
|
+
const passphrase =
|
|
1662
|
+
typeof cmdOpts.passphrase === "string" ? cmdOpts.passphrase : undefined;
|
|
1657
1663
|
if (
|
|
1658
1664
|
!visibilityValues.includes(
|
|
1659
1665
|
visibility as (typeof visibilityValues)[number]
|
|
@@ -1664,6 +1670,12 @@ function wirePublishCommand(program: Command): void {
|
|
|
1664
1670
|
`Invalid visibility: ${visibility}. Must be public, secret-link, invite-only, or encrypted.`
|
|
1665
1671
|
);
|
|
1666
1672
|
}
|
|
1673
|
+
if (visibility === "encrypted" && !passphrase?.trim()) {
|
|
1674
|
+
throw new CliError(
|
|
1675
|
+
"VALIDATION",
|
|
1676
|
+
"Encrypted publish export requires --passphrase."
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1667
1679
|
|
|
1668
1680
|
const { formatPublishExport, publishExport } =
|
|
1669
1681
|
await import("./commands/publish");
|
|
@@ -1671,6 +1683,7 @@ function wirePublishCommand(program: Command): void {
|
|
|
1671
1683
|
configPath: globals.config,
|
|
1672
1684
|
json: format === "json",
|
|
1673
1685
|
out: typeof cmdOpts.out === "string" ? cmdOpts.out : undefined,
|
|
1686
|
+
encryptionPassphrase: passphrase,
|
|
1674
1687
|
slug: cmdOpts.slug as string | undefined,
|
|
1675
1688
|
summary: cmdOpts.summary as string | undefined,
|
|
1676
1689
|
title: cmdOpts.title as string | undefined,
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
import {
|
|
16
16
|
adapterError,
|
|
17
17
|
corruptError,
|
|
18
|
+
permissionError,
|
|
18
19
|
timeoutError,
|
|
19
20
|
tooLargeError,
|
|
20
21
|
} from "../../errors";
|
|
@@ -33,6 +34,15 @@ const SUPPORTED_MIMES = [
|
|
|
33
34
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
34
35
|
];
|
|
35
36
|
|
|
37
|
+
const PDF_SIGNATURE = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]);
|
|
38
|
+
const CFB_SIGNATURE = new Uint8Array([
|
|
39
|
+
0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
|
|
40
|
+
]);
|
|
41
|
+
const ENCRYPTION_INFO = utf16le("EncryptionInfo");
|
|
42
|
+
const ENCRYPTED_PACKAGE = utf16le("EncryptedPackage");
|
|
43
|
+
const MAX_MESSAGE_LENGTH = 200;
|
|
44
|
+
const PASSWORD_ERROR_REGEX = /password(?:-protected)?|no password given/i;
|
|
45
|
+
|
|
36
46
|
/**
|
|
37
47
|
* Create zero-copy Buffer view of Uint8Array.
|
|
38
48
|
* Assumes input.bytes is immutable (contract requirement).
|
|
@@ -41,6 +51,103 @@ function toBuffer(bytes: Uint8Array): Buffer {
|
|
|
41
51
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
function utf16le(value: string): Uint8Array {
|
|
55
|
+
return new Uint8Array(Buffer.from(value, "utf16le"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasPrefix(bytes: Uint8Array, prefix: Uint8Array): boolean {
|
|
59
|
+
if (bytes.length < prefix.length) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (let index = 0; index < prefix.length; index += 1) {
|
|
64
|
+
if (bytes[index] !== prefix[index]) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function includesBytes(bytes: Uint8Array, needle: Uint8Array): boolean {
|
|
73
|
+
if (needle.length === 0 || bytes.length < needle.length) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
outer: for (
|
|
78
|
+
let index = 0;
|
|
79
|
+
index <= bytes.length - needle.length;
|
|
80
|
+
index += 1
|
|
81
|
+
) {
|
|
82
|
+
for (let offset = 0; offset < needle.length; offset += 1) {
|
|
83
|
+
if (bytes[index + offset] !== needle[offset]) {
|
|
84
|
+
continue outer;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isPasswordProtectedPdf(bytes: Uint8Array): boolean {
|
|
94
|
+
if (!hasPrefix(bytes, PDF_SIGNATURE)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tailStart = Math.max(0, bytes.length - 64 * 1024);
|
|
99
|
+
const tail = Buffer.from(bytes.subarray(tailStart)).toString("latin1");
|
|
100
|
+
return /\/Encrypt\b/.test(tail);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isPasswordProtectedXlsx(bytes: Uint8Array): boolean {
|
|
104
|
+
return (
|
|
105
|
+
hasPrefix(bytes, CFB_SIGNATURE) &&
|
|
106
|
+
includesBytes(bytes, ENCRYPTION_INFO) &&
|
|
107
|
+
includesBytes(bytes, ENCRYPTED_PACKAGE)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isPasswordProtected(input: ConvertInput): boolean {
|
|
112
|
+
if (input.ext === ".pdf") {
|
|
113
|
+
return isPasswordProtectedPdf(input.bytes);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (input.ext === ".xlsx") {
|
|
117
|
+
return isPasswordProtectedXlsx(input.bytes);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sanitizeErrorMessage(message: string, input: ConvertInput): string {
|
|
124
|
+
const normalized = message.trim();
|
|
125
|
+
|
|
126
|
+
let hasControlChars = false;
|
|
127
|
+
for (const char of normalized) {
|
|
128
|
+
const code = char.charCodeAt(0);
|
|
129
|
+
if (
|
|
130
|
+
(code >= 0 && code <= 8) ||
|
|
131
|
+
code === 11 ||
|
|
132
|
+
code === 12 ||
|
|
133
|
+
(code >= 14 && code <= 31)
|
|
134
|
+
) {
|
|
135
|
+
hasControlChars = true;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
normalized.length === 0 ||
|
|
142
|
+
normalized.length > MAX_MESSAGE_LENGTH ||
|
|
143
|
+
hasControlChars
|
|
144
|
+
) {
|
|
145
|
+
return `Could not convert ${input.ext} file to markdown`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return normalized;
|
|
149
|
+
}
|
|
150
|
+
|
|
44
151
|
export const markitdownAdapter: Converter = {
|
|
45
152
|
id: CONVERTER_ID,
|
|
46
153
|
version: CONVERTER_VERSION,
|
|
@@ -55,6 +162,21 @@ export const markitdownAdapter: Converter = {
|
|
|
55
162
|
return { ok: false, error: tooLargeError(input, CONVERTER_ID) };
|
|
56
163
|
}
|
|
57
164
|
|
|
165
|
+
// 1b. Detect password-protected documents before calling markitdown-ts.
|
|
166
|
+
// markitdown-ts logs dependency stack traces to stderr for these files.
|
|
167
|
+
if (isPasswordProtected(input)) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: permissionError(
|
|
171
|
+
input,
|
|
172
|
+
CONVERTER_ID,
|
|
173
|
+
"File is password-protected",
|
|
174
|
+
undefined,
|
|
175
|
+
{ protection: input.ext.slice(1) }
|
|
176
|
+
),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
58
180
|
// 2. Setup timeout handling
|
|
59
181
|
// Note: markitdown-ts doesn't support AbortSignal, so underlying
|
|
60
182
|
// work may continue after timeout (known limitation; process isolation future work)
|
|
@@ -128,14 +250,27 @@ export const markitdownAdapter: Converter = {
|
|
|
128
250
|
}
|
|
129
251
|
|
|
130
252
|
// Map adapter errors
|
|
253
|
+
const message =
|
|
254
|
+
err instanceof Error
|
|
255
|
+
? sanitizeErrorMessage(err.message, input)
|
|
256
|
+
: `Could not convert ${input.ext} file to markdown`;
|
|
257
|
+
|
|
258
|
+
if (PASSWORD_ERROR_REGEX.test(message)) {
|
|
259
|
+
return {
|
|
260
|
+
ok: false,
|
|
261
|
+
error: permissionError(
|
|
262
|
+
input,
|
|
263
|
+
CONVERTER_ID,
|
|
264
|
+
"File is password-protected",
|
|
265
|
+
err,
|
|
266
|
+
{ protection: input.ext.slice(1) }
|
|
267
|
+
),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
131
271
|
return {
|
|
132
272
|
ok: false,
|
|
133
|
-
error: adapterError(
|
|
134
|
-
input,
|
|
135
|
-
CONVERTER_ID,
|
|
136
|
-
err instanceof Error ? err.message : "Unknown error",
|
|
137
|
-
err
|
|
138
|
-
),
|
|
273
|
+
error: adapterError(input, CONVERTER_ID, message, err),
|
|
139
274
|
};
|
|
140
275
|
}
|
|
141
276
|
},
|
package/src/converters/errors.ts
CHANGED
|
@@ -179,6 +179,29 @@ export function corruptError(
|
|
|
179
179
|
});
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Create an error for permission-gated files (for example password-protected documents).
|
|
184
|
+
*/
|
|
185
|
+
export function permissionError(
|
|
186
|
+
input: Pick<ConvertInput, "sourcePath" | "mime" | "ext">,
|
|
187
|
+
converterId: string,
|
|
188
|
+
message: string,
|
|
189
|
+
cause?: unknown,
|
|
190
|
+
details?: Record<string, unknown>
|
|
191
|
+
): ConvertError {
|
|
192
|
+
return convertError("PERMISSION", {
|
|
193
|
+
message,
|
|
194
|
+
retryable: false,
|
|
195
|
+
fatal: false,
|
|
196
|
+
converterId,
|
|
197
|
+
sourcePath: input.sourcePath,
|
|
198
|
+
mime: input.mime,
|
|
199
|
+
ext: input.ext,
|
|
200
|
+
cause,
|
|
201
|
+
details,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
182
205
|
/**
|
|
183
206
|
* Create an error for adapter-level failures.
|
|
184
207
|
*/
|
package/src/publish/artifact.ts
CHANGED
|
@@ -32,13 +32,37 @@ export interface PublishArtifactSpace {
|
|
|
32
32
|
visibility: PublishVisibility;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export interface
|
|
35
|
+
export interface EncryptedArtifactPayload {
|
|
36
|
+
ciphertext: string;
|
|
37
|
+
iterations: number;
|
|
38
|
+
iv: string;
|
|
39
|
+
salt: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EncryptedPublishArtifactSpace {
|
|
43
|
+
encryptedPayload: EncryptedArtifactPayload;
|
|
44
|
+
routeSlug: string;
|
|
45
|
+
secretToken: string;
|
|
46
|
+
sourceType: "note" | "collection";
|
|
47
|
+
visibility: "encrypted";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PublishArtifactV1 {
|
|
36
51
|
exportedAt: string;
|
|
37
52
|
source: string;
|
|
38
53
|
spaces: PublishArtifactSpace[];
|
|
39
54
|
version: 1;
|
|
40
55
|
}
|
|
41
56
|
|
|
57
|
+
export interface PublishArtifactV2 {
|
|
58
|
+
exportedAt: string;
|
|
59
|
+
source: string;
|
|
60
|
+
spaces: EncryptedPublishArtifactSpace[];
|
|
61
|
+
version: 2;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type PublishArtifact = PublishArtifactV1 | PublishArtifactV2;
|
|
65
|
+
|
|
42
66
|
export const PUBLISH_VISIBILITY_VALUES = [
|
|
43
67
|
"public",
|
|
44
68
|
"secret-link",
|
|
@@ -244,6 +268,27 @@ export const buildPublishArtifact = (input: {
|
|
|
244
268
|
version: 1 as const,
|
|
245
269
|
});
|
|
246
270
|
|
|
271
|
+
export const buildEncryptedPublishArtifact = (input: {
|
|
272
|
+
encryptedPayload: EncryptedArtifactPayload;
|
|
273
|
+
routeSlug: string;
|
|
274
|
+
secretToken: string;
|
|
275
|
+
source: string;
|
|
276
|
+
sourceType: "note" | "collection";
|
|
277
|
+
}) => ({
|
|
278
|
+
exportedAt: new Date().toISOString(),
|
|
279
|
+
source: input.source,
|
|
280
|
+
spaces: [
|
|
281
|
+
{
|
|
282
|
+
encryptedPayload: input.encryptedPayload,
|
|
283
|
+
routeSlug: input.routeSlug,
|
|
284
|
+
secretToken: input.secretToken,
|
|
285
|
+
sourceType: input.sourceType,
|
|
286
|
+
visibility: "encrypted" as const,
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
version: 2 as const,
|
|
290
|
+
});
|
|
291
|
+
|
|
247
292
|
export const derivePublishArtifactFilename = (artifact: PublishArtifact) => {
|
|
248
293
|
const routeSlug =
|
|
249
294
|
artifact.spaces[0]?.routeSlug.trim() ||
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { randomBytes, webcrypto } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { EncryptedArtifactPayload, PublishArtifactNote } from "./artifact";
|
|
4
|
+
|
|
5
|
+
const { subtle } = webcrypto;
|
|
6
|
+
|
|
7
|
+
const PBKDF2_ITERATIONS = 210_000;
|
|
8
|
+
const IV_BYTES = 12;
|
|
9
|
+
const SALT_BYTES = 16;
|
|
10
|
+
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
|
|
13
|
+
type MetadataEntry = {
|
|
14
|
+
label: string;
|
|
15
|
+
value: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type NoteBlock =
|
|
19
|
+
| { type: "paragraph"; text: string }
|
|
20
|
+
| { type: "markdown"; markdown: string }
|
|
21
|
+
| { type: "heading"; depth: 2 | 3; id: string; text: string }
|
|
22
|
+
| { type: "list"; items: string[]; style: "ordered" | "unordered" }
|
|
23
|
+
| { code: string; language: string; type: "code" }
|
|
24
|
+
| { alt: string; caption: string; src: string; type: "image" };
|
|
25
|
+
|
|
26
|
+
type ReaderNoteCard = {
|
|
27
|
+
backlinks: Array<{
|
|
28
|
+
excerpt: string;
|
|
29
|
+
noteId: string;
|
|
30
|
+
slug: string;
|
|
31
|
+
title: string;
|
|
32
|
+
}>;
|
|
33
|
+
blocks: NoteBlock[];
|
|
34
|
+
excerpt: string;
|
|
35
|
+
metadata: MetadataEntry[];
|
|
36
|
+
noteId: string;
|
|
37
|
+
outline: Array<{ depth: 2 | 3; id: string; text: string }>;
|
|
38
|
+
related: Array<{
|
|
39
|
+
excerpt: string;
|
|
40
|
+
noteId: string;
|
|
41
|
+
score: number;
|
|
42
|
+
slug: string;
|
|
43
|
+
title: string;
|
|
44
|
+
}>;
|
|
45
|
+
slug: string;
|
|
46
|
+
summary: string;
|
|
47
|
+
title: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type ReaderSpaceData = {
|
|
51
|
+
assetManifest: [];
|
|
52
|
+
currentNote: ReaderNoteCard;
|
|
53
|
+
homeNoteSlug?: string;
|
|
54
|
+
metadataPreview: MetadataEntry[];
|
|
55
|
+
nextNoteSlug?: string;
|
|
56
|
+
noteCards: ReaderNoteCard[];
|
|
57
|
+
previousNoteSlug?: string;
|
|
58
|
+
searchIndex: Array<{
|
|
59
|
+
excerpt: string;
|
|
60
|
+
haystack: string;
|
|
61
|
+
noteId: string;
|
|
62
|
+
slug: string;
|
|
63
|
+
title: string;
|
|
64
|
+
}>;
|
|
65
|
+
shareLabel: string;
|
|
66
|
+
sharePath: string;
|
|
67
|
+
snapshot: {
|
|
68
|
+
createdAt: string;
|
|
69
|
+
id: string;
|
|
70
|
+
lastIndexedAt: string;
|
|
71
|
+
searchEnabled: boolean;
|
|
72
|
+
version: number;
|
|
73
|
+
};
|
|
74
|
+
sourceType: "note" | "collection";
|
|
75
|
+
summary: string;
|
|
76
|
+
title: string;
|
|
77
|
+
visibility: "encrypted";
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const toBase64 = (value: Uint8Array) => Buffer.from(value).toString("base64");
|
|
81
|
+
|
|
82
|
+
const stripFrontmatter = (markdown: string) => {
|
|
83
|
+
if (!markdown.startsWith("---\n")) {
|
|
84
|
+
return markdown;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const endIndex = markdown.indexOf("\n---\n");
|
|
88
|
+
if (endIndex === -1) {
|
|
89
|
+
return markdown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return markdown.slice(endIndex + 5);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const filterMetadata = (
|
|
96
|
+
metadata?: Record<string, string | string[]>
|
|
97
|
+
): MetadataEntry[] => {
|
|
98
|
+
if (!metadata) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Object.entries(metadata).map(([key, value]) => ({
|
|
103
|
+
label: key
|
|
104
|
+
.replace(/([A-Z])/g, " $1")
|
|
105
|
+
.replace(/^./, (char) => char.toUpperCase()),
|
|
106
|
+
value: Array.isArray(value) ? value.join(", ") : value,
|
|
107
|
+
}));
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const parseMarkdownBlocks = (markdown: string): NoteBlock[] => [
|
|
111
|
+
{
|
|
112
|
+
type: "markdown",
|
|
113
|
+
markdown: stripFrontmatter(markdown).trim(),
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const getOutline = (blocks: NoteBlock[]) =>
|
|
118
|
+
blocks.flatMap((block) =>
|
|
119
|
+
block.type === "heading"
|
|
120
|
+
? [{ depth: block.depth, id: block.id, text: block.text }]
|
|
121
|
+
: []
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const makeToken = (slug: string) =>
|
|
125
|
+
`${slug}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
126
|
+
|
|
127
|
+
const deriveExcerpt = (summary: string, blocks: NoteBlock[]) => {
|
|
128
|
+
if (summary.trim()) {
|
|
129
|
+
return summary.trim();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const paragraph = blocks.find((block) => block.type === "paragraph");
|
|
133
|
+
return paragraph?.text.slice(0, 160) ?? "";
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const deriveReaderPayload = (input: {
|
|
137
|
+
exportedAt: string;
|
|
138
|
+
homeNoteSlug?: string;
|
|
139
|
+
notes: PublishArtifactNote[];
|
|
140
|
+
routeSlug: string;
|
|
141
|
+
sourceType: "note" | "collection";
|
|
142
|
+
summary: string;
|
|
143
|
+
title: string;
|
|
144
|
+
}) => {
|
|
145
|
+
const noteCards: ReaderNoteCard[] = input.notes.map((note) => {
|
|
146
|
+
const blocks = parseMarkdownBlocks(note.markdown);
|
|
147
|
+
return {
|
|
148
|
+
noteId: `${input.routeSlug}:${note.slug}`,
|
|
149
|
+
slug: note.slug,
|
|
150
|
+
title: note.title,
|
|
151
|
+
excerpt: deriveExcerpt(note.summary, blocks),
|
|
152
|
+
summary: note.summary,
|
|
153
|
+
blocks,
|
|
154
|
+
metadata: filterMetadata(note.metadata),
|
|
155
|
+
outline: getOutline(blocks),
|
|
156
|
+
backlinks: [],
|
|
157
|
+
related: [],
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const currentNote =
|
|
162
|
+
(input.homeNoteSlug
|
|
163
|
+
? noteCards.find((note) => note.slug === input.homeNoteSlug)
|
|
164
|
+
: undefined) ?? noteCards[0];
|
|
165
|
+
|
|
166
|
+
if (!currentNote) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Encrypted publish "${input.routeSlug}" requires at least one note`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const currentIndex = noteCards.findIndex(
|
|
173
|
+
(note) => note.noteId === currentNote.noteId
|
|
174
|
+
);
|
|
175
|
+
const sharePath = `/locked/${makeToken(input.routeSlug)}`;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
payload: {
|
|
179
|
+
sharePath,
|
|
180
|
+
shareLabel: "Encrypted share",
|
|
181
|
+
visibility: "encrypted" as const,
|
|
182
|
+
sourceType: input.sourceType,
|
|
183
|
+
title: input.title,
|
|
184
|
+
summary: input.summary,
|
|
185
|
+
snapshot: {
|
|
186
|
+
id: `snapshot-${input.routeSlug}-encrypted-v1`,
|
|
187
|
+
version: 1,
|
|
188
|
+
createdAt: input.exportedAt,
|
|
189
|
+
lastIndexedAt: input.exportedAt,
|
|
190
|
+
searchEnabled: noteCards.length > 1,
|
|
191
|
+
},
|
|
192
|
+
metadataPreview: [],
|
|
193
|
+
assetManifest: [],
|
|
194
|
+
searchIndex: noteCards.map((note) => ({
|
|
195
|
+
noteId: note.noteId,
|
|
196
|
+
slug: note.slug,
|
|
197
|
+
title: note.title,
|
|
198
|
+
excerpt: note.excerpt,
|
|
199
|
+
haystack: `${note.title} ${note.summary}`.toLowerCase(),
|
|
200
|
+
})),
|
|
201
|
+
noteCards,
|
|
202
|
+
currentNote,
|
|
203
|
+
previousNoteSlug: noteCards[currentIndex - 1]?.slug,
|
|
204
|
+
nextNoteSlug: noteCards[currentIndex + 1]?.slug,
|
|
205
|
+
homeNoteSlug: input.homeNoteSlug ?? noteCards[0]?.slug,
|
|
206
|
+
} satisfies ReaderSpaceData,
|
|
207
|
+
secretToken: sharePath.replace("/locked/", ""),
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const deriveKey = async (passphrase: string, salt: Uint8Array) => {
|
|
212
|
+
const material = await subtle.importKey(
|
|
213
|
+
"raw",
|
|
214
|
+
encoder.encode(passphrase),
|
|
215
|
+
"PBKDF2",
|
|
216
|
+
false,
|
|
217
|
+
["deriveKey"]
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return subtle.deriveKey(
|
|
221
|
+
{
|
|
222
|
+
name: "PBKDF2",
|
|
223
|
+
hash: "SHA-256",
|
|
224
|
+
salt,
|
|
225
|
+
iterations: PBKDF2_ITERATIONS,
|
|
226
|
+
},
|
|
227
|
+
material,
|
|
228
|
+
{
|
|
229
|
+
name: "AES-GCM",
|
|
230
|
+
length: 256,
|
|
231
|
+
},
|
|
232
|
+
false,
|
|
233
|
+
["encrypt"]
|
|
234
|
+
);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const encryptJson = async (
|
|
238
|
+
passphrase: string,
|
|
239
|
+
payload: unknown
|
|
240
|
+
): Promise<EncryptedArtifactPayload> => {
|
|
241
|
+
const salt = randomBytes(SALT_BYTES);
|
|
242
|
+
const iv = randomBytes(IV_BYTES);
|
|
243
|
+
const key = await deriveKey(passphrase, salt);
|
|
244
|
+
const plaintext = encoder.encode(JSON.stringify(payload));
|
|
245
|
+
const ciphertext = await subtle.encrypt(
|
|
246
|
+
{ name: "AES-GCM", iv },
|
|
247
|
+
key,
|
|
248
|
+
plaintext
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
ciphertext: toBase64(new Uint8Array(ciphertext)),
|
|
253
|
+
iv: toBase64(iv),
|
|
254
|
+
salt: toBase64(salt),
|
|
255
|
+
iterations: PBKDF2_ITERATIONS,
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export const buildEncryptedArtifactPayload = async (input: {
|
|
260
|
+
exportedAt: string;
|
|
261
|
+
homeNoteSlug?: string;
|
|
262
|
+
notes: PublishArtifactNote[];
|
|
263
|
+
passphrase: string;
|
|
264
|
+
routeSlug: string;
|
|
265
|
+
sourceType: "note" | "collection";
|
|
266
|
+
summary: string;
|
|
267
|
+
title: string;
|
|
268
|
+
}) => {
|
|
269
|
+
const { payload, secretToken } = deriveReaderPayload(input);
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
encryptedPayload: await encryptJson(input.passphrase, payload),
|
|
273
|
+
secretToken,
|
|
274
|
+
};
|
|
275
|
+
};
|
|
@@ -10,6 +10,7 @@ import type { DocumentRow, StorePort, TagRow } from "../store/types";
|
|
|
10
10
|
import { parseRef } from "../cli/commands/ref-parser";
|
|
11
11
|
import { parseFrontmatter } from "../ingestion/frontmatter";
|
|
12
12
|
import {
|
|
13
|
+
buildEncryptedPublishArtifact,
|
|
13
14
|
buildPublishArtifact,
|
|
14
15
|
buildExportedMetadata,
|
|
15
16
|
derivePublishSlug,
|
|
@@ -21,8 +22,10 @@ import {
|
|
|
21
22
|
type PublishArtifactNote,
|
|
22
23
|
type PublishVisibility,
|
|
23
24
|
} from "./artifact";
|
|
25
|
+
import { buildEncryptedArtifactPayload } from "./encrypted-export";
|
|
24
26
|
|
|
25
27
|
export interface PublishExportCoreOptions {
|
|
28
|
+
encryptionPassphrase?: string;
|
|
26
29
|
routeSlug?: string;
|
|
27
30
|
summary?: string;
|
|
28
31
|
title?: string;
|
|
@@ -170,6 +173,34 @@ async function exportCollectionArtifact(
|
|
|
170
173
|
collection.name,
|
|
171
174
|
target,
|
|
172
175
|
]);
|
|
176
|
+
const visibility = resolveVisibility(options.visibility);
|
|
177
|
+
|
|
178
|
+
if (visibility === "encrypted") {
|
|
179
|
+
if (!options.encryptionPassphrase) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"Encrypted publish export requires --passphrase or encryptionPassphrase."
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const encrypted = await buildEncryptedArtifactPayload({
|
|
186
|
+
exportedAt: new Date().toISOString(),
|
|
187
|
+
homeNoteSlug: chooseHomeNoteSlug(notes),
|
|
188
|
+
notes,
|
|
189
|
+
passphrase: options.encryptionPassphrase,
|
|
190
|
+
routeSlug,
|
|
191
|
+
sourceType: "collection",
|
|
192
|
+
summary,
|
|
193
|
+
title,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return buildEncryptedPublishArtifact({
|
|
197
|
+
encryptedPayload: encrypted.encryptedPayload,
|
|
198
|
+
routeSlug,
|
|
199
|
+
secretToken: encrypted.secretToken,
|
|
200
|
+
source: collection.name,
|
|
201
|
+
sourceType: "collection",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
173
204
|
|
|
174
205
|
return buildPublishArtifact({
|
|
175
206
|
homeNoteSlug: chooseHomeNoteSlug(notes),
|
|
@@ -179,7 +210,7 @@ async function exportCollectionArtifact(
|
|
|
179
210
|
sourceType: "collection",
|
|
180
211
|
summary,
|
|
181
212
|
title,
|
|
182
|
-
visibility
|
|
213
|
+
visibility,
|
|
183
214
|
});
|
|
184
215
|
}
|
|
185
216
|
|
|
@@ -200,6 +231,42 @@ async function exportDocumentArtifact(
|
|
|
200
231
|
const summary =
|
|
201
232
|
options.summary ?? deriveExportedSummary(markdown, frontmatter);
|
|
202
233
|
const slug = deriveExportedSlug(doc);
|
|
234
|
+
const visibility = resolveVisibility(options.visibility);
|
|
235
|
+
const routeSlug = derivePublishSlug([options.routeSlug ?? "", slug, target]);
|
|
236
|
+
|
|
237
|
+
if (visibility === "encrypted") {
|
|
238
|
+
if (!options.encryptionPassphrase) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
"Encrypted publish export requires --passphrase or encryptionPassphrase."
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const encrypted = await buildEncryptedArtifactPayload({
|
|
245
|
+
exportedAt: new Date().toISOString(),
|
|
246
|
+
notes: [
|
|
247
|
+
{
|
|
248
|
+
markdown,
|
|
249
|
+
metadata: buildExportedMetadata(doc, frontmatter, tags),
|
|
250
|
+
slug,
|
|
251
|
+
summary,
|
|
252
|
+
title,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
passphrase: options.encryptionPassphrase,
|
|
256
|
+
routeSlug,
|
|
257
|
+
sourceType: "note",
|
|
258
|
+
summary,
|
|
259
|
+
title,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return buildEncryptedPublishArtifact({
|
|
263
|
+
encryptedPayload: encrypted.encryptedPayload,
|
|
264
|
+
routeSlug,
|
|
265
|
+
secretToken: encrypted.secretToken,
|
|
266
|
+
source: doc.uri,
|
|
267
|
+
sourceType: "note",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
203
270
|
|
|
204
271
|
return buildPublishArtifact({
|
|
205
272
|
notes: [
|
|
@@ -211,12 +278,12 @@ async function exportDocumentArtifact(
|
|
|
211
278
|
title,
|
|
212
279
|
},
|
|
213
280
|
],
|
|
214
|
-
routeSlug
|
|
281
|
+
routeSlug,
|
|
215
282
|
source: doc.uri,
|
|
216
283
|
sourceType: "note",
|
|
217
284
|
summary,
|
|
218
285
|
title,
|
|
219
|
-
visibility
|
|
286
|
+
visibility,
|
|
220
287
|
});
|
|
221
288
|
}
|
|
222
289
|
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -340,6 +340,7 @@ export interface CreateEditableCopyRequestBody {
|
|
|
340
340
|
}
|
|
341
341
|
|
|
342
342
|
export interface PublishExportRequestBody {
|
|
343
|
+
encryptionPassphrase?: string;
|
|
343
344
|
slug?: string;
|
|
344
345
|
summary?: string;
|
|
345
346
|
target: string;
|
|
@@ -765,6 +766,12 @@ export async function handlePublishExport(
|
|
|
765
766
|
if (body.summary !== undefined && typeof body.summary !== "string") {
|
|
766
767
|
return errorResponse("VALIDATION", "summary must be a string");
|
|
767
768
|
}
|
|
769
|
+
if (
|
|
770
|
+
body.encryptionPassphrase !== undefined &&
|
|
771
|
+
typeof body.encryptionPassphrase !== "string"
|
|
772
|
+
) {
|
|
773
|
+
return errorResponse("VALIDATION", "encryptionPassphrase must be a string");
|
|
774
|
+
}
|
|
768
775
|
if (body.title !== undefined && typeof body.title !== "string") {
|
|
769
776
|
return errorResponse("VALIDATION", "title must be a string");
|
|
770
777
|
}
|
|
@@ -779,6 +786,7 @@ export async function handlePublishExport(
|
|
|
779
786
|
const artifact = await exportPublishArtifact({
|
|
780
787
|
collections: config.collections,
|
|
781
788
|
options: {
|
|
789
|
+
encryptionPassphrase: body.encryptionPassphrase,
|
|
782
790
|
routeSlug: body.slug,
|
|
783
791
|
summary: body.summary,
|
|
784
792
|
title: body.title,
|