narrarium-astro-reader 0.1.33 → 0.1.37

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.
@@ -1,4 +1,4 @@
1
- /** Return the singleton salt for this build process, creating it on first call. */
1
+ /** Return the singleton salt for this build/dev process, creating it on first call. */
2
2
  export declare function getBuildSalt(): Buffer;
3
3
  /** Base64-encoded build salt, ready to embed in an HTML attribute. */
4
4
  export declare function getBuildSaltBase64(): string;
@@ -30,4 +30,17 @@ export declare function encryptString(plaintext: string, password: string): Encr
30
30
  * the result equals `CANARY_PLAINTEXT` — no fast-hash oracle in the HTML.
31
31
  */
32
32
  export declare function encryptCanary(password: string): EncryptedChunk;
33
+ /**
34
+ * Encrypt a raw Buffer with AES-256-GCM using the same PBKDF2-derived build
35
+ * key. Returns raw `iv` and `ct` Buffers for binary file endpoints.
36
+ *
37
+ * Wire format (concatenate before serving):
38
+ * [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
39
+ *
40
+ * Client side: `bytes.slice(0, 12)` = IV, `bytes.slice(12)` = ciphertext+tag.
41
+ */
42
+ export declare function encryptBufferRaw(data: Buffer, password: string): {
43
+ iv: Buffer;
44
+ ct: Buffer;
45
+ };
33
46
  //# sourceMappingURL=content-crypto.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"content-crypto.d.ts","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAcA,mFAAmF;AACnF,wBAAgB,YAAY,IAAI,MAAM,CAMrC;AAED,sEAAsE;AACtE,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAMD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,iBAAiB,CAAC;AAE/C,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,CAWjF;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,CAE9D"}
1
+ {"version":3,"file":"content-crypto.d.ts","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAiBA,uFAAuF;AACvF,wBAAgB,YAAY,IAAI,MAAM,CAOrC;AAED,sEAAsE;AACtE,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAMD;;;;GAIG;AACH,eAAO,MAAM,gBAAgB,iBAAiB,CAAC;AAE/C,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,0DAA0D;IAC1D,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,CAWjF;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc,CAE9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAQ3F"}
@@ -8,14 +8,18 @@ import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
8
8
  * with the same parameters. A public salt is fine — it only prevents rainbow
9
9
  * tables; the security comes from the password entropy.
10
10
  */
