@jant/core 0.3.42 → 0.3.43

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 (79) 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-Ctl0T0zO.js +5 -0
  6. package/dist/{app-Cu3lveYI.js → app-GbfwoeDJ.js} +630 -123
  7. package/dist/client/.vite/manifest.json +1 -1
  8. package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
  9. package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
  10. package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
  11. package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
  12. package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
  13. package/dist/index.js +5 -5
  14. package/dist/node.js +5 -5
  15. package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
  16. package/package.json +1 -1
  17. package/src/__tests__/helpers/app.ts +15 -4
  18. package/src/app.tsx +8 -0
  19. package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
  20. package/src/client/tiptap/extensions.ts +3 -0
  21. package/src/client/tiptap/insert-paragraph-around.ts +79 -0
  22. package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
  23. package/src/db/migrations/meta/0018_snapshot.json +2225 -0
  24. package/src/db/migrations/meta/_journal.json +8 -1
  25. package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
  26. package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
  27. package/src/db/migrations/pg/meta/_journal.json +8 -1
  28. package/src/db/pg/schema.ts +18 -0
  29. package/src/db/schema.ts +23 -0
  30. package/src/index.ts +1 -2
  31. package/src/lib/__tests__/hosted-signin.test.ts +30 -0
  32. package/src/lib/__tests__/navigation.test.ts +4 -20
  33. package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
  34. package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
  35. package/src/lib/__tests__/summary.test.ts +140 -0
  36. package/src/lib/__tests__/view.test.ts +66 -0
  37. package/src/lib/feed.ts +70 -34
  38. package/src/lib/hosted-signin.ts +9 -3
  39. package/src/lib/navigation.ts +11 -12
  40. package/src/lib/post-meta.ts +20 -2
  41. package/src/lib/rate-limit-d1.ts +99 -0
  42. package/src/lib/rate-limit-memory.ts +105 -0
  43. package/src/lib/rate-limit.ts +63 -0
  44. package/src/lib/render.tsx +9 -0
  45. package/src/lib/resolve-config.ts +9 -0
  46. package/src/lib/summary.ts +42 -7
  47. package/src/lib/url.ts +34 -0
  48. package/src/lib/view.ts +42 -8
  49. package/src/middleware/__tests__/auth.test.ts +44 -4
  50. package/src/middleware/__tests__/rate-limit.test.ts +113 -0
  51. package/src/middleware/__tests__/session.test.ts +85 -0
  52. package/src/middleware/auth.ts +62 -25
  53. package/src/middleware/rate-limit.ts +54 -0
  54. package/src/middleware/session.ts +36 -0
  55. package/src/routes/__tests__/compose.test.ts +1 -1
  56. package/src/routes/api/__tests__/search.test.ts +48 -0
  57. package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
  58. package/src/routes/api/internal/search-reindex.ts +40 -0
  59. package/src/routes/api/search.ts +13 -0
  60. package/src/routes/auth/dev.ts +1 -1
  61. package/src/routes/auth/signin.tsx +23 -5
  62. package/src/routes/dash/settings.tsx +3 -5
  63. package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
  64. package/src/routes/feed/sitemap.ts +208 -33
  65. package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
  66. package/src/routes/pages/home.tsx +24 -15
  67. package/src/routes/pages/page.tsx +34 -0
  68. package/src/routes/pages/partials.tsx +4 -15
  69. package/src/runtime/cloudflare.ts +4 -0
  70. package/src/runtime/node.ts +16 -0
  71. package/src/services/__tests__/post.test.ts +205 -0
  72. package/src/services/__tests__/search.test.ts +44 -0
  73. package/src/services/export.ts +9 -2
  74. package/src/services/post.ts +200 -2
  75. package/src/types/app-context.ts +20 -0
  76. package/src/types/config.ts +8 -0
  77. package/src/types/props.ts +0 -7
  78. package/src/ui/layouts/BaseLayout.tsx +9 -0
  79. package/dist/app-DzCB4yOp.js +0 -5
