@jant/core 0.3.42 → 0.3.44

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 (99) hide show
  1. package/bin/commands/import-site.js +1 -1
  2. package/bin/commands/search-reindex.js +175 -0
  3. package/bin/lib/hugo-markdown.js +102 -0
  4. package/bin/lib/site-pull-media.js +1 -4
  5. package/dist/app-BI9bnCkO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
  7. package/dist/client/.vite/manifest.json +2 -2
  8. package/dist/client/_assets/client-BQH7AQ24.css +2 -0
  9. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  10. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  11. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  12. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  13. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +5 -5
  16. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/helpers/app.ts +15 -4
  19. package/src/app.tsx +8 -0
  20. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  21. package/src/client/tiptap/extensions.ts +3 -0
  22. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  23. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  24. package/src/db/migrations/0019_bored_magus.sql +2 -0
  25. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  26. package/src/db/migrations/meta/0019_snapshot.json +2238 -0
  27. package/src/db/migrations/meta/_journal.json +15 -1
  28. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  29. package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
  30. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  31. package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
  32. package/src/db/migrations/pg/meta/_journal.json +15 -1
  33. package/src/db/pg/schema.ts +22 -0
  34. package/src/db/schema.ts +27 -0
  35. package/src/index.ts +1 -2
  36. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  37. package/src/lib/__tests__/navigation.test.ts +4 -20
  38. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  39. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  40. package/src/lib/__tests__/summary.test.ts +140 -0
  41. package/src/lib/__tests__/view.test.ts +66 -0
  42. package/src/lib/feed.ts +70 -34
  43. package/src/lib/hosted-signin.ts +9 -3
  44. package/src/lib/icons.ts +37 -0
  45. package/src/lib/navigation.ts +11 -12
  46. package/src/lib/post-meta.ts +20 -2
  47. package/src/lib/rate-limit-d1.ts +99 -0
  48. package/src/lib/rate-limit-memory.ts +105 -0
  49. package/src/lib/rate-limit.ts +63 -0
  50. package/src/lib/render.tsx +9 -0
  51. package/src/lib/resolve-config.ts +9 -0
  52. package/src/lib/summary.ts +42 -7
  53. package/src/lib/url.ts +34 -0
  54. package/src/lib/view.ts +42 -8
  55. package/src/middleware/__tests__/auth.test.ts +44 -4
  56. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  57. package/src/middleware/__tests__/session.test.ts +85 -0
  58. package/src/middleware/auth.ts +62 -25
  59. package/src/middleware/rate-limit.ts +54 -0
  60. package/src/middleware/session.ts +36 -0
  61. package/src/routes/__tests__/compose.test.ts +1 -1
  62. package/src/routes/api/__tests__/search.test.ts +48 -0
  63. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  64. package/src/routes/api/internal/search-reindex.ts +40 -0
  65. package/src/routes/api/internal/sites.ts +1 -0
  66. package/src/routes/api/search.ts +13 -0
  67. package/src/routes/auth/dev.ts +1 -1
  68. package/src/routes/auth/signin.tsx +23 -5
  69. package/src/routes/dash/settings.tsx +3 -5
  70. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  71. package/src/routes/feed/sitemap.ts +208 -33
  72. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  73. package/src/routes/pages/home.tsx +24 -15
  74. package/src/routes/pages/page.tsx +34 -0
  75. package/src/routes/pages/partials.tsx +4 -15
  76. package/src/runtime/cloudflare.ts +4 -0
  77. package/src/runtime/node.ts +16 -0
  78. package/src/services/__tests__/post.test.ts +205 -0
  79. package/src/services/__tests__/search.test.ts +44 -0
  80. package/src/services/__tests__/site-admin.test.ts +85 -0
  81. package/src/services/export.ts +9 -2
  82. package/src/services/post.ts +200 -2
  83. package/src/services/site-admin.ts +66 -1
  84. package/src/styles/ui.css +12 -0
  85. package/src/types/app-context.ts +20 -0
  86. package/src/types/config.ts +8 -0
  87. package/src/types/props.ts +0 -7
  88. package/src/ui/feed/LinkCard.tsx +3 -20
  89. package/src/ui/feed/LinkPreview.tsx +5 -19
  90. package/src/ui/feed/PostStatusBadges.tsx +4 -38
  91. package/src/ui/layouts/BaseLayout.tsx +23 -29
  92. package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
  93. package/src/ui/shared/Icon.tsx +60 -0
  94. package/src/ui/shared/IconSprite.tsx +57 -0
  95. package/src/ui/shared/PostFooter.tsx +6 -62
  96. package/src/ui/shared/custom-icons.ts +132 -0
  97. package/src/ui/shared/icon-collector.ts +37 -0
  98. package/dist/app-DzCB4yOp.js +0 -5
  99. package/dist/client/_assets/client-C_kImWZj.css +0 -2
