@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
@@ -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
  });
@@ -19,6 +19,7 @@ import { createSiteMemberService } from "../../../services/site-member.js";
19
19
  import { errorHandler } from "../../../middleware/error-handler.js";
20
20
  import { createI18n } from "../../../i18n/i18n.js";
21
21
  import { DEFAULT_APP_PORT } from "../../../lib/env.js";
22
+ import { createMemoryRateLimiter } from "../../../lib/rate-limit-memory.js";
22
23
  import { resolveConfig } from "../../../lib/resolve-config.js";
23
24
  import type { Database } from "../../../db/index.js";
24
25
  import type { StorageDriver, UploadedPart } from "../../../lib/storage.js";
@@ -265,6 +266,7 @@ function createTestAppWithStorage(options: {
265
266
  c.set("allSettings", allSettings);
266
267
  c.set("appConfig", resolveConfig(c.env, allSettings));
267
268
  c.set("storage", options.storage);
269
+ c.set("rateLimiter", createMemoryRateLimiter());
268
270
 
269
271
  const i18n = createI18n("en");
270
272
  c.set("lang", "en");
@@ -276,20 +278,25 @@ function createTestAppWithStorage(options: {
276
278
  "test-user",
277
279
  "owner",
278
280
  );
281
+ const session = {
282
+ user: { id: "test-user", email: "test@test.com", name: "Test" },
283
+ session: { id: "test-session" },
284
+ } as unknown as AppVariables["session"];
279
285
  c.set("auth", {
280
286
  api: {
281
- getSession: async () => ({
282
- user: { id: "test-user", email: "test@test.com", name: "Test" },
283
- session: { id: "test-session" },
284
- }),
287
+ getSession: async () => session,
285
288
  },
286
289
  } as AppVariables["auth"]);
290
+ c.set("session", session);
291
+ c.set("isAuthenticated", true);
287
292
  } else {
288
293
  c.set("auth", {
289
294
  api: {
290
295
  getSession: async () => null,
291
296
  },
292
297
  } as AppVariables["auth"]);
298
+ c.set("session", null);
299
+ c.set("isAuthenticated", false);
293
300
  }
294
301
 
295
302
  await next();
@@ -0,0 +1,40 @@
1
+ import { Hono } from "hono";
2
+ import { z } from "zod";
3
+ import { requireInternalAdminApi } from "../../../middleware/auth.js";
4
+ import { parseValidated } from "../../../lib/schemas.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
+ const ReindexSchema = z.object({
11
+ limit: z.number().int().positive().max(500).optional(),
12
+ cursor: z.string().min(1).optional(),
13
+ });
14
+
15
+ export const internalSearchReindexRoutes = new Hono<Env>();
16
+
17
+ /**
18
+ * Rebuild `post.body_text` for a batch of non-deleted posts. FTS indexes
19
+ * (SQLite trigger / Postgres generated column) refresh automatically when
20
+ * `body_text` changes.
21
+ *
22
+ * Idempotent. Callers loop with the returned `nextCursor` until `done: true`.
23
+ * Used by the `jant search-reindex` CLI to backfill search indexes for
24
+ * existing posts after changes to the text extraction logic (e.g. including
25
+ * link mark hrefs so inline URLs become searchable).
26
+ */
27
+ internalSearchReindexRoutes.post("/", requireInternalAdminApi(), async (c) => {
28
+ const contentType = c.req.header("Content-Type") || "";
29
+ const rawBody = contentType.includes("application/json")
30
+ ? await c.req.json().catch(() => ({}))
31
+ : {};
32
+ const body = parseValidated(ReindexSchema, rawBody);
33
+
34
+ const result = await c.var.services.posts.reindexBodyText({
35
+ limit: body.limit,
36
+ cursor: body.cursor,
37
+ });
38
+
39
+ return c.json(result);
40
+ });
@@ -33,6 +33,7 @@ const CreateManagedSiteSchema = z.object({
33
33
  siteName: z.string().trim().min(1).max(120),
34
34
  siteLanguage: z.string().trim().max(35).optional(),
35
35
  timeZone: z.string().trim().max(100).optional(),
36
+ idempotencyKey: z.string().trim().min(1).max(128).optional(),
36
37
  });
37
38
 
38
39
  const ManagedSiteDomainSchema = z.object({
@@ -7,11 +7,24 @@ import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../types/app-context.js";
8
8
  import { ValidationError, ExternalServiceError } from "../../lib/errors.js";
9
9
  import { toSearchApiResult } from "../../lib/api-search.js";
10
+ import { rateLimit } from "../../middleware/rate-limit.js";
10
11
 
11
12
  type Env = { Bindings: Bindings; Variables: AppVariables };
12
13
 
13
14
  export const searchApiRoutes = new Hono<Env>();
14
15
 
16
+ // Per-IP rate limit. The request-time wrapper is needed because the
17
+ // per-minute cap is pulled from `appConfig` which is only available on
18
+ // `c.var`; constructing the middleware once at module load would capture
19
+ // an undefined value.
20
+ searchApiRoutes.use("*", async (c, next) =>
21
+ rateLimit({
22
+ name: "search",
23
+ limit: c.var.appConfig.rateLimit.searchPerMinute,
24
+ windowSec: 60,
25
+ })(c, next),
26
+ );
27
+
15
28
  // Search posts
16
29
  searchApiRoutes.get("/", async (c) => {
17
30
  const query = c.req.query("q");
@@ -65,7 +65,7 @@ devAuthRoutes.get("/__dev/login", async (c) => {
65
65
  });
66
66
  } catch {
67
67
  return c.text(
68
- "Dev login failed. Finish /setup once or run `mise run db-local-rebuild-demo`, then retry.",
68
+ "Dev login failed. Finish /setup once or run `mise run db-wrangler-rebuild-demo`, then retry.",
69
69
  500,
70
70
  );
71
71
  }
@@ -14,7 +14,7 @@ import { SigninSchema } from "../../lib/schemas.js";
14
14
  import { buildPageTitle } from "../../lib/page-title.js";
15
15
  import { getI18n } from "../../i18n/index.js";
16
16
  import { getHostedControlPlaneSigninUrl } from "../../lib/hosted-signin.js";
17
- import { toPublicPath } from "../../lib/url.js";
17
+ import { isSafeInternalRedirect, toPublicPath } from "../../lib/url.js";
18
18
 
19
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
20
20
 
@@ -22,11 +22,13 @@ const SigninContent: FC<{
22
22
  demoEmail?: string;
23
23
  demoPassword?: string;
24
24
  sitePathPrefix?: string;
25
- }> = ({ demoEmail, demoPassword, sitePathPrefix = "" }) => {
25
+ redirect?: string;
26
+ }> = ({ demoEmail, demoPassword, sitePathPrefix = "", redirect }) => {
26
27
  const { i18n } = useLingui();
27
28
  const signals = JSON.stringify({
28
29
  email: demoEmail || "",
29
30
  password: demoPassword || "",
31
+ ...(redirect ? { redirect } : {}),
30
32
  }).replace(/</g, "\\u003c");
31
33
 
32
34
  return (
@@ -121,9 +123,15 @@ const SigninContent: FC<{
121
123
  export const signinRoutes = new Hono<Env>();
122
124
 
123
125
  signinRoutes.get("/signin", async (c) => {
126
+ const rawRedirect = c.req.query("redirect");
127
+ const redirect = isSafeInternalRedirect(rawRedirect)
128
+ ? rawRedirect
129
+ : undefined;
130
+
124
131
  const hostedSigninUrl = getHostedControlPlaneSigninUrl(
125
132
  c.env,
126
133
  c.var.publicRequestUrl,
134
+ redirect,
127
135
  );
128
136
  if (hostedSigninUrl) {
129
137
  return c.redirect(hostedSigninUrl);
@@ -157,6 +165,7 @@ signinRoutes.get("/signin", async (c) => {
157
165
  demoEmail={c.var.appConfig.demoEmail}
158
166
  demoPassword={c.var.appConfig.demoPassword}
159
167
  sitePathPrefix={c.var.appConfig.sitePathPrefix}
168
+ redirect={redirect}
160
169
  />
161
170
  </BaseLayout>,
162
171
  );
@@ -195,6 +204,14 @@ signinRoutes.post("/signin", async (c) => {
195
204
  }
196
205
 
197
206
  const { email, password } = parsed.data;
207
+ const rawRedirect =
208
+ body && typeof body === "object" && "redirect" in body
209
+ ? (body as { redirect?: unknown }).redirect
210
+ : undefined;
211
+ const redirectTarget =
212
+ typeof rawRedirect === "string" && isSafeInternalRedirect(rawRedirect)
213
+ ? rawRedirect
214
+ : "/";
198
215
 
199
216
  try {
200
217
  const { headers } = await c.var.auth.api.signInEmail({
@@ -203,9 +220,10 @@ signinRoutes.post("/signin", async (c) => {
203
220
  headers: c.req.raw.headers,
204
221
  });
205
222
 
206
- return dsRedirect(toPublicPath("/", c.var.appConfig.sitePathPrefix), {
207
- headers,
208
- });
223
+ return dsRedirect(
224
+ toPublicPath(redirectTarget, c.var.appConfig.sitePathPrefix),
225
+ { headers },
226
+ );
209
227
  } catch {
210
228
  return dsToast(
211
229
  i18n._(
@@ -1016,11 +1016,9 @@ settingsRoutes.get("/account/sessions", async (c) => {
1016
1016
 
1017
1017
  const navData = await getNavigationData(c);
1018
1018
 
1019
- // Get current session to mark it
1020
- const currentSession = await c.var.auth.api.getSession({
1021
- headers: c.req.raw.headers,
1022
- });
1023
- const currentToken = currentSession?.session?.token ?? "";
1019
+ // Session was pre-fetched by `attachSession`; this route is behind
1020
+ // `requireAuth`, so it's guaranteed to be present here.
1021
+ const currentToken = c.var.session?.session?.token ?? "";
1024
1022
 
1025
1023
  // List all active sessions
1026
1024
  const rawSessions = await c.var.auth.api.listSessions({