11
- let _buildSalt = null;
12
- /** Return the singleton salt for this build process, creating it on first call. */
11
+ // Use a process-global symbol so the singleton survives Vite module re-evaluation
12
+ // in dev mode (HMR). Without this, each Astro page request may get a fresh module
13
+ // instance with a new random salt, making the canary and content salts diverge.
14
+ const SALT_GLOBAL_KEY = Symbol.for("narrarium.buildSalt");
15
+ /** Return the singleton salt for this build/dev process, creating it on first call. */
13
16
  export function getBuildSalt() {
14
- if (!_buildSalt) {
15
- _buildSalt = randomBytes(16);
17
+ const g = globalThis;
18
+ if (!g[SALT_GLOBAL_KEY]) {
19
+ g[SALT_GLOBAL_KEY] = randomBytes(16);
16
20
  console.info("[narrarium-reader] Content encryption enabled (AES-256-GCM).");
17
21
  }
18
- return _buildSalt;
22
+ return g[SALT_GLOBAL_KEY];
19
23
  }
20
24
  /** Base64-encoded build salt, ready to embed in an HTML attribute. */
21
25
  export function getBuildSaltBase64() {
@@ -59,4 +63,22 @@ export function encryptString(plaintext, password) {
59
63
  export function encryptCanary(password) {
60
64
  return encryptString(CANARY_PLAINTEXT, password);
61
65
  }
66
+ /**
67
+ * Encrypt a raw Buffer with AES-256-GCM using the same PBKDF2-derived build
68
+ * key. Returns raw `iv` and `ct` Buffers for binary file endpoints.
69
+ *
70
+ * Wire format (concatenate before serving):
71
+ * [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
72
+ *
73
+ * Client side: `bytes.slice(0, 12)` = IV, `bytes.slice(12)` = ciphertext+tag.
74
+ */
75
+ export function encryptBufferRaw(data, password) {
76
+ const salt = getBuildSalt();
77
+ const key = deriveKey(password, salt);
78
+ const iv = randomBytes(12);
79
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
80
+ const body = Buffer.concat([cipher.update(data), cipher.final()]);
81
+ const tag = cipher.getAuthTag(); // always 16 bytes for AES-GCM
82
+ return { iv, ct: Buffer.concat([body, tag]) };
83
+ }
62
84
  //# sourceMappingURL=content-crypto.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"content-crypto.js","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEtE;;;;;;;;GAQG;AAEH,IAAI,UAAU,GAAkB,IAAI,CAAC;AAErC,mFAAmF;AACnF,MAAM,UAAU,YAAY;IAC1B,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,UAAU,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;QAC7B,OAAO,CAAC,IAAI,CAAC,8DAA8D,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,UAAU,CAAC;AACpB,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,kBAAkB;IAChC,OAAO,YAAY,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAC3C,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB,EAAE,IAAY;IAC/C,OAAO,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,CAAC,CAAC;AAC3D,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,cAAc,CAAC;AAS/C;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,SAAiB,EAAE,QAAgB;IAC/D,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC;IAC5B,MAAM,GAAG,GAAG,SAAS,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,EAAE,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,MAAM,MAAM,GAAG,cAAc,CAAC,aAAa,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IACtD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAC/E,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,8BAA8B;IAC/D,OAAO;QACL,EAAE,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACzB,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;KAClD,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,QAAgB;IAC5C,OAAO,aAAa,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;AACnD,CAAC"}
1
+ {"version":3,"file":"content-crypto.js","sourceRoot":"","sources":["../../src/lib/content-crypto.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEtE;;;;;;;;GAQG;AAEH,kFAAkF;AAClF,kFAAkF;AAClF,gFAAgF;AAChF,MAAM,eAAe,GAAG,MAAM,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;AAE1D,uFAAuF;AACvF,MAAM,UAAU,YAAY;IAC1B,MAAM,CAAC,GAAG,UAAuE,CAAC;IAClF,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC,EAAE,CAAC;QACxB,CAAC,CAAC,eAAe,CAAC,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;QACrC,OAAO,CAAC,IAAI,CAAC,8DAA8D,CAAC,CAAC;IAC/E,CAAC;IACD,OAAO,CAAC,CAAC,eAAe,CAAE,CAAC;AAC7B,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,kBAAkB;IAChC,OAAO,YAAY,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAC3C,CAAC;AAED,SAAS,SAAS,CAAC,QAAgB,EAAE,IAAY;IAC/C,OAAO,UAAU,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,QAAQ,CAAC,CAAC;AAC3D,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,cAAc,CAAC;AAS/C;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,SAAiB,EAAE,QAAgB;IAC/D,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC;IAC5B,MAAM,GAAG,GAAG,SAAS,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,EAAE,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,MAAM,MAAM,GAAG,cAAc,CAAC,aAAa,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IACtD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAC/E,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,8BAA8B;IAC/D,OAAO;QACL,EAAE,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,CAAC;QACzB,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;KAClD,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,QAAgB;IAC5C,OAAO,aAAa,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;AACnD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,QAAgB;IAC7D,MAAM,IAAI,GAAG,YAAY,EAAE,CAAC;IAC5B,MAAM,GAAG,GAAG,SAAS,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;IACtC,MAAM,EAAE,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,MAAM,MAAM,GAAG,cAAc,CAAC,aAAa,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IACtD,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAClE,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC,CAAC,8BAA8B;IAC/D,OAAO,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC;AAChD,CAAC"}
@@ -0,0 +1,17 @@
1
+ export interface EvaluationData {
2
+ id: string;
3
+ title: string;
4
+ htmlContent: string;
5
+ }
6
+ /**
7
+ * Load the chapter-level evaluation from
8
+ * `evaluations/chapters/<chapterSlug>.md`, or return null if the file does
9
+ * not exist (evaluation has not been run yet).
10
+ */
11
+ export declare function loadChapterEvaluation(chapterSlug: string): Promise<EvaluationData | null>;
12
+ /**
13
+ * Load the paragraph-level evaluation from
14
+ * `evaluations/paragraphs/<chapterSlug>/<paragraphSlug>.md`, or return null.
15
+ */
16
+ export declare function loadParagraphEvaluation(chapterSlug: string, paragraphSlug: string): Promise<EvaluationData | null>;
17
+ //# sourceMappingURL=evaluations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"evaluations.d.ts","sourceRoot":"","sources":["../../src/lib/evaluations.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAe/F;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAC3C,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,GACpB,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAwBhC"}
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { marked } from "marked";
4
+ import { parseNarrariumMarkdownDocument } from "narrarium";
5
+ import { getBookRoot } from "./book.js";
6
+ /**
7
+ * Load the chapter-level evaluation from
8
+ * `evaluations/chapters/<chapterSlug>.md`, or return null if the file does
9
+ * not exist (evaluation has not been run yet).
10
+ */
11
+ export async function loadChapterEvaluation(chapterSlug) {
12
+ const root = getBookRoot();
13
+ const filePath = path.join(root, "evaluations", "chapters", `${chapterSlug}.md`);
14
+ const raw = await readFile(filePath, "utf8").catch(() => null);
15
+ if (!raw)
16
+ return null;
17
+ const doc = parseNarrariumMarkdownDocument(`evaluations/chapters/${chapterSlug}.md`, raw);
18
+ const fm = doc.frontmatter;
19
+ const htmlContent = await marked.parse(doc.body ?? "");
20
+ return {
21
+ id: String(fm.id ?? `evaluation:chapter:${chapterSlug}`),
22
+ title: String(fm.title ?? "Chapter Evaluation"),
23
+ htmlContent,
24
+ };
25
+ }
26
+ /**
27
+ * Load the paragraph-level evaluation from
28
+ * `evaluations/paragraphs/<chapterSlug>/<paragraphSlug>.md`, or return null.
29
+ */
30
+ export async function loadParagraphEvaluation(chapterSlug, paragraphSlug) {
31
+ const root = getBookRoot();
32
+ const filePath = path.join(root, "evaluations", "paragraphs", chapterSlug, `${paragraphSlug}.md`);
33
+ const raw = await readFile(filePath, "utf8").catch(() => null);
34
+ if (!raw)
35
+ return null;
36
+ const doc = parseNarrariumMarkdownDocument(`evaluations/paragraphs/${chapterSlug}/${paragraphSlug}.md`, raw);
37
+ const fm = doc.frontmatter;
38
+ const htmlContent = await marked.parse(doc.body ?? "");
39
+ return {
40
+ id: String(fm.id ?? `evaluation:paragraph:${chapterSlug}:${paragraphSlug}`),
41
+ title: String(fm.title ?? "Scene Evaluation"),
42
+ htmlContent,
43
+ };
44
+ }
45
+ //# sourceMappingURL=evaluations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"evaluations.js","sourceRoot":"","sources":["../../src/lib/evaluations.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,8BAA8B,EAAE,MAAM,WAAW,CAAC;AAC3D,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAQxC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,WAAmB;IAC7D,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;IAC3B,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,aAAa,EAAE,UAAU,EAAE,GAAG,WAAW,KAAK,CAAC,CAAC;IACjF,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IAC/D,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IAEtB,MAAM,GAAG,GAAG,8BAA8B,CAAC,wBAAwB,WAAW,KAAK,EAAE,GAAG,CAAC,CAAC;IAC1F,MAAM,EAAE,GAAG,GAAG,CAAC,WAAsC,CAAC;IACtD,MAAM,WAAW,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAEvD,OAAO;QACL,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,EAAE,IAAI,sBAAsB,WAAW,EAAE,CAAC;QACxD,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC,KAAK,IAAI,oBAAoB,CAAC;QAC/C,WAAW;KACZ,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,WAAmB,EACnB,aAAqB;IAErB,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;IAC3B,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CACxB,IAAI,EACJ,aAAa,EACb,YAAY,EACZ,WAAW,EACX,GAAG,aAAa,KAAK,CACtB,CAAC;IACF,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;IAC/D,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAC;IAEtB,MAAM,GAAG,GAAG,8BAA8B,CACxC,0BAA0B,WAAW,IAAI,aAAa,KAAK,EAC3D,GAAG,CACJ,CAAC;IACF,MAAM,EAAE,GAAG,GAAG,CAAC,WAAsC,CAAC;IACtD,MAAM,WAAW,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAEvD,OAAO;QACL,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,EAAE,IAAI,wBAAwB,WAAW,IAAI,aAAa,EAAE,CAAC;QAC3E,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC,KAAK,IAAI,kBAAkB,CAAC;QAC7C,WAAW;KACZ,CAAC;AACJ,CAAC"}
@@ -0,0 +1,17 @@
1
+ import type { APIRoute } from "astro";
2
+ /**
3
+ * Build-time endpoint that generates the book EPUB and — when a reader
4
+ * password is configured — encrypts it with the same AES-256-GCM key used
5
+ * for page content.
6
+ *
7
+ * Wire format (encrypted):
8
+ * [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
9
+ *
10
+ * The client downloads this blob, slices the IV from the first 12 bytes, and
11
+ * decrypts the rest with the already-derived AES key stored in localStorage.
12
+ *
13
+ * Wire format (no password):
14
+ * Raw EPUB bytes (application/epub+zip).
15
+ */
16
+ export declare const GET: APIRoute;
17
+ //# sourceMappingURL=epub.enc.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"epub.enc.d.ts","sourceRoot":"","sources":["../../src/pages/epub.enc.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAUtC;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,GAAG,EAAE,QA6BjB,CAAC"}
@@ -0,0 +1,50 @@
1
+ import { exportEpub, pathExists } from "narrarium";
2
+ import path from "node:path";
3
+ import { readFile, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { randomBytes } from "node:crypto";
6
+ import { getBookRoot } from "../lib/book.js";
7
+ import { getReaderPassword } from "../lib/reader-mode.js";
8
+ import { encryptBufferRaw } from "../lib/content-crypto.js";
9
+ /**
10
+ * Build-time endpoint that generates the book EPUB and — when a reader
11
+ * password is configured — encrypts it with the same AES-256-GCM key used
12
+ * for page content.
13
+ *
14
+ * Wire format (encrypted):
15
+ * [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
16
+ *
17
+ * The client downloads this blob, slices the IV from the first 12 bytes, and
18
+ * decrypts the rest with the already-derived AES key stored in localStorage.
19
+ *
20
+ * Wire format (no password):
21
+ * Raw EPUB bytes (application/epub+zip).
22
+ */
23
+ export const GET = async () => {
24
+ const root = getBookRoot();
25
+ const ready = await pathExists(path.join(root, "book.md"));
26
+ if (!ready) {
27
+ return new Response("Book not found", { status: 404 });
28
+ }
29
+ const password = getReaderPassword();
30
+ const tempId = randomBytes(8).toString("hex");
31
+ const tempPath = path.join(tmpdir(), `narrarium-epub-${tempId}.epub`);
32
+ try {
33
+ await exportEpub(root, { outputPath: tempPath });
34
+ const epubBytes = await readFile(tempPath);
35
+ if (password) {
36
+ const { iv, ct } = encryptBufferRaw(epubBytes, password);
37
+ const combined = Buffer.concat([iv, ct]);
38
+ return new Response(combined, {
39
+ headers: { "Content-Type": "application/octet-stream" },
40
+ });
41
+ }
42
+ return new Response(epubBytes, {
43
+ headers: { "Content-Type": "application/octet-stream" },
44
+ });
45
+ }
46
+ finally {
47
+ await rm(tempPath, { force: true }).catch(() => { });
48
+ }
49
+ };
50
+ //# sourceMappingURL=epub.enc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"epub.enc.js","sourceRoot":"","sources":["../../src/pages/epub.enc.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAE5D;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,GAAG,GAAa,KAAK,IAAI,EAAE;IACtC,MAAM,IAAI,GAAG,WAAW,EAAE,CAAC;IAC3B,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,CAAC;IAC3D,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,IAAI,QAAQ,CAAC,gBAAgB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC;IACzD,CAAC;IAED,MAAM,QAAQ,GAAG,iBAAiB,EAAE,CAAC;IACrC,MAAM,MAAM,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC;IAC9C,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,EAAE,kBAAkB,MAAM,OAAO,CAAC,CAAC;IAEtE,IAAI,CAAC;QACH,MAAM,UAAU,CAAC,IAAI,EAAE,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC,CAAC;QACjD,MAAM,SAAS,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,CAAC;QAE3C,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,gBAAgB,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;YACzD,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;YACzC,OAAO,IAAI,QAAQ,CAAC,QAAQ,EAAE;gBAC5B,OAAO,EAAE,EAAE,cAAc,EAAE,0BAA0B,EAAE;aACxD,CAAC,CAAC;QACL,CAAC;QAED,OAAO,IAAI,QAAQ,CAAC,SAAS,EAAE;YAC7B,OAAO,EAAE,EAAE,cAAc,EAAE,0BAA0B,EAAE;SACxD,CAAC,CAAC;IACL,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,CAAC,QAAQ,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACtD,CAAC;AACH,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "narrarium-astro-reader",
3
- "version": "0.1.33",
3
+ "version": "0.1.37",
4
4
  "type": "module",
5
5
  "description": "Astro reader and scaffolding CLI for Narrarium book repositories.",
6
6
  "license": "MIT",
@@ -0,0 +1,28 @@
1
+ ---
2
+ interface Props {
3
+ dialogId: string;
4
+ title: string;
5
+ htmlContent: string;
6
+ }
7
+
8
+ const { dialogId, title, htmlContent } = Astro.props;
9
+ ---
10
+
11
+ <dialog class="eval-dialog" id={dialogId} aria-label={title}>
12
+ <div class="eval-dialog__inner">
13
+ <div class="eval-dialog__header">
14
+ <h2 class="eval-dialog__title">{title}</h2>
15
+ <button
16
+ type="button"
17
+ class="eval-dialog__close"
18
+ aria-label="Close evaluation"
19
+ data-eval-close={dialogId}
20
+ >
21
+
22
+ </button>
23
+ </div>
24
+ <div class="eval-dialog__body prose">
25
+ <Fragment set:html={htmlContent} />
26
+ </div>
27
+ </div>
28
+ </dialog>
@@ -185,7 +185,22 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
185
185
  // before we touch any page content.
186
186
  return aesDecrypt(key, canaryIv, canaryCt).then(function (plaintext) {
187
187
  if (plaintext !== "narrarium-ok") throw new Error("stale");
188
- return window._narrariumDecryptPage(key, true);
188
+ // Expose the key so the EPUB download handler can reuse it.
189
+ window._narrariumAesKey = key;
190
+ // Guard against a race where the auto-decrypt micro-task resolves
191
+ // before the HTML parser has emitted <main> and its encrypted spans.
192
+ // If the DOM is still being parsed, defer until DOMContentLoaded.
193
+ function waitForDom() {
194
+ if (document.readyState === "loading") {
195
+ return new Promise(function (resolve) {
196
+ document.addEventListener("DOMContentLoaded", resolve, { once: true });
197
+ });
198
+ }
199
+ return Promise.resolve();
200
+ }
201
+ return waitForDom().then(function () {
202
+ return window._narrariumDecryptPage(key, true);
203
+ });
189
204
  });
190
205
  })
191
206
  .then(function () {
@@ -246,6 +261,7 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
246
261
  <div class="masthead-actions">
247
262
  <a class="chip reader-status" data-continue-link href="./" hidden>Continue reading</a>
248
263
  <button type="button" class="masthead-btn" data-tts-panel-toggle aria-label="Open read aloud controls" aria-pressed="false" hidden>🔊</button>
264
+ <button type="button" class="masthead-btn" data-epub-download aria-label="Download EPUB" hidden>⬇</button>
249
265
  <button type="button" class="masthead-btn" data-search-toggle aria-label="Search">🔍</button>
250
266
  <button type="button" class="masthead-btn" data-reader-settings-toggle aria-label="Open reading settings">⚙</button>
251
267
  </div>
@@ -314,6 +330,9 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
314
330
  try { localStorage.setItem("narrarium-reader-aes-key", b64); } catch (_) {}
315
331
  });
316
332
 
333
+ // Expose the key so the EPUB download handler can reuse it.
334
+ window._narrariumAesKey = aesKey;
335
+
317
336
  // Decrypt the page — passes false = manual unlock, so the
318
337
  // content-unlocked event will fire for late-init scripts.
319
338
  return window._narrariumDecryptPage(aesKey, false);
@@ -327,5 +346,65 @@ const isChapterPage = Number.isFinite(currentChapterNumber);
327
346
  })();
328
347
  </script>
329
348
  )}