@@ -93,15 +93,25 @@ const SEARCHABLE_TYPES = new Set([
93
93
  * matching.
94
94
  *
95
95
  * @param bodyJson - TipTap JSON string (the `body` column)
96
+ * @param options.includeLinkHrefs
97
+ * When `true`, URLs from inline link marks are appended after the link text
98
+ * so they get indexed for search. Default `false` keeps the output clean for
99
+ * plain-text consumers like `toPlainText`/`extractTitle`.
96
100
  * @returns Plain text for FTS indexing, or null if parsing fails or doc is empty
97
101
  *
98
102
  * @example
99
103
  * ```ts
100
104
  * const text = extractBodyText(body);
101
105
  * // "Hello world Some code here"
106
+ *
107
+ * const indexed = extractBodyText(body, { includeLinkHrefs: true });
108
+ * // "See this page https://example.com"
102
109
  * ```
103
110
  */
104
- export function extractBodyText(bodyJson: string): string | null {
111
+ export function extractBodyText(
112
+ bodyJson: string,
113
+ options: { includeLinkHrefs?: boolean } = {},
114
+ ): string | null {
105
115
  let doc: TiptapNode;
106
116
  try {
107
117
  doc = JSON.parse(bodyJson) as TiptapNode;
@@ -111,9 +121,23 @@ export function extractBodyText(bodyJson: string): string | null {
111
121
 
112
122
  if (doc.type !== "doc" || !doc.content) return null;
113
123
 
124
+ const includeLinkHrefs = options.includeLinkHrefs === true;
125
+
114
126
  function collectText(node: TiptapNode): string {
115
127
  if (!SEARCHABLE_TYPES.has(node.type)) return "";
116
- if (node.type === "text") return node.text ?? "";
128
+ if (node.type === "text") {
129
+ const text = node.text ?? "";
130
+ if (!includeLinkHrefs || !node.marks || node.marks.length === 0) {
131
+ return text;
132
+ }
133
+ const hrefs: string[] = [];
134
+ for (const mark of node.marks) {
135
+ if (mark.type !== "link") continue;
136
+ const href = mark.attrs?.href;
137
+ if (typeof href === "string" && href.trim()) hrefs.push(href);
138
+ }
139
+ return hrefs.length > 0 ? `${text} ${hrefs.join(" ")}` : text;
140
+ }
117
141
  if (node.type === "hardBreak") return " ";
118
142
  if (!node.content) return "";
119
143
  return node.content.map(collectText).join(" ");
@@ -190,19 +214,22 @@ export function extractSummary(
190
214
  * @param bodyJson - Tiptap JSON string
191
215
  * @param maxBlocks - Maximum number of top-level blocks to include
192
216
  * @param maxChars - Maximum total plain-text character count
193
- * @returns HTML summary and whether content was truncated, or null
217
+ * @returns HTML summary, whether content was truncated, and the index in
218
+ * `doc.content` where the content after the summary boundary begins, or null.
219
+ * `breakAtIndex` lets callers align the summary with the full-body rendering
220
+ * when splitting at the "read more" boundary (e.g. to insert an anchor).
194
221
  *
195
222
  * @example
196
223
  * ```ts
197
224
  * const result = extractSummaryHtml(body, 5, 500);
198
- * // { html: "<ul><li><p>Item</p></li></ul>", hasMore: true }
225
+ * // { html: "<ul><li><p>Item</p></li></ul>", hasMore: true, breakAtIndex: 1 }
199
226
  * ```
200
227
  */
201
228
  export function extractSummaryHtml(
202
229
  bodyJson: string,
203
230
  maxBlocks: number = 5,
204
231
  maxChars: number = 500,
205
- ): { html: string; hasMore: boolean } | null {
232
+ ): { html: string; hasMore: boolean; breakAtIndex: number } | null {
206
233
  let doc: TiptapNode;
207
234
  try {
208
235
  doc = JSON.parse(bodyJson) as TiptapNode;
@@ -231,15 +258,21 @@ export function extractSummaryHtml(
231
258
  return {
232
259
  html: renderTiptapDocument(subDoc),
233
260
  hasMore: true,
261
+ // Anchor goes in place of the moreBreak marker, so the marker itself
262
+ // is NOT part of the pre-anchor body. It remains in the post-anchor
263
+ // body as an inert HTML comment.
264
+ breakAtIndex: moreBreakIdx,
234
265
  };
235
266
  }
236
267
 
237
268
  // No moreBreak — accumulate blocks up to limits
238
269
  const selected: TiptapNode[] = [];
239
270
  let totalChars = 0;
271
+ let lastSelectedIdx = -1;
240
272
 
241
- for (const node of nodes) {
242
- if (!SUMMARY_BLOCK_TYPES.has(node.type)) continue;
273
+ for (let i = 0; i < nodes.length; i++) {
274
+ const node = nodes[i];
275
+ if (!node || !SUMMARY_BLOCK_TYPES.has(node.type)) continue;
243
276
 
244
277
  const text = extractPlainText(node).trim();
245
278
  if (
@@ -250,6 +283,7 @@ export function extractSummaryHtml(
250
283
 
251
284
  selected.push(node);
252
285
  totalChars += text.length;
286
+ lastSelectedIdx = i;
253
287
  }
254
288
 
255
289
  if (selected.length === 0) return null;
@@ -261,5 +295,6 @@ export function extractSummaryHtml(
261
295
  return {
262
296
  html: renderTiptapDocument(subDoc),
263
297
  hasMore: selected.length < totalContentNodes,
298
+ breakAtIndex: lastSelectedIdx + 1,
264
299
  };
265
300
  }
package/src/lib/url.ts CHANGED
@@ -303,6 +303,40 @@ export function toPublicHref(href: string, sitePathPrefix = ""): string {
303
303
  return toPublicPath(href, sitePathPrefix);
304
304
  }
305
305
 
306
+ /**
307
+ * Check whether a path is a safe same-origin redirect target.
308
+ *
309
+ * Accepts only paths that start with a single `/` (no protocol-relative
310
+ * `//host`, no scheme, no control characters). Callers should use this to
311
+ * validate user-supplied `redirect` query parameters before issuing a
312
+ * `Location` header.
313
+ *
314
+ * @param path - Candidate redirect path
315
+ * @returns `true` when the path is safe to use as an internal redirect
316
+ *
317
+ * @example
318
+ * ```ts
319
+ * isSafeInternalRedirect("/settings") // true
320
+ * isSafeInternalRedirect("//evil.example") // false
321
+ * isSafeInternalRedirect("https://evil.example") // false
322
+ * ```
323
+ */
324
+ export function isSafeInternalRedirect(
325
+ path: string | null | undefined,
326
+ ): path is string {
327
+ if (typeof path !== "string") return false;
328
+ if (!path.startsWith("/")) return false;
329
+ if (path.startsWith("//")) return false;
330
+ // Disallow backslash-prefixed paths (some browsers treat `/\host` as
331
+ // protocol-relative) and control characters that could smuggle headers.
332
+ if (path.startsWith("/\\")) return false;
333
+ for (let i = 0; i < path.length; i += 1) {
334
+ const code = path.charCodeAt(i);
335
+ if (code < 0x20 || code === 0x7f) return false;
336
+ }
337
+ return true;
338
+ }
339
+
306
340
  /**
307
341
  * Remove the site path prefix from a public request pathname.
308
342
  *
package/src/lib/view.ts CHANGED
@@ -35,7 +35,8 @@ import {
35
35
  } from "./time.js";
36
36
  import { getCollectionPagePath } from "./collection-paths.js";
37
37
  import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
38
- import { extractSummaryHtml } from "./summary.js";
38
+ import { extractSummaryHtml, extractBodyText } from "./summary.js";
39
+ import { renderTiptapDocument } from "./tiptap-render.js";
39
40
  import { highlightText } from "./search-snippet.js";
40
41
  import { toPublicPath } from "./url.js";
41
42
 
@@ -159,8 +160,17 @@ function getPlainSummary(post: PostWithMedia): string | undefined {
159
160
  return normalizePreviewText(post.quoteText);
160
161
  }
161
162
 
163
+ // `post.bodyText` is written with `includeLinkHrefs: true` for FTS search
164
+ // indexing, so it's polluted with trailing link URLs. For human-facing
165
+ // preview text, prefer a clean re-derivation from the source TipTap JSON.
166
+ // Fall back to `post.bodyText` when the body isn't valid JSON (legacy rows
167
+ // or fixtures); that path predates link-href injection and carries no
168
+ // pollution risk.
169
+ const cleanBody = post.body ? extractBodyText(post.body) : null;
170
+
162
171
  return (
163
172
  normalizePreviewText(post.summary) ||
173
+ normalizePreviewText(cleanBody) ||
164
174
  normalizePreviewText(post.bodyText) ||
165
175
  getLegacyBodyPreview(post) ||
166
176
  normalizePreviewText(post.url)
@@ -216,14 +226,38 @@ export function toPostView(
216
226
  summaryHasMore = result.hasMore;
217
227
 
218
228
  // Inject #continue anchor at the excerpt boundary for scroll targeting.
219
- // Both summaryHtml and bodyHtml are rendered by the same renderTiptapJson,
220
- // so the excerpt HTML is a prefix of bodyHtml.
229
+ // The summary HTML is NOT a byte-prefix of bodyHtml — structural nodes
230
+ // like `horizontalRule` and `moreBreak` appear in bodyHtml but are
231
+ // excluded from the summary, so slicing bodyHtml by summary.length lands
232
+ // mid-tag and corrupts the markup. Instead, render the pre-boundary
233
+ // doc slice and splice the anchor at that exact block boundary.
221
234
  if (result.hasMore && post.bodyHtml) {
222
- const pos = result.html.length;
223
- bodyHtmlWithAnchor =
224
- post.bodyHtml.slice(0, pos) +
225
- '<span id="continue"></span>' +
226
- post.bodyHtml.slice(pos);
235
+ try {
236
+ const doc = JSON.parse(post.body) as {
237
+ type?: string;
238
+ content?: unknown[];
239
+ };
240
+ if (
241
+ doc.type === "doc" &&
242
+ Array.isArray(doc.content) &&
243
+ result.breakAtIndex > 0 &&
244
+ result.breakAtIndex <= doc.content.length
245
+ ) {
246
+ const beforeHtml = renderTiptapDocument({
247
+ type: "doc",
248
+ content: doc.content.slice(0, result.breakAtIndex) as never[],
249
+ });
250
+ if (post.bodyHtml.startsWith(beforeHtml)) {
251
+ bodyHtmlWithAnchor =
252
+ beforeHtml +
253
+ '<span id="continue"></span>' +
254
+ post.bodyHtml.slice(beforeHtml.length);
255
+ }
256
+ }
257
+ } catch {
258
+ // Fallback: leave bodyHtml untouched if the split can't be computed
259
+ // safely. Better no anchor than a broken document.
260
+ }
227
261
  }
228
262
  }
229
263
  }
@@ -7,6 +7,7 @@ import {
7
7
  isLocalHostname,
8
8
  hasValidLocalDevToken,
9
9
  } from "../auth.js";
10
+ import { attachSession } from "../session.js";
10
11
  import { errorHandler } from "../error-handler.js";
11
12
  import { DEFAULT_APP_PORT } from "../../lib/env.js";
12
13
  import type { Bindings } from "../../types.js";
@@ -135,6 +136,7 @@ describe("requireAuth", () => {
135
136
  } as AppVariables["services"]);
136
137
  await next();
137
138
  });
139
+ app.use("*", attachSession());
138
140
  app.get("/settings", requireAuth(), (c) => c.text("Settings"));
139
141
 
140
142
  const res = await app.request("/settings");
@@ -142,7 +144,7 @@ describe("requireAuth", () => {
142
144
  expect(await res.text()).toBe("Settings");
143
145
  });
144
146
 
145
- it("redirects unauthenticated requests to /signin", async () => {
147
+ it("redirects unauthenticated requests to /signin with the original path", async () => {
146
148
  const app = createTestHonoApp();
147
149
  app.use("*", async (c, next) => {
148
150
  c.set("auth", createMockAuth(false));
@@ -151,14 +153,38 @@ describe("requireAuth", () => {
151
153
  } as AppVariables["services"]);
152
154
  await next();
153
155
  });
156
+ app.use("*", attachSession());
157
+ app.get("/settings/general", requireAuth(), (c) => c.text("Settings"));
158
+
159
+ const res = await app.request("/settings/general", { redirect: "manual" });
160
+ expect(res.status).toBe(302);
161
+ expect(res.headers.get("Location")).toBe(
162
+ "/signin?redirect=%2Fsettings%2Fgeneral",
163
+ );
164
+ });
165
+
166
+ it("preserves query string when redirecting to /signin", async () => {
167
+ const app = createTestHonoApp();
168
+ app.use("*", async (c, next) => {
169
+ c.set("auth", createMockAuth(false));
170
+ c.set("services", {
171
+ siteMembers: createMockSiteMembers(),
172
+ } as AppVariables["services"]);
173
+ await next();
174
+ });
175
+ app.use("*", attachSession());
154
176
  app.get("/settings", requireAuth(), (c) => c.text("Settings"));
155
177
 
156
- const res = await app.request("/settings", { redirect: "manual" });
178
+ const res = await app.request("/settings?tab=profile", {
179
+ redirect: "manual",
180
+ });
157
181
  expect(res.status).toBe(302);
158
- expect(res.headers.get("Location")).toBe("/signin");
182
+ expect(res.headers.get("Location")).toBe(
183
+ "/signin?redirect=%2Fsettings%3Ftab%3Dprofile",
184
+ );
159
185
  });
160
186
 
161
- it("redirects to custom path", async () => {
187
+ it("does not add a redirect query when requireAuth targets a custom path", async () => {
162
188
  const app = createTestHonoApp();
163
189
  app.use("*", async (c, next) => {
164
190
  c.set("auth", createMockAuth(false));
@@ -167,6 +193,7 @@ describe("requireAuth", () => {
167
193
  } as AppVariables["services"]);
168
194
  await next();
169
195
  });
196
+ app.use("*", attachSession());
170
197
  app.get("/settings", requireAuth("/login"), (c) => c.text("Settings"));
171
198
 
172
199
  const res = await app.request("/settings", { redirect: "manual" });
@@ -187,6 +214,7 @@ describe("requireAuthApi", () => {
187
214
  } as AppVariables["services"]);
188
215
  await next();
189
216
  });
217
+ app.use("*", attachSession());
190
218
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
191
219
 
192
220
  const res = await app.request("/api/data");
@@ -207,6 +235,7 @@ describe("requireAuthApi", () => {
207
235
  } as AppVariables["services"]);
208
236
  await next();
209
237
  });
238
+ app.use("*", attachSession());
210
239
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
211
240
 
212
241
  const res = await app.request("/api/data");
@@ -234,6 +263,7 @@ describe("requireAuthApi", () => {
234
263
  } as AppVariables["services"]);
235
264
  await next();
236
265
  });
266
+ app.use("*", attachSession());
237
267
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
238
268
 
239
269
  const res = await app.request("/api/data");
@@ -254,6 +284,7 @@ describe("requireAuthApi", () => {
254
284
  } as AppVariables["services"]);
255
285
  await next();
256
286
  });
287
+ app.use("*", attachSession());
257
288
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
258
289
 
259
290
  const res = await app.request("/api/data", {
@@ -281,6 +312,7 @@ describe("requireAuthApi", () => {
281
312
  } as AppVariables["services"]);
282
313
  await next();
283
314
  });
315
+ app.use("*", attachSession());
284
316
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
285
317
 
286
318
  const res = await app.request("/api/data", {
@@ -304,6 +336,7 @@ describe("requireAuthApi", () => {
304
336
  } as AppVariables["services"]);
305
337
  await next();
306
338
  });
339
+ app.use("*", attachSession());
307
340
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
308
341
 
309
342
  const res = await app.request("/api/data", {
@@ -330,6 +363,7 @@ describe("requireAuthApi", () => {
330
363
  } as AppVariables["services"]);
331
364
  await next();
332
365
  });
366
+ app.use("*", attachSession());
333
367
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
334
368
 
335
369
  const res = await app.request(LOCAL_API_URL, {
@@ -356,6 +390,7 @@ describe("requireAuthApi", () => {
356
390
  } as AppVariables["services"]);
357
391
  await next();
358
392
  });
393
+ app.use("*", attachSession());
359
394
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
360
395
 
361
396
  const res = await app.request("https://myblog.com/api/data", {
@@ -382,6 +417,7 @@ describe("requireAuthApi", () => {
382
417
  } as AppVariables["services"]);
383
418
  await next();
384
419
  });
420
+ app.use("*", attachSession());
385
421
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
386
422
 
387
423
  const res = await app.request("https://jant.localtest.me/api/data", {
@@ -410,6 +446,7 @@ describe("requireAuthApi", () => {
410
446
  } as AppVariables["services"]);
411
447
  await next();
412
448
  });
449
+ app.use("*", attachSession());
413
450
  app.get("/api/data", requireAuthApi(), (c) => c.json({ data: "secret" }));
414
451
 
415
452
  const res = await app.request("https://jant.me/api/data", {
@@ -436,6 +473,7 @@ describe("requireInternalAdminApi", () => {
436
473
  } as AppVariables["services"]);
437
474
  await next();
438
475
  });
476
+ app.use("*", attachSession());
439
477
  app.post("/api/internal/demo", requireInternalAdminApi(), (c) =>
440
478
  c.json({ ok: true }),
441
479
  );
@@ -459,6 +497,7 @@ describe("requireInternalAdminApi", () => {
459
497
  } as AppVariables["services"]);
460
498
  await next();
461
499
  });
500
+ app.use("*", attachSession());
462
501
  app.post("/api/internal/demo", requireInternalAdminApi(), (c) =>
463
502
  c.json({ ok: true }),
464
503
  );
@@ -485,6 +524,7 @@ describe("requireInternalAdminApi", () => {
485
524
  } as AppVariables["services"]);
486
525
  await next();
487
526
  });
527
+ app.use("*", attachSession());
488
528
  app.post("/api/internal/demo", requireInternalAdminApi(), (c) =>
489
529
  c.json({ ok: true }),
490
530
  );
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Hono } from "hono";
3
+ import { rateLimit } from "../rate-limit.js";
4
+ import type { RateLimiter } from "../../lib/rate-limit.js";
5
+ import type { Bindings } from "../../types.js";
6
+ import type { AppVariables } from "../../types/app-context.js";
7
+
8
+ type Env = { Bindings: Bindings; Variables: AppVariables };
9
+
10
+ /**
11
+ * Build a tiny Hono app that seeds just the slice of `c.var` the
12
+ * rate-limit middleware reads — keeps the blast radius of each test
13
+ * minimal and independent of the full `createTestApp` fixture.
14
+ */
15
+ function buildApp(options: {
16
+ limiter: RateLimiter;
17
+ disabled?: boolean;
18
+ }): Hono<Env> {
19
+ const app = new Hono<Env>();
20
+ app.use("*", async (c, next) => {
21
+ c.set("rateLimiter", options.limiter);
22
+ c.set("appConfig", {
23
+ rateLimit: {
24
+ disabled: options.disabled ?? false,
25
+ searchPerMinute: 30,
26
+ },
27
+ } as AppVariables["appConfig"]);
28
+ await next();
29
+ });
30
+ app.use("*", rateLimit({ name: "test", limit: 2, windowSec: 60 }));
31
+ app.get("/", (c) => c.text("ok"));
32
+ return app;
33
+ }
34
+
35
+ /** Fake limiter that lets us script results without real timing. */
36
+ function scriptedLimiter(
37
+ outcomes: Array<{ ok: boolean; retryAfterSec?: number }>,
38
+ ) {
39
+ const keys: string[] = [];
40
+ let i = 0;
41
+ const limiter: RateLimiter = {
42
+ async check(key) {
43
+ keys.push(key);
44
+ const out = outcomes[i++] ?? { ok: true };
45
+ return out;
46
+ },
47
+ };
48
+ return { limiter, keys: () => keys };
49
+ }
50
+
51
+ describe("rateLimit middleware", () => {
52
+ it("passes the request through when under limit", async () => {
53
+ const { limiter } = scriptedLimiter([{ ok: true }]);
54
+ const app = buildApp({ limiter });
55
+
56
+ const res = await app.request("/");
57
+ expect(res.status).toBe(200);
58
+ expect(await res.text()).toBe("ok");
59
+ });
60
+
61
+ it("responds 429 with Retry-After when the limiter rejects", async () => {
62
+ const { limiter } = scriptedLimiter([{ ok: false, retryAfterSec: 42 }]);
63
+ const app = buildApp({ limiter });
64
+
65
+ const res = await app.request("/");
66
+ expect(res.status).toBe(429);
67
+ expect(res.headers.get("retry-after")).toBe("42");
68
+ expect(await res.json()).toEqual({
69
+ error: "Too many requests. Please slow down.",
70
+ });
71
+ });
72
+
73
+ it("falls back to the window size when retryAfterSec is missing", async () => {
74
+ const { limiter } = scriptedLimiter([{ ok: false }]);
75
+ const app = buildApp({ limiter });
76
+
77
+ const res = await app.request("/");
78
+ expect(res.headers.get("retry-after")).toBe("60");
79
+ });
80
+
81
+ it("short-circuits when rateLimit.disabled is true", async () => {
82
+ const { limiter, keys } = scriptedLimiter([{ ok: false }]);
83
+ const app = buildApp({ limiter, disabled: true });
84
+
85
+ const res = await app.request("/");
86
+ expect(res.status).toBe(200);
87
+ // Limiter should not have been consulted at all when disabled.
88
+ expect(keys()).toEqual([]);
89
+ });
90
+
91
+ it("prefers cf-connecting-ip over x-forwarded-for for the bucket key", async () => {
92
+ const { limiter, keys } = scriptedLimiter([{ ok: true }]);
93
+ const app = buildApp({ limiter });
94
+
95
+ await app.request("/", {
96
+ headers: {
97
+ "cf-connecting-ip": "1.2.3.4",
98
+ "x-forwarded-for": "5.6.7.8",
99
+ },
100
+ });
101
+ expect(keys()).toEqual(["test:1.2.3.4"]);
102
+ });
103
+
104
+ it("falls back to x-forwarded-for (first entry) when cf header is absent", async () => {
105
+ const { limiter, keys } = scriptedLimiter([{ ok: true }]);
106
+ const app = buildApp({ limiter });
107
+
108
+ await app.request("/", {
109
+ headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2" },
110
+ });
111
+ expect(keys()).toEqual(["test:10.0.0.1"]);
112
+ });
113
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Hono } from "hono";
3
+ import { attachSession } from "../session.js";
4
+ import type { Bindings } from "../../types.js";
5
+ import type { AppVariables } from "../../types/app-context.js";
6
+
7
+ type Env = { Bindings: Bindings; Variables: AppVariables };
8
+
9
+ function createAppWithAuth(mockAuth: AppVariables["auth"]): Hono<Env> & {
10
+ // ensures tests see session/isAuthenticated via c.var
11
+ _lastSession?: AppVariables["session"];
12
+ _lastIsAuthenticated?: boolean;
13
+ } {
14
+ const app = new Hono<Env>();
15
+ app.use("*", async (c, next) => {
16
+ c.set("auth", mockAuth);
17
+ await next();
18
+ });
19
+ app.use("*", attachSession());
20
+ return app;
21
+ }
22
+
23
+ function buildSessionMock(
24
+ impl: () => Promise<AppVariables["session"]>,
25
+ ): AppVariables["auth"] {
26
+ return {
27
+ api: {
28
+ getSession: impl,
29
+ },
30
+ } as unknown as AppVariables["auth"];
31
+ }
32
+
33
+ describe("attachSession", () => {
34
+ it("populates c.var.session and isAuthenticated on a valid session", async () => {
35
+ const mockAuth = buildSessionMock(
36
+ async () =>
37
+ ({
38
+ user: { id: "user-1", email: "x@y.z", name: "X" },
39
+ session: { id: "sess-1" },
40
+ }) as unknown as AppVariables["session"],
41
+ );
42
+ const app = createAppWithAuth(mockAuth);
43
+ app.get("/", (c) =>
44
+ c.json({
45
+ authed: c.var.isAuthenticated,
46
+ userId: (c.var.session?.user as { id?: string } | undefined)?.id,
47
+ }),
48
+ );
49
+
50
+ const res = await app.request("/");
51
+ expect(res.status).toBe(200);
52
+ expect(await res.json()).toEqual({ authed: true, userId: "user-1" });
53
+ });
54
+
55
+ it("sets isAuthenticated=false and session=null when no session is present", async () => {
56
+ const mockAuth = buildSessionMock(async () => null);
57
+ const app = createAppWithAuth(mockAuth);
58
+ app.get("/", (c) =>
59
+ c.json({
60
+ authed: c.var.isAuthenticated,
61
+ session: c.var.session,
62
+ }),
63
+ );
64
+
65
+ const res = await app.request("/");
66
+ expect(await res.json()).toEqual({ authed: false, session: null });
67
+ });
68
+
69
+ it("swallows errors from getSession and treats the request as unauthenticated", async () => {
70
+ const mockAuth = buildSessionMock(async () => {
71
+ throw new Error("session lookup failed");
72
+ });
73
+ const app = createAppWithAuth(mockAuth);
74
+ app.get("/", (c) =>
75
+ c.json({
76
+ authed: c.var.isAuthenticated,
77
+ session: c.var.session,
78
+ }),
79
+ );
80
+
81
+ const res = await app.request("/");
82
+ expect(res.status).toBe(200);
83
+ expect(await res.json()).toEqual({ authed: false, session: null });
84
+ });
85
+ });