@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 CHANGED
@@ -9,7 +9,7 @@
9
9
  [![Discord](./assets/badges/discord.svg)](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 end-to-end encrypted. **[See the reader →](#publish-to-gnosh)**
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 end-to-end encrypted
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: [v0.40.2](./CHANGELOG.md#0402---2026-04-06)
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 end-to-end encrypted
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 | Use When |
640
- | :----------------------- | :-------------------------------------------------- |
641
- | **Public** | Open URL, indexable — talks, blog posts, portfolios |
642
- | **Secret link** | Unguessable token, rotate / revoke / expire |
643
- | **Invite-only** | Private space for specific people |
644
- | **End-to-end encrypted** | WebCrypto bundle, passphrase decrypts in-browser |
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 an artifact updates the same URL no churn for your readers.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.42.0",
3
+ "version": "1.0.1",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -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,
@@ -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 hint (public, secret-link, invite-only, encrypted)",
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
  },
@@ -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
  */
@@ -32,13 +32,37 @@ export interface PublishArtifactSpace {
32
32
  visibility: PublishVisibility;
33
33
  }
34
34
 
35
- export interface PublishArtifact {
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: resolveVisibility(options.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: derivePublishSlug([options.routeSlug ?? "", slug, target]),
281
+ routeSlug,
215
282
  source: doc.uri,
216
283
  sourceType: "note",
217
284
  summary,
218
285
  title,
219
- visibility: resolveVisibility(options.visibility),
286
+ visibility,
220
287
  });
221
288
  }
222
289
 
@@ -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,