@openparachute/vault 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Owner authentication for the OAuth consent page.
3
+ *
4
+ * The "owner" is the person who set up this vault — identified by a password
5
+ * stored globally in config.yaml (owner_password_hash). The password is used
6
+ * to prove ownership when authorizing third-party OAuth clients.
7
+ *
8
+ * Password hashing uses Bun.password (bcrypt, cost 12 by default) — no deps.
9
+ *
10
+ * Rate limiting is per-IP, in-memory. Acceptable for v1: resets on restart,
11
+ * doesn't handle multi-process deployments. Tighten later if needed.
12
+ */
13
+
14
+ import { readGlobalConfig, writeGlobalConfig } from "./config.ts";
15
+
16
+ const BCRYPT_COST = 12;
17
+ const MIN_PASSWORD_LENGTH = 12;
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Password storage
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Read the stored bcrypt hash, or null if none set (or set to empty string). */
24
+ export function getOwnerPasswordHash(): string | null {
25
+ const hash = readGlobalConfig().owner_password_hash;
26
+ if (typeof hash !== "string" || hash.length === 0) return null;
27
+ return hash;
28
+ }
29
+
30
+ /** Whether a password has been set. */
31
+ export function hasOwnerPassword(): boolean {
32
+ return getOwnerPasswordHash() !== null;
33
+ }
34
+
35
+ /** Validate password strength. Returns error message or null. */
36
+ export function validatePasswordStrength(password: string): string | null {
37
+ if (password.length < MIN_PASSWORD_LENGTH) {
38
+ return `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** Hash and store the owner password. Throws on weak passwords. */
44
+ export async function setOwnerPassword(password: string): Promise<void> {
45
+ const err = validatePasswordStrength(password);
46
+ if (err) throw new Error(err);
47
+
48
+ const hash = await Bun.password.hash(password, {
49
+ algorithm: "bcrypt",
50
+ cost: BCRYPT_COST,
51
+ });
52
+
53
+ const config = readGlobalConfig();
54
+ config.owner_password_hash = hash;
55
+ writeGlobalConfig(config);
56
+ }
57
+
58
+ /** Remove the stored password (disables password-based consent auth). */
59
+ export function clearOwnerPassword(): void {
60
+ const config = readGlobalConfig();
61
+ delete config.owner_password_hash;
62
+ writeGlobalConfig(config);
63
+ }
64
+
65
+ /** Verify a provided password against the given hash. */
66
+ export async function verifyOwnerPassword(password: string, hash: string): Promise<boolean> {
67
+ try {
68
+ return await Bun.password.verify(password, hash);
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Rate limiting
76
+ // ---------------------------------------------------------------------------
77
+
78
+ interface RateLimitEntry {
79
+ failures: number;
80
+ firstFailureAt: number;
81
+ lockedUntil: number | null;
82
+ }
83
+
84
+ /**
85
+ * Per-IP rate limiter for consent-page attempts.
86
+ *
87
+ * Policy:
88
+ * - Up to MAX_FAILURES failed attempts within WINDOW_MS → lockout
89
+ * - Lockout lasts LOCKOUT_MS
90
+ * - A successful attempt clears the IP's counter
91
+ */
92
+ export class RateLimiter {
93
+ private entries = new Map<string, RateLimitEntry>();
94
+
95
+ constructor(
96
+ private readonly maxFailures = 10,
97
+ private readonly windowMs = 60_000,
98
+ private readonly lockoutMs = 15 * 60_000,
99
+ ) {}
100
+
101
+ /**
102
+ * Check whether an IP is currently allowed to attempt auth.
103
+ * Returns `{ allowed: false, retryAfterSec }` if locked out.
104
+ */
105
+ check(ip: string): { allowed: true } | { allowed: false; retryAfterSec: number } {
106
+ const entry = this.entries.get(ip);
107
+ if (!entry) return { allowed: true };
108
+
109
+ const now = Date.now();
110
+ if (entry.lockedUntil && entry.lockedUntil > now) {
111
+ return { allowed: false, retryAfterSec: Math.ceil((entry.lockedUntil - now) / 1000) };
112
+ }
113
+
114
+ // Expired lockout or old window — reset and allow
115
+ if (entry.lockedUntil && entry.lockedUntil <= now) {
116
+ this.entries.delete(ip);
117
+ return { allowed: true };
118
+ }
119
+ if (now - entry.firstFailureAt > this.windowMs) {
120
+ this.entries.delete(ip);
121
+ return { allowed: true };
122
+ }
123
+
124
+ return { allowed: true };
125
+ }
126
+
127
+ /** Record a failed attempt. Triggers lockout if threshold reached. */
128
+ recordFailure(ip: string): void {
129
+ const now = Date.now();
130
+ const entry = this.entries.get(ip);
131
+
132
+ if (!entry || now - entry.firstFailureAt > this.windowMs) {
133
+ this.entries.set(ip, {
134
+ failures: 1,
135
+ firstFailureAt: now,
136
+ lockedUntil: null,
137
+ });
138
+ return;
139
+ }
140
+
141
+ entry.failures += 1;
142
+ if (entry.failures >= this.maxFailures) {
143
+ entry.lockedUntil = now + this.lockoutMs;
144
+ }
145
+ }
146
+
147
+ /** Record a successful attempt. Clears the IP's counter. */
148
+ recordSuccess(ip: string): void {
149
+ this.entries.delete(ip);
150
+ }
151
+
152
+ /** For tests: drop all state. */
153
+ reset(): void {
154
+ this.entries.clear();
155
+ }
156
+ }
157
+
158
+ /** Singleton rate limiter for the OAuth consent endpoint. */
159
+ export const authorizeRateLimit = new RateLimiter();
package/src/prompt.ts ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Simple interactive prompts for CLI setup.
3
+ *
4
+ * Uses `node:readline/promises` for input so the interface is explicitly
5
+ * closed after each prompt. The older `for await (const line of console)`
6
+ * pattern held stdin open across prompts and in Bun sometimes caused
7
+ * subsequent `console.log` writes to appear swallowed.
8
+ */
9
+ import { createInterface } from "node:readline/promises";
10
+
11
+ async function readLine(prompt: string): Promise<string> {
12
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13
+ try {
14
+ return await rl.question(prompt);
15
+ } finally {
16
+ rl.close();
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Ask a yes/no question. Returns true for yes.
22
+ */
23
+ export async function confirm(question: string, defaultYes = true): Promise<boolean> {
24
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
25
+ // Loop in case the user types something non-yes/no.
26
+ while (true) {
27
+ const answer = (await readLine(`${question} ${hint} `)).trim().toLowerCase();
28
+ if (answer === "") return defaultYes;
29
+ if (answer === "y" || answer === "yes") return true;
30
+ if (answer === "n" || answer === "no") return false;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Ask for a text input. Returns the trimmed answer, or defaultValue if empty.
36
+ */
37
+ export async function ask(question: string, defaultValue = ""): Promise<string> {
38
+ const hint = defaultValue ? ` (${defaultValue})` : "";
39
+ const answer = (await readLine(`${question}${hint}: `)).trim();
40
+ return answer || defaultValue;
41
+ }
42
+
43
+ /**
44
+ * Ask for a password with masked input (shows "*" per character).
45
+ * Falls back to plain echo if stdin isn't a TTY (e.g. piped input in CI).
46
+ */
47
+ export async function askPassword(question: string): Promise<string> {
48
+ const stdin = process.stdin;
49
+ if (!stdin.isTTY || typeof stdin.setRawMode !== "function") {
50
+ return ask(question);
51
+ }
52
+
53
+ process.stdout.write(`${question}: `);
54
+
55
+ return new Promise<string>((resolve, reject) => {
56
+ stdin.setRawMode(true);
57
+ stdin.resume();
58
+ stdin.setEncoding("utf8");
59
+
60
+ let buf = "";
61
+ let settled = false;
62
+
63
+ // Always restore terminal state on exit, success or failure.
64
+ const cleanup = () => {
65
+ if (settled) return;
66
+ settled = true;
67
+ try {
68
+ stdin.removeListener("data", onData);
69
+ stdin.removeListener("error", onError);
70
+ stdin.setRawMode(false);
71
+ stdin.pause();
72
+ } catch {
73
+ // Best-effort; don't mask the underlying completion.
74
+ }
75
+ };
76
+
77
+ const onData = (data: string) => {
78
+ try {
79
+ for (const ch of data) {
80
+ // Enter — done
81
+ if (ch === "\r" || ch === "\n") {
82
+ process.stdout.write("\n");
83
+ cleanup();
84
+ resolve(buf);
85
+ return;
86
+ }
87
+ // Ctrl-C — abort
88
+ if (ch === "\u0003") {
89
+ process.stdout.write("\n");
90
+ cleanup();
91
+ process.exit(130);
92
+ }
93
+ // Backspace / DEL
94
+ if (ch === "\u0008" || ch === "\u007f") {
95
+ if (buf.length > 0) {
96
+ buf = buf.slice(0, -1);
97
+ process.stdout.write("\b \b");
98
+ }
99
+ continue;
100
+ }
101
+ // Printable
102
+ if (ch >= " ") {
103
+ buf += ch;
104
+ process.stdout.write("*");
105
+ }
106
+ }
107
+ } catch (err) {
108
+ cleanup();
109
+ reject(err);
110
+ }
111
+ };
112
+ const onError = (err: Error) => {
113
+ cleanup();
114
+ reject(err);
115
+ };
116
+
117
+ stdin.on("data", onData);
118
+ stdin.on("error", onError);
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Ask user to pick from options. Returns the chosen value.
124
+ */
125
+ export async function choose(question: string, options: { label: string; value: string; description?: string }[]): Promise<string> {
126
+ console.log(question);
127
+ for (let i = 0; i < options.length; i++) {
128
+ const desc = options[i].description ? ` — ${options[i].description}` : "";
129
+ console.log(` ${i + 1}) ${options[i].label}${desc}`);
130
+ }
131
+ process.stdout.write(` Choice [1]: `);
132
+
133
+ for await (const line of console) {
134
+ const answer = line.trim();
135
+ if (answer === "") return options[0].value;
136
+ const idx = parseInt(answer, 10) - 1;
137
+ if (idx >= 0 && idx < options.length) return options[idx].value;
138
+ process.stdout.write(` Please enter 1-${options.length}: `);
139
+ }
140
+ return options[0].value;
141
+ }
@@ -0,0 +1,214 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { handleViewNote } from "./routes.ts";
3
+
4
+ // Redirect URL builder — mirrors the logic in server.ts
5
+ function buildRedirectUrl(reqUrl: string, noteId: string, prefix = ""): string {
6
+ const dest = new URL(`${prefix}/view/${noteId}`, reqUrl);
7
+ dest.search = new URL(reqUrl).search;
8
+ return dest.toString();
9
+ }
10
+
11
+ describe("/public → /view redirect", () => {
12
+ it("preserves query params including ?key=", () => {
13
+ const url = buildRedirectUrl("http://localhost:1940/public/abc?key=pvk_secret", "abc");
14
+ expect(url).toBe("http://localhost:1940/view/abc?key=pvk_secret");
15
+ });
16
+
17
+ it("works without query params", () => {
18
+ const url = buildRedirectUrl("http://localhost:1940/public/abc", "abc");
19
+ expect(url).toBe("http://localhost:1940/view/abc");
20
+ });
21
+
22
+ it("preserves multiple query params", () => {
23
+ const url = buildRedirectUrl("http://localhost:1940/public/abc?key=pvk_x&format=html", "abc");
24
+ expect(url).toBe("http://localhost:1940/view/abc?key=pvk_x&format=html");
25
+ });
26
+
27
+ it("works for vault-scoped paths", () => {
28
+ const url = buildRedirectUrl("http://localhost:1940/vaults/work/public/abc?key=pvk_x", "abc", "/vaults/work");
29
+ expect(url).toBe("http://localhost:1940/vaults/work/view/abc?key=pvk_x");
30
+ });
31
+ });
32
+
33
+ // Minimal Store stub — only getNote is needed
34
+ function makeStore(notes: Record<string, { content: string; tags?: string[]; metadata?: Record<string, unknown>; path?: string }>) {
35
+ return {
36
+ getNote(id: string) {
37
+ const n = notes[id];
38
+ if (!n) return null;
39
+ return {
40
+ id,
41
+ content: n.content,
42
+ tags: n.tags ?? [],
43
+ metadata: n.metadata ?? {},
44
+ path: n.path,
45
+ createdAt: "2025-01-01T00:00:00Z",
46
+ updatedAt: "2025-01-01T00:00:00Z",
47
+ };
48
+ },
49
+ getNoteByPath(path: string) {
50
+ for (const [id, n] of Object.entries(notes)) {
51
+ if (n.path?.toLowerCase() === path.toLowerCase()) {
52
+ return this.getNote(id);
53
+ }
54
+ }
55
+ return null;
56
+ },
57
+ } as any;
58
+ }
59
+
60
+ describe("handleViewNote", () => {
61
+ it("returns 404 for non-existent note", () => {
62
+ const store = makeStore({});
63
+ const resp = handleViewNote(store, "missing");
64
+ expect(resp.status).toBe(404);
65
+ });
66
+
67
+ it("returns 404 for note without publish tag (unauthenticated)", () => {
68
+ const store = makeStore({
69
+ "n1": { content: "hello", tags: ["other"] },
70
+ });
71
+ const resp = handleViewNote(store, "n1");
72
+ expect(resp.status).toBe(404);
73
+ });
74
+
75
+ it("serves note with publish tag as HTML", async () => {
76
+ const store = makeStore({
77
+ "n1": { content: "# Hello\n\nWorld", tags: ["publish"], path: "Blog/My Post.md" },
78
+ });
79
+ const resp = handleViewNote(store, "n1");
80
+ expect(resp.status).toBe(200);
81
+ expect(resp.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
82
+
83
+ const html = await resp.text();
84
+ expect(html).toContain("<title>My Post</title>");
85
+ expect(html).toContain("<h1>Hello</h1>");
86
+ expect(html).toContain("<p>World</p>");
87
+ });
88
+
89
+ it("serves note with metadata.published=true", async () => {
90
+ const store = makeStore({
91
+ "n2": { content: "Content here", metadata: { published: true } },
92
+ });
93
+ const resp = handleViewNote(store, "n2");
94
+ expect(resp.status).toBe(200);
95
+ const html = await resp.text();
96
+ expect(html).toContain("<p>Content here</p>");
97
+ });
98
+
99
+ it("renders markdown features correctly", async () => {
100
+ const store = makeStore({
101
+ "n3": {
102
+ content: "**bold** and *italic* and `code`\n\n- item 1\n- item 2\n\n```\ncode block\n```\n\n[link](https://example.com)",
103
+ tags: ["publish"],
104
+ },
105
+ });
106
+ const resp = handleViewNote(store, "n3");
107
+ const html = await resp.text();
108
+ expect(html).toContain("<strong>bold</strong>");
109
+ expect(html).toContain("<em>italic</em>");
110
+ expect(html).toContain("<li>item 1</li>");
111
+ expect(html).toContain("<li>item 2</li>");
112
+ expect(html).toContain("<pre><code>");
113
+ expect(html).toContain('href="https://example.com"');
114
+ });
115
+
116
+ it("escapes HTML in note content", async () => {
117
+ const store = makeStore({
118
+ "n4": { content: "<script>alert('xss')</script>", tags: ["publish"] },
119
+ });
120
+ const resp = handleViewNote(store, "n4");
121
+ const html = await resp.text();
122
+ expect(html).not.toContain("<script>");
123
+ expect(html).toContain("&lt;script&gt;");
124
+ });
125
+
126
+ it("strips javascript: URI links to prevent XSS", async () => {
127
+ const store = makeStore({
128
+ "n6": { content: "[click me](javascript:alert(1))", tags: ["publish"] },
129
+ });
130
+ const resp = handleViewNote(store, "n6");
131
+ const html = await resp.text();
132
+ expect(html).not.toContain("javascript:");
133
+ expect(html).toContain("click me");
134
+ expect(html).not.toContain("<a ");
135
+ });
136
+
137
+ it("strips data: URI links to prevent XSS", async () => {
138
+ const store = makeStore({
139
+ "n7": { content: "[click](data:text/html;base64,PHNjcmlwdD4=)", tags: ["publish"] },
140
+ });
141
+ const resp = handleViewNote(store, "n7");
142
+ const html = await resp.text();
143
+ expect(html).not.toContain("data:");
144
+ expect(html).toContain("click");
145
+ });
146
+
147
+ it("allows safe http/https/mailto links", async () => {
148
+ const store = makeStore({
149
+ "n8": { content: "[site](https://example.com) and [mail](mailto:a@b.com)", tags: ["publish"] },
150
+ });
151
+ const resp = handleViewNote(store, "n8");
152
+ const html = await resp.text();
153
+ expect(html).toContain('href="https://example.com"');
154
+ expect(html).toContain('href="mailto:a@b.com"');
155
+ });
156
+
157
+ it("supports dark mode via media query", async () => {
158
+ const store = makeStore({
159
+ "n5": { content: "test", tags: ["publish"] },
160
+ });
161
+ const resp = handleViewNote(store, "n5");
162
+ const html = await resp.text();
163
+ expect(html).toContain("prefers-color-scheme: dark");
164
+ });
165
+
166
+ // CSP header
167
+ it("includes Content-Security-Policy header", () => {
168
+ const store = makeStore({
169
+ "n1": { content: "test", tags: ["publish"] },
170
+ });
171
+ const resp = handleViewNote(store, "n1");
172
+ const csp = resp.headers.get("Content-Security-Policy");
173
+ expect(csp).toContain("script-src 'none'");
174
+ expect(csp).toContain("default-src 'self'");
175
+ });
176
+
177
+ // Auth-aware behavior
178
+ it("serves any note when authenticated", async () => {
179
+ const store = makeStore({
180
+ "private": { content: "secret stuff", tags: ["internal"] },
181
+ });
182
+ const resp = handleViewNote(store, "private", { authenticated: true });
183
+ expect(resp.status).toBe(200);
184
+ const html = await resp.text();
185
+ expect(html).toContain("<p>secret stuff</p>");
186
+ });
187
+
188
+ it("returns 404 for unpublished note when not authenticated", () => {
189
+ const store = makeStore({
190
+ "private": { content: "secret stuff", tags: ["internal"] },
191
+ });
192
+ const resp = handleViewNote(store, "private");
193
+ expect(resp.status).toBe(404);
194
+ });
195
+
196
+ // Custom published tag
197
+ it("uses custom published_tag from config", async () => {
198
+ const store = makeStore({
199
+ "n1": { content: "public content", tags: ["public"] },
200
+ });
201
+ const resp = handleViewNote(store, "n1", { publishedTag: "public" });
202
+ expect(resp.status).toBe(200);
203
+ const html = await resp.text();
204
+ expect(html).toContain("<p>public content</p>");
205
+ });
206
+
207
+ it("rejects note with default tag when custom tag is configured", () => {
208
+ const store = makeStore({
209
+ "n1": { content: "content", tags: ["publish"] },
210
+ });
211
+ const resp = handleViewNote(store, "n1", { publishedTag: "public" });
212
+ expect(resp.status).toBe(404);
213
+ });
214
+ });
@@ -0,0 +1,9 @@
1
+ declare module "qrcode-terminal" {
2
+ export function generate(
3
+ text: string,
4
+ opts: { small?: boolean },
5
+ cb: (qr: string) => void,
6
+ ): void;
7
+ const _default: { generate: typeof generate };
8
+ export default _default;
9
+ }