349
+ <script is:inline>
350
+ /* ── EPUB download ──────────────────────────────────────────────────────
351
+ The epub.enc static endpoint is always generated at build time.
352
+ • Encrypted build: client fetches, decrypts with the stored AES key,
353
+ creates an in-memory Blob and triggers a browser <a download>.
354
+ • Plain build: client fetches, wraps in a Blob, triggers download.
355
+ The button is hidden until _narrariumContentReady resolves.
356
+ ──────────────────────────────────────────────────────────────────────── */
357
+ (function () {
358
+ var btn = document.querySelector("[data-epub-download]");
359
+ if (!btn) return;
360
+
361
+ window._narrariumContentReady.then(function (state) {
362
+ if (state === "decrypted" || state === "plain") {
363
+ btn.removeAttribute("hidden");
364
+ }
365
+ });
366
+
367
+ btn.addEventListener("click", function () {
368
+ var isEncrypted = Boolean(document.body.dataset.cryptoSalt);
369
+ var epubUrl = new URL("epub.enc", document.baseURI).toString();
370
+
371
+ btn.setAttribute("disabled", "");
372
+
373
+ fetch(epubUrl)
374
+ .then(function (r) {
375
+ if (!r.ok) throw new Error("fetch failed: " + r.status);
376
+ return r.arrayBuffer();
377
+ })
378
+ .then(function (buf) {
379
+ if (isEncrypted) {
380
+ var aesKey = window._narrariumAesKey;
381
+ if (!aesKey) throw new Error("no key");
382
+ var bytes = new Uint8Array(buf);
383
+ var iv = bytes.slice(0, 12);
384
+ var ct = bytes.slice(12);
385
+ return crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, aesKey, ct);
386
+ }
387
+ return buf;
388
+ })
389
+ .then(function (plainBuf) {
390
+ var blob = new Blob([plainBuf], { type: "application/epub+zip" });
391
+ var url = URL.createObjectURL(blob);
392
+ var a = document.createElement("a");
393
+ a.href = url;
394
+ a.download = "book.epub";
395
+ document.body.appendChild(a);
396
+ a.click();
397
+ document.body.removeChild(a);
398
+ setTimeout(function () { URL.revokeObjectURL(url); }, 60000);
399
+ })
400
+ .catch(function (err) {
401
+ console.error("[narrarium] EPUB download failed", err);
402
+ })
403
+ .finally(function () {
404
+ btn.removeAttribute("disabled");
405
+ });
406
+ });
407
+ })();
408
+ </script>
330
409
  </body>
