@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.
- package/bin/commands/import-site.js +1 -1
- package/bin/commands/search-reindex.js +175 -0
- package/bin/lib/hugo-markdown.js +102 -0
- package/bin/lib/site-pull-media.js +1 -4
- package/dist/app-BI9bnCkO.js +5 -0
- package/dist/{app-Cu3lveYI.js → app-CtJDxZBb.js} +969 -373
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_assets/client-BQH7AQ24.css +2 -0
- package/dist/client/_assets/{client-auth-BRFl5zQA.js → client-auth-CXILhW1b.js} +144 -144
- package/dist/{env-wCpMcNXs.js → env-CgaH9Mut.js} +1 -1
- package/dist/{github-api-CficQztC.js → github-api-BkRWnqMx.js} +1 -1
- package/dist/{github-app-F4qZ05xk.js → github-app-WeadXMb8.js} +1 -1
- package/dist/{github-sync-zohnA9qv.js → github-sync-7y_nTXx1.js} +41 -14
- package/dist/index.js +5 -5
- package/dist/node.js +5 -5
- package/dist/{url-FvvgARU9.js → url-umUptr5z.js} +30 -1
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +15 -4
- package/src/app.tsx +8 -0
- package/src/client/tiptap/__tests__/insert-paragraph-around.test.ts +228 -0
- package/src/client/tiptap/extensions.ts +3 -0
- package/src/client/tiptap/insert-paragraph-around.ts +79 -0
- package/src/db/migrations/0018_yummy_franklin_richards.sql +6 -0
- package/src/db/migrations/0019_bored_magus.sql +2 -0
- package/src/db/migrations/meta/0018_snapshot.json +2225 -0
- package/src/db/migrations/meta/0019_snapshot.json +2238 -0
- package/src/db/migrations/meta/_journal.json +15 -1
- package/src/db/migrations/pg/0016_familiar_lionheart.sql +6 -0
- package/src/db/migrations/pg/0017_bright_beyonder.sql +2 -0
- package/src/db/migrations/pg/meta/0016_snapshot.json +2840 -0
- package/src/db/migrations/pg/meta/0017_snapshot.json +2862 -0
- package/src/db/migrations/pg/meta/_journal.json +15 -1
- package/src/db/pg/schema.ts +22 -0
- package/src/db/schema.ts +27 -0
- package/src/index.ts +1 -2
- package/src/lib/__tests__/hosted-signin.test.ts +30 -0
- package/src/lib/__tests__/navigation.test.ts +4 -20
- package/src/lib/__tests__/rate-limit-d1.test.ts +82 -0
- package/src/lib/__tests__/rate-limit-memory.test.ts +69 -0
- package/src/lib/__tests__/summary.test.ts +140 -0
- package/src/lib/__tests__/view.test.ts +66 -0
- package/src/lib/feed.ts +70 -34
- package/src/lib/hosted-signin.ts +9 -3
- package/src/lib/icons.ts +37 -0
- package/src/lib/navigation.ts +11 -12
- package/src/lib/post-meta.ts +20 -2
- package/src/lib/rate-limit-d1.ts +99 -0
- package/src/lib/rate-limit-memory.ts +105 -0
- package/src/lib/rate-limit.ts +63 -0
- package/src/lib/render.tsx +9 -0
- package/src/lib/resolve-config.ts +9 -0
- package/src/lib/summary.ts +42 -7
- package/src/lib/url.ts +34 -0
- package/src/lib/view.ts +42 -8
- package/src/middleware/__tests__/auth.test.ts +44 -4
- package/src/middleware/__tests__/rate-limit.test.ts +113 -0
- package/src/middleware/__tests__/session.test.ts +85 -0
- package/src/middleware/auth.ts +62 -25
- package/src/middleware/rate-limit.ts +54 -0
- package/src/middleware/session.ts +36 -0
- package/src/routes/__tests__/compose.test.ts +1 -1
- package/src/routes/api/__tests__/search.test.ts +48 -0
- package/src/routes/api/__tests__/upload-multipart.test.ts +11 -4
- package/src/routes/api/internal/search-reindex.ts +40 -0
- package/src/routes/api/internal/sites.ts +1 -0
- package/src/routes/api/search.ts +13 -0
- package/src/routes/auth/dev.ts +1 -1
- package/src/routes/auth/signin.tsx +23 -5
- package/src/routes/dash/settings.tsx +3 -5
- package/src/routes/feed/__tests__/sitemap.test.ts +320 -4
- package/src/routes/feed/sitemap.ts +208 -33
- package/src/routes/pages/__tests__/page-canonical.test.ts +101 -0
- package/src/routes/pages/home.tsx +24 -15
- package/src/routes/pages/page.tsx +34 -0
- package/src/routes/pages/partials.tsx +4 -15
- package/src/runtime/cloudflare.ts +4 -0
- package/src/runtime/node.ts +16 -0
- package/src/services/__tests__/post.test.ts +205 -0
- package/src/services/__tests__/search.test.ts +44 -0
- package/src/services/__tests__/site-admin.test.ts +85 -0
- package/src/services/export.ts +9 -2
- package/src/services/post.ts +200 -2
- package/src/services/site-admin.ts +66 -1
- package/src/styles/ui.css +12 -0
- package/src/types/app-context.ts +20 -0
- package/src/types/config.ts +8 -0
- package/src/types/props.ts +0 -7
- package/src/ui/feed/LinkCard.tsx +3 -20
- package/src/ui/feed/LinkPreview.tsx +5 -19
- package/src/ui/feed/PostStatusBadges.tsx +4 -38
- package/src/ui/layouts/BaseLayout.tsx +23 -29
- package/src/ui/shared/DecorativeQuoteMark.tsx +2 -13
- package/src/ui/shared/Icon.tsx +60 -0
- package/src/ui/shared/IconSprite.tsx +57 -0
- package/src/ui/shared/PostFooter.tsx +6 -62
- package/src/ui/shared/custom-icons.ts +132 -0
- package/src/ui/shared/icon-collector.ts +37 -0
- package/dist/app-DzCB4yOp.js +0 -5
- package/dist/client/_assets/client-C_kImWZj.css +0 -2
package/src/lib/summary.ts
CHANGED
|
@@ -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(
|
|
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")
|
|
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
|
|
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 (
|
|
242
|
-
|
|
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
|
-
//
|
|
220
|
-
//
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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", {
|
|
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(
|
|
182
|
+
expect(res.headers.get("Location")).toBe(
|
|
183
|
+
"/signin?redirect=%2Fsettings%3Ftab%3Dprofile",
|
|
184
|
+
);
|
|
159
185
|
});
|
|
160
186
|
|
|
161
|
-
it("
|
|
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
|
+
});
|