@@ -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
+ });
@@ -11,7 +11,7 @@ import type { AppVariables } from "../types/app-context.js";
11
11
  import { getDevApiToken, getInternalAdminToken } from "../lib/env.js";
12
12
  import { NotFoundError, UnauthorizedError } from "../lib/errors.js";
13
13
  import { getRuntimeSitePathPrefix } from "../lib/site-resolution.js";
14
- import { toPublicHref } from "../lib/url.js";
14
+ import { isSafeInternalRedirect, toPublicHref } from "../lib/url.js";
15
15
 
16
16
  type Env = { Bindings: Bindings; Variables: AppVariables };
17
17
 
@@ -78,6 +78,38 @@ export function hasValidLocalDevToken(
78
78
  return hostname ? isLocalHostname(hostname) : false;
79
79
  }
80
80
 
81
+ /**
82
+ * Paths that should never be used as post-signin redirect targets (would
83
+ * either loop back to signin or hit an unauthenticated endpoint).
84
+ */
85
+ const POST_SIGNIN_REDIRECT_BLOCKLIST = new Set([
86
+ "/signin",
87
+ "/signout",
88
+ "/setup",
89
+ "/reset",
90
+ "/__sso",
91
+ ]);
92
+
93
+ function getPostSigninRedirect(requestUrl: string): string | null {
94
+ // `c.req.url` is already the app-internal URL — `prepareRequestForRouting`
95
+ // strips any configured site path prefix before Hono sees it, so we just
96
+ // need to preserve pathname + query and validate it as a safe same-origin
97
+ // redirect target.
98
+ let url: URL;
99
+ try {
100
+ url = new URL(requestUrl);
101
+ } catch {
102
+ return null;
103
+ }
104
+
105
+ const pathname = url.pathname || "/";
106
+ if (POST_SIGNIN_REDIRECT_BLOCKLIST.has(pathname)) return null;
107
+ if (pathname.startsWith("/api/")) return null;
108
+
109
+ const candidate = `${pathname}${url.search}`;
110
+ return isSafeInternalRedirect(candidate) ? candidate : null;
111
+ }
112
+
81
113
  /**
82
114
  * Middleware that requires authentication.
83
115
  * Redirects to signin page if not authenticated.
@@ -90,28 +122,38 @@ export function requireAuth(redirectTo = "/signin"): MiddlewareHandler<Env> {
90
122
  appConfig: c.var.appConfig,
91
123
  currentSiteDomain: c.var.currentSiteDomain,
92
124
  });
93
- const redirectTarget = toPublicHref(redirectTo, sitePathPrefix);
94
125
 
95
- try {
96
- const session = await c.var.auth.api.getSession({
97
- headers: c.req.raw.headers,
98
- });
126
+ const buildRedirectTarget = () => {
127
+ const publicHref = toPublicHref(redirectTo, sitePathPrefix);
128
+ // Only append `?redirect=...` when redirecting to the default signin flow.
129
+ // Callers passing a custom path get it untouched.
130
+ if (redirectTo !== "/signin") return publicHref;
99
131
 
100
- if (!session?.user) {
101
- return c.redirect(redirectTarget);
102
- }
132
+ const postSignin = getPostSigninRedirect(c.req.url);
133
+ if (!postSignin) return publicHref;
134
+
135
+ const separator = publicHref.includes("?") ? "&" : "?";
136
+ return `${publicHref}${separator}redirect=${encodeURIComponent(postSignin)}`;
137
+ };
138
+
139
+ // Session was already fetched by `attachSession` middleware.
140
+ const session = c.var.session;
141
+ if (!session?.user) {
142
+ return c.redirect(buildRedirectTarget());
143
+ }
103
144
 
145
+ try {
104
146
  const membership = await c.var.services.siteMembers.get(
105
147
  c.var.currentSite.id,
106
148
  session.user.id,
107
149
  );
108
150
  if (!membership) {
109
- return c.redirect(redirectTarget);
151
+ return c.redirect(buildRedirectTarget());
110
152
  }
111
153
 
112
154
  await next();
113
155
  } catch {
114
- return c.redirect(redirectTarget);
156
+ return c.redirect(buildRedirectTarget());
115
157
  }
116
158
  };
117
159
  }
@@ -123,26 +165,21 @@ export function requireAuth(redirectTo = "/signin"): MiddlewareHandler<Env> {
123
165
  */