331
410
  </html>
@@ -10,15 +10,19 @@ import { createCipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
10
10
  * tables; the security comes from the password entropy.
11
11
  */
12
12
 
13
- let _buildSalt: Buffer | null = null;
13
+ // Use a process-global symbol so the singleton survives Vite module re-evaluation
14
+ // in dev mode (HMR). Without this, each Astro page request may get a fresh module
15
+ // instance with a new random salt, making the canary and content salts diverge.
16
+ const SALT_GLOBAL_KEY = Symbol.for("narrarium.buildSalt");
14
17
 
15
- /** Return the singleton salt for this build process, creating it on first call. */
18
+ /** Return the singleton salt for this build/dev process, creating it on first call. */
16
19
  export function getBuildSalt(): Buffer {
17
- if (!_buildSalt) {
18
- _buildSalt = randomBytes(16);
20
+ const g = globalThis as typeof globalThis & { [key: symbol]: Buffer | undefined };
21
+ if (!g[SALT_GLOBAL_KEY]) {
22
+ g[SALT_GLOBAL_KEY] = randomBytes(16);
19
23
  console.info("[narrarium-reader] Content encryption enabled (AES-256-GCM).");
20
24
  }
21
- return _buildSalt;
25
+ return g[SALT_GLOBAL_KEY]!;
22
26
  }
23
27
 
24
28
  /** Base64-encoded build salt, ready to embed in an HTML attribute. */
@@ -74,3 +78,22 @@ export function encryptString(plaintext: string, password: string): EncryptedChu
74
78
  export function encryptCanary(password: string): EncryptedChunk {
75
79
  return encryptString(CANARY_PLAINTEXT, password);
76
80
  }
81
+
82
+ /**
83
+ * Encrypt a raw Buffer with AES-256-GCM using the same PBKDF2-derived build
84
+ * key. Returns raw `iv` and `ct` Buffers for binary file endpoints.
85
+ *
86
+ * Wire format (concatenate before serving):
87
+ * [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
88
+ *
89
+ * Client side: `bytes.slice(0, 12)` = IV, `bytes.slice(12)` = ciphertext+tag.
90
+ */
91
+ export function encryptBufferRaw(data: Buffer, password: string): { iv: Buffer; ct: Buffer } {
92
+ const salt = getBuildSalt();
93
+ const key = deriveKey(password, salt);
94
+ const iv = randomBytes(12);
95
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
96
+ const body = Buffer.concat([cipher.update(data), cipher.final()]);
97
+ const tag = cipher.getAuthTag(); // always 16 bytes for AES-GCM
98
+ return { iv, ct: Buffer.concat([body, tag]) };
99
+ }
@@ -0,0 +1,66 @@
1
+ import path from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { marked } from "marked";
4
+ import { parseNarrariumMarkdownDocument } from "narrarium";
5
+ import { getBookRoot } from "./book.js";
6
+
7
+ export interface EvaluationData {
8
+ id: string;
9
+ title: string;
10
+ htmlContent: string;
11
+ }
12
+
13
+ /**
14
+ * Load the chapter-level evaluation from
15
+ * `evaluations/chapters/<chapterSlug>.md`, or return null if the file does
16
+ * not exist (evaluation has not been run yet).
17
+ */
18
+ export async function loadChapterEvaluation(chapterSlug: string): Promise<EvaluationData | null> {
19
+ const root = getBookRoot();
20
+ const filePath = path.join(root, "evaluations", "chapters", `${chapterSlug}.md`);
21
+ const raw = await readFile(filePath, "utf8").catch(() => null);
22
+ if (!raw) return null;
23
+
24
+ const doc = parseNarrariumMarkdownDocument(`evaluations/chapters/${chapterSlug}.md`, raw);
25
+ const fm = doc.frontmatter as Record<string, unknown>;
26
+ const htmlContent = await marked.parse(doc.body ?? "");
27
+
28
+ return {
29
+ id: String(fm.id ?? `evaluation:chapter:${chapterSlug}`),
30
+ title: String(fm.title ?? "Chapter Evaluation"),
31
+ htmlContent,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Load the paragraph-level evaluation from
37
+ * `evaluations/paragraphs/<chapterSlug>/<paragraphSlug>.md`, or return null.
38
+ */
39
+ export async function loadParagraphEvaluation(
40
+ chapterSlug: string,
41
+ paragraphSlug: string,
42
+ ): Promise<EvaluationData | null> {
43
+ const root = getBookRoot();
44
+ const filePath = path.join(
45
+ root,
46
+ "evaluations",
47
+ "paragraphs",
48
+ chapterSlug,
49
+ `${paragraphSlug}.md`,
50
+ );
51
+ const raw = await readFile(filePath, "utf8").catch(() => null);
52
+ if (!raw) return null;
53
+
54
+ const doc = parseNarrariumMarkdownDocument(
55
+ `evaluations/paragraphs/${chapterSlug}/${paragraphSlug}.md`,
56
+ raw,
57
+ );
58
+ const fm = doc.frontmatter as Record<string, unknown>;
59
+ const htmlContent = await marked.parse(doc.body ?? "");
60
+
61
+ return {
62
+ id: String(fm.id ?? `evaluation:paragraph:${chapterSlug}:${paragraphSlug}`),
63
+ title: String(fm.title ?? "Scene Evaluation"),
64
+ htmlContent,
65
+ };
66
+ }
@@ -5,6 +5,7 @@ import { listChapters, pathExists } from "narrarium";
5
5
  import AssetFigure from "../../components/AssetFigure.astro";
6
6
  import ChapterPager from "../../components/ChapterPager.astro";
7
7
  import EncryptedHtml from "../../components/EncryptedHtml.astro";
8
+ import EvaluationModal from "../../components/EvaluationModal.astro";
8
9
  import LinkedValue from "../../components/LinkedValue.astro";
9
10
  import MetadataSection from "../../components/MetadataSection.astro";
10
11
  import RelatedLinks from "../../components/RelatedLinks.astro";
@@ -12,6 +13,8 @@ import BaseLayout from "../../layouts/BaseLayout.astro";
12
13
  import { loadAssetFigure } from "../../lib/assets";
13
14
  import { loadRelatedCanonLinks, loadStoryMentionLinks } from "../../lib/canon";
14
15
  import { getBookRoot, loadChapterPageData } from "../../lib/book";
16
+ import { loadChapterEvaluation, loadParagraphEvaluation } from "../../lib/evaluations";
17
+ import { isFullCanonMode } from "../../lib/reader-mode";
15
18
 
16
19
  export async function getStaticPaths() {
17
20
  const root = getBookRoot();
@@ -41,13 +44,23 @@ const chapterId = String(chapter.metadata.id);
41
44
  const chapterRelatedLinks = await loadRelatedCanonLinks(chapterId, [chapter.metadata.pov, chapter.metadata.timeline_ref]);
42
45
  const chapterStoryLinks = await loadStoryMentionLinks(chapterId, chapter.metadata.number);
43
46
  const chapterFigure = await loadAssetFigure(String(chapter.metadata.id), chapter.metadata.title);
47
+
48
+ // Full-canon mode: load evaluations for author view.
49
+ const fullCanonMode = isFullCanonMode();
50
+ const chapterEvaluation = fullCanonMode ? await loadChapterEvaluation(chapterSlug) : null;
51
+
44
52
  const renderedParagraphs = await Promise.all(
45
- chapter.paragraphs.map(async (paragraph) => ({
46
- ...paragraph,
47
- anchorId: `scene-${path.basename(paragraph.path, ".md")}`,
48
- figure: await loadAssetFigure(String(paragraph.metadata.id), paragraph.metadata.title),
49
- html: await marked.parse(paragraph.body),
50
- })),
53
+ chapter.paragraphs.map(async (paragraph) => {
54
+ const paragraphSlug = path.basename(paragraph.path, ".md");
55
+ return {
56
+ ...paragraph,
57
+ paragraphSlug,
58
+ anchorId: `scene-${paragraphSlug}`,
59
+ figure: await loadAssetFigure(String(paragraph.metadata.id), paragraph.metadata.title),
60
+ html: await marked.parse(paragraph.body),
61
+ evaluation: fullCanonMode ? await loadParagraphEvaluation(chapterSlug, paragraphSlug) : null,
62
+ };
63
+ }),
51
64
  );
52
65
  ---
53
66
 
@@ -66,6 +79,11 @@ const renderedParagraphs = await Promise.all(
66
79
  <button type="button" class="chip is-action" data-bookmark-toggle>Save bookmark</button>
67
80
  <button type="button" class="chip is-action" data-tts-trigger="chapter" hidden>Read aloud</button>
68
81
  <button type="button" class="chip is-action" data-reader-focus-toggle>Full read</button>
82
+ {chapterEvaluation && (
83
+ <button type="button" class="chip is-action eval-trigger" data-eval-open={`eval-chapter-${chapterSlug}`}>
84
+ Evaluation
85
+ </button>
86
+ )}
69
87
  </div>
70
88
  </section>
71
89
 
@@ -148,6 +166,11 @@ const renderedParagraphs = await Promise.all(
148
166
  Mark voice start
149
167
  </button>
150
168
  <button type="button" class="chip is-action" data-tts-trigger={paragraph.anchorId} hidden>Read aloud</button>
169
+ {paragraph.evaluation && (
170
+ <button type="button" class="chip is-action eval-trigger" data-eval-open={`eval-paragraph-${paragraph.paragraphSlug}`}>
171
+ Evaluation
172
+ </button>
173
+ )}
151
174
  </div>
152
175
  </div>
153
176
 
@@ -221,6 +244,24 @@ const renderedParagraphs = await Promise.all(
221
244
  Double-tap or double-click to show controls, or press a key.
222
245
  </div>
223
246
 
247
+ {/* Evaluation modals — full-canon mode only */}
248
+ {chapterEvaluation && (
249
+ <EvaluationModal
250
+ dialogId={`eval-chapter-${chapterSlug}`}
251
+ title={chapterEvaluation.title}
252
+ htmlContent={chapterEvaluation.htmlContent}
253
+ />
254
+ )}
255
+ {renderedParagraphs.map((paragraph) =>
256
+ paragraph.evaluation ? (
257
+ <EvaluationModal
258
+ dialogId={`eval-paragraph-${paragraph.paragraphSlug}`}
259
+ title={paragraph.evaluation.title}
260
+ htmlContent={paragraph.evaluation.htmlContent}
261
+ />
262
+ ) : null
263
+ )}
264
+
224
265
  <script is:inline>
225
266
  (() => {
226
267
  const focusModeKey = "narrarium-reader-focus-mode";
@@ -548,6 +589,28 @@ const renderedParagraphs = await Promise.all(
548
589
  function isEditable(node) {
549
590
  return node instanceof HTMLElement && (node.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(node.tagName));
550
591
  }
592
+
593
+ // ── Evaluation modal wiring ─────────────────────────────────────────
594
+ document.querySelectorAll("[data-eval-open]").forEach(function (btn) {
595
+ btn.addEventListener("click", function () {
596
+ var dialogId = btn.getAttribute("data-eval-open");
597
+ var dialog = dialogId ? document.getElementById(dialogId) : null;
598
+ if (dialog instanceof HTMLDialogElement) dialog.showModal();
599
+ });
600
+ });
601
+ document.querySelectorAll("[data-eval-close]").forEach(function (btn) {
602
+ btn.addEventListener("click", function () {
603
+ var dialogId = btn.getAttribute("data-eval-close");
604
+ var dialog = dialogId ? document.getElementById(dialogId) : null;
605
+ if (dialog instanceof HTMLDialogElement) dialog.close();
606
+ });
607
+ });
608
+ // Close on backdrop click (click lands directly on <dialog>, not its children).
609
+ document.querySelectorAll(".eval-dialog").forEach(function (dialog) {
610
+ dialog.addEventListener("click", function (event) {
611
+ if (event.target === dialog) /** @type {HTMLDialogElement} */ (dialog).close();
612
+ });
613
+ });
551
614
  })();
552
615
  </script>
553
616
  </BaseLayout>
@@ -0,0 +1,54 @@
1
+ import type { APIRoute } from "astro";
2
+ import { exportEpub, pathExists } from "narrarium";
3
+ import path from "node:path";
4
+ import { readFile, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { randomBytes } from "node:crypto";
7
+ import { getBookRoot } from "../lib/book.js";
8
+ import { getReaderPassword } from "../lib/reader-mode.js";
9
+ import { encryptBufferRaw } from "../lib/content-crypto.js";
10
+
11
+ /**
12
+ * Build-time endpoint that generates the book EPUB and — when a reader
13
+ * password is configured — encrypts it with the same AES-256-GCM key used
14
+ * for page content.
15
+ *
16
+ * Wire format (encrypted):
17
+ * [12-byte IV][ciphertext ∥ 16-byte GCM auth tag]
18
+ *
19
+ * The client downloads this blob, slices the IV from the first 12 bytes, and
20
+ * decrypts the rest with the already-derived AES key stored in localStorage.
21
+ *
22
+ * Wire format (no password):
23
+ * Raw EPUB bytes (application/epub+zip).
24
+ */
25
+ export const GET: APIRoute = async () => {
26
+ const root = getBookRoot();
27
+ const ready = await pathExists(path.join(root, "book.md"));
28
+ if (!ready) {
29
+ return new Response("Book not found", { status: 404 });
30
+ }
31
+
32
+ const password = getReaderPassword();
33
+ const tempId = randomBytes(8).toString("hex");
34
+ const tempPath = path.join(tmpdir(), `narrarium-epub-${tempId}.epub`);
35
+
36
+ try {
37
+ await exportEpub(root, { outputPath: tempPath });
38
+ const epubBytes = await readFile(tempPath);
39
+
40
+ if (password) {
41
+ const { iv, ct } = encryptBufferRaw(epubBytes, password);
42
+ const combined = Buffer.concat([iv, ct]);
43
+ return new Response(combined, {
44
+ headers: { "Content-Type": "application/octet-stream" },
45
+ });
46
+ }
47
+
48
+ return new Response(epubBytes, {
49
+ headers: { "Content-Type": "application/octet-stream" },
50
+ });
51
+ } finally {
52
+ await rm(tempPath, { force: true }).catch(() => {});
53
+ }
54
+ };
@@ -934,7 +934,7 @@ h3 {
934
934
  display: grid;
935
935
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
936
936
  gap: 1rem;
937
- align-items: center;
937
+ align-items: stretch;
938
938
  border: 1px solid var(--line);
939
939
  background: var(--surface);
940
940
  padding: 0.9rem 1.25rem;
@@ -943,7 +943,7 @@ h3 {
943
943
  }
944
944
 
945
945
  .pager-link {
946
- display: inline-flex;
946
+ display: flex;
947
947
  align-items: center;
948
948
  gap: 0.4rem;
949
949
  padding: 0.5rem 0.75rem;
@@ -974,6 +974,7 @@ h3 {
974
974
  .pager-jump {
975
975
  display: grid;
976
976
  gap: 0.3rem;
977
+ align-content: center;
977
978
  min-width: 0;
978
979
  }
979
980
 
@@ -1588,3 +1589,74 @@ body.reader-auth-locked #reader-password-gate {
1588
1589
  html[data-theme="dark"] .reader-password-gate__error {
1589
1590
  color: #e74c3c;
1590
1591
  }
1592
+
1593
+ /* ─── Evaluation modal ──────────────────────────────────────── */
1594
+ .eval-dialog {
1595
+ position: fixed;
1596
+ inset: 0;
1597
+ z-index: 300;
1598
+ width: min(720px, calc(100% - 2rem));
1599
+ max-height: calc(100dvh - 4rem);
1600
+ margin: auto;
1601
+ padding: 0;
1602
+ border: 1px solid var(--line);
1603
+ border-radius: 12px;
1604
+ background: var(--surface);
1605
+ color: var(--text);
1606
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.22);
1607
+ overflow: hidden;
1608
+ }
1609
+
1610
+ .eval-dialog::backdrop {
1611
+ background: rgba(0, 0, 0, 0.5);
1612
+ backdrop-filter: blur(3px);
1613
+ }
1614
+
1615
+ .eval-dialog__inner {
1616
+ display: flex;
1617
+ flex-direction: column;
1618
+ max-height: calc(100dvh - 4rem);
1619
+ }
1620
+
1621
+ .eval-dialog__header {
1622
+ display: flex;
1623
+ align-items: center;
1624
+ justify-content: space-between;
1625
+ gap: 1rem;
1626
+ padding: 1rem 1.5rem;
1627
+ border-bottom: 1px solid var(--line);
1628
+ flex-shrink: 0;
1629
+ }
1630
+
1631
+ .eval-dialog__title {
1632
+ margin: 0;
1633
+ font-size: 1rem;
1634
+ font-weight: 600;
1635
+ }
1636
+
1637
+ .eval-dialog__close {
1638
+ flex-shrink: 0;
1639
+ display: flex;
1640
+ align-items: center;
1641
+ justify-content: center;
1642
+ width: 2rem;
1643
+ height: 2rem;
1644
+ border-radius: 50%;
1645
+ border: 1px solid var(--line);
1646
+ background: transparent;
1647
+ color: var(--text-muted);
1648
+ cursor: pointer;
1649
+ font-size: 0.875rem;
1650
+ transition: background 120ms ease, color 120ms ease;
1651
+ }
1652
+
1653
+ .eval-dialog__close:hover {
1654
+ background: var(--surface-muted);
1655
+ color: var(--text);
1656
+ }
1657
+
1658
+ .eval-dialog__body {
1659
+ overflow-y: auto;
1660
+ padding: 1.5rem;
1661
+ flex: 1 1 auto;
1662
+ }