124
166
  export function requireAuthApi(): MiddlewareHandler<Env> {
125
167
  return async (c, next) => {
126
- // 1. Try session auth (existing behavior)
127
- try {
128
- const session = await c.var.auth.api.getSession({
129
- headers: c.req.raw.headers,
130
- });
131
-
132
- if (session?.user) {
168
+ // 1. Try session auth (session is pre-fetched by `attachSession` middleware).
169
+ const session = c.var.session;
170
+ if (session?.user) {
171
+ try {
133
172
  const membership = await c.var.services.siteMembers.get(
134
173
  c.var.currentSite.id,
135
174
  session.user.id,
136
175
  );
137
- if (!membership) {
138
- throw new UnauthorizedError();
176
+ if (membership) {
177
+ await next();
178
+ return;
139
179
  }
140
-
141
- await next();
142
- return;
180
+ } catch {
181
+ // Membership check failed — fall through to Bearer token
143
182
  }
144
- } catch {
145
- // Session check failed — fall through to Bearer token
146
183
  }
147
184
 
148
185
  // 2. Try Bearer token auth
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Rate-Limit Middleware
3
+ *
4
+ * Applies a per-IP rate limit to the routes it wraps. Which storage
5
+ * backs the limiter (D1 or in-memory) is decided upstream by the
6
+ * runtime; this middleware only reads `c.var.rateLimiter` and doesn't
7
+ * care.
8
+ *
9
+ * When the limit is exceeded, responds with HTTP 429 and a
10
+ * `Retry-After` header. When `appConfig.rateLimit.disabled` is true the
11
+ * middleware short-circuits to `next()` so test and dev environments
12
+ * don't have to reason about bucket state.
13
+ */
14
+
15
+ import type { MiddlewareHandler } from "hono";
16
+ import { getClientIp } from "../lib/rate-limit.js";
17
+ import type { Bindings } from "../types.js";
18
+ import type { AppVariables } from "../types/app-context.js";
19
+
20
+ type Env = { Bindings: Bindings; Variables: AppVariables };
21
+
22
+ export interface RateLimitMiddlewareOptions {
23
+ /**
24
+ * Storage-key prefix scoping this limit (e.g. "search"). Keeps
25
+ * counters for different endpoints independent when they share a
26
+ * storage backend.
27
+ */
28
+ name: string;
29
+ /** Max requests per IP within `windowSec`. */
30
+ limit: number;
31
+ /** Sliding window size in seconds. */
32
+ windowSec: number;
33
+ }
34
+
35
+ export function rateLimit(
36
+ opts: RateLimitMiddlewareOptions,
37
+ ): MiddlewareHandler<Env> {
38
+ return async (c, next) => {
39
+ if (c.var.appConfig.rateLimit.disabled) return next();
40
+
41
+ const ip = getClientIp(c);
42
+ const result = await c.var.rateLimiter.check(`${opts.name}:${ip}`, {
43
+ limit: opts.limit,
44
+ windowSec: opts.windowSec,
45
+ });
46
+
47
+ if (!result.ok) {
48
+ c.header("Retry-After", String(result.retryAfterSec ?? opts.windowSec));
49
+ return c.json({ error: "Too many requests. Please slow down." }, 429);
50
+ }
51
+
52
+ return next();
53
+ };
54
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Session Middleware
3
+ *
4
+ * Runs once per request (after runtime init) to look up the better-auth
5
+ * session and stash it on `c.var.session` / `c.var.isAuthenticated`.
6
+ *
7
+ * This replaces ad-hoc `auth.api.getSession()` calls scattered across
8
+ * view helpers (e.g. `lib/navigation.ts`) so each request only parses
9
+ * the session cookie once. better-auth's own cookieCache (5 min) still
10
+ * keeps this cheap, but centralizing the call also unlocks `Promise.all`
11
+ * patterns in routes that previously serialized on a hidden session fetch.
12
+ *
13
+ * Never throws — any lookup error is treated as "not authenticated".
14
+ */
15
+
16
+ import type { MiddlewareHandler } from "hono";
17
+ import type { Bindings } from "../types.js";
18
+ import type { AppVariables } from "../types/app-context.js";
19
+
20
+ type Env = { Bindings: Bindings; Variables: AppVariables };
21
+
22
+ export function attachSession(): MiddlewareHandler<Env> {
23
+ return async (c, next) => {
24
+ try {
25
+ const session = await c.var.auth.api.getSession({
26
+ headers: c.req.raw.headers,
27
+ });
28
+ c.set("session", session ?? null);
29
+ c.set("isAuthenticated", !!session?.user);
30
+ } catch {
31
+ c.set("session", null);
32
+ c.set("isAuthenticated", false);
33
+ }
34
+ await next();
35
+ };
36
+ }
@@ -45,7 +45,7 @@ describe("Compose Routes", () => {
45
45
  });
46
46
 
47
47
  expect(res.status).toBe(302);
48
- expect(res.headers.get("Location")).toBe("/signin");
48
+ expect(res.headers.get("Location")).toBe("/signin?redirect=%2Fcompose");
49
49
  });
50
50
 
51
51
  it("creates a note post and returns timeline card via SSE", async () => {
@@ -110,4 +110,52 @@ describe("Search API Routes", () => {
110
110
  // Should not return 401
111
111
  expect(res.status).not.toBe(401);
112
112
  });
113
+
114
+ it("rate-limits repeated requests from the same IP", async () => {
115
+ // Test app uses in-memory defaults (30/min). Send 31 requests from
116
+ // the same spoofed IP and confirm the tail gets a 429 with Retry-After.
117
+ const { app } = createTestApp({ fts: true });
118
+ app.route("/api/search", searchApiRoutes);
119
+
120
+ const headers = { "cf-connecting-ip": "203.0.113.7" };
121
+ let ok = 0;
122
+ let throttled = 0;
123
+ let lastRetryAfter: string | null = null;
124
+
125
+ for (let i = 0; i < 31; i++) {
126
+ const res = await app.request("/api/search?q=hi", { headers });
127
+ if (res.status === 429) {
128
+ throttled += 1;
129
+ lastRetryAfter = res.headers.get("retry-after");
130
+ } else if (res.status === 200) {
131
+ ok += 1;
132
+ }
133
+ }
134
+
135
+ expect(ok).toBe(30);
136
+ expect(throttled).toBe(1);
137
+ expect(Number(lastRetryAfter)).toBeGreaterThan(0);
138
+ });
139
+
140
+ it("does not rate-limit when appConfig.rateLimit.disabled is true", async () => {
141
+ const { app } = createTestApp({ fts: true });
142
+
143
+ // Flip the disabled flag after the test-app middleware seeds
144
+ // appConfig, but before the search route runs. Middleware order:
145
+ // createTestApp's global use → this override → search route.
146
+ app.use("/api/search/*", async (c, next) => {
147
+ c.set("appConfig", {
148
+ ...c.var.appConfig,
149
+ rateLimit: { ...c.var.appConfig.rateLimit, disabled: true },
150
+ });
151
+ await next();
152
+ });
153
+ app.route("/api/search", searchApiRoutes);
154
+
155
+ const headers = { "cf-connecting-ip": "203.0.113.8" };
156
+ for (let i = 0; i < 40; i++) {
157
+ const res = await app.request("/api/search?q=hi", { headers });
158
+ expect(res.status).not.toBe(429);
159
+ }
160
+ });
113
161
  });