@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/assets/auth.sqlite.schema.ts +479 -0
  4. package/assets/bun-sqlite.test.ts +46 -0
  5. package/assets/d1.test.ts +41 -0
  6. package/assets/drizzle.config.ts +2 -0
  7. package/assets/generate-auth-schema.mjs +25 -0
  8. package/assets/platform.sqlite.schema.ts +46 -0
  9. package/assets/polar-checkout.ts +10 -0
  10. package/assets/polar-route.ts +19 -0
  11. package/assets/polar.test.ts +30 -0
  12. package/assets/polar.ts +12 -0
  13. package/assets/purchase.bun-sqlite.ts +38 -0
  14. package/assets/purchase.sqlite.ts +39 -0
  15. package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
  16. package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
  17. package/assets/sqlite-migrations/meta/_journal.json +13 -0
  18. package/assets/turso.test.ts +40 -0
  19. package/package.json +44 -5
  20. package/src/adapters/database.mjs +448 -0
  21. package/src/adapters/deployment.mjs +101 -0
  22. package/src/adapters/email.mjs +75 -0
  23. package/src/adapters/payments.mjs +270 -0
  24. package/src/catalog.mjs +128 -0
  25. package/src/cli.mjs +294 -0
  26. package/src/generate.mjs +183 -0
  27. package/src/template.mjs +93 -0
  28. package/template/.env.docker +16 -0
  29. package/template/.env.example +67 -0
  30. package/template/DESIGN.md +58 -0
  31. package/template/LICENSE +21 -0
  32. package/template/biome.json +46 -0
  33. package/template/docker-compose.yml +47 -0
  34. package/template/docs/project-guide.md +61 -0
  35. package/template/drizzle/0000_create-app-role.sql +54 -0
  36. package/template/drizzle/0001_add-rls-context.sql +21 -0
  37. package/template/drizzle/0002_platform_tables.sql +320 -0
  38. package/template/drizzle/meta/0002_snapshot.json +2125 -0
  39. package/template/drizzle/meta/_journal.json +27 -0
  40. package/template/drizzle.config.ts +25 -0
  41. package/template/e2e/authenticated-ui.spec.ts +40 -0
  42. package/template/e2e/headless-design.spec.ts +66 -0
  43. package/template/e2e/navigation-loading.spec.ts +70 -0
  44. package/template/e2e/theme-responsive.spec.ts +260 -0
  45. package/template/gitignore +24 -0
  46. package/template/messages/de.json +212 -0
  47. package/template/messages/en.json +212 -0
  48. package/template/package.json +101 -0
  49. package/template/paraglide.config.ts +49 -0
  50. package/template/playwright.config.ts +58 -0
  51. package/template/project.inlang/settings.json +12 -0
  52. package/template/public/drizzle.svg +1 -0
  53. package/template/public/favicon.ico +0 -0
  54. package/template/public/logo192.png +0 -0
  55. package/template/public/logo512.png +0 -0
  56. package/template/public/manifest.json +25 -0
  57. package/template/public/robots.txt +3 -0
  58. package/template/scripts/check-client-bundle.ts +150 -0
  59. package/template/scripts/check-server-fns.ts +290 -0
  60. package/template/scripts/check-sql-raw.ts +50 -0
  61. package/template/scripts/compile-paraglide.ts +12 -0
  62. package/template/scripts/postgres/README.md +76 -0
  63. package/template/scripts/postgres/create-app-role.sh +60 -0
  64. package/template/scripts/seed-local.ts +331 -0
  65. package/template/scripts/validate-prod-env.ts +53 -0
  66. package/template/src/__tests__/README.md +9 -0
  67. package/template/src/__tests__/account-security.test.ts +664 -0
  68. package/template/src/__tests__/admin-http-surface.test.ts +311 -0
  69. package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
  70. package/template/src/__tests__/auth-harness.ts +104 -0
  71. package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
  72. package/template/src/__tests__/auth-redirect.test.ts +120 -0
  73. package/template/src/__tests__/body-limit.test.ts +47 -0
  74. package/template/src/__tests__/creem.test.ts +62 -0
  75. package/template/src/__tests__/demo-checkout.test.ts +141 -0
  76. package/template/src/__tests__/email-locale.test.ts +110 -0
  77. package/template/src/__tests__/global-setup.ts +80 -0
  78. package/template/src/__tests__/input-rules.test.ts +0 -0
  79. package/template/src/__tests__/message-parity.test.ts +38 -0
  80. package/template/src/__tests__/navigation-loading.test.tsx +252 -0
  81. package/template/src/__tests__/nowpayments.test.ts +106 -0
  82. package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
  83. package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
  84. package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
  85. package/template/src/__tests__/rls-policy.test.ts +120 -0
  86. package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
  87. package/template/src/__tests__/search-params-oauth.test.ts +57 -0
  88. package/template/src/__tests__/security-headers.test.ts +12 -0
  89. package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
  90. package/template/src/__tests__/stripe.test.ts +84 -0
  91. package/template/src/__tests__/test-db.ts +32 -0
  92. package/template/src/__tests__/test-rls-db.ts +23 -0
  93. package/template/src/__tests__/user-update-guard.test.ts +109 -0
  94. package/template/src/components/LocaleSwitcher.tsx +79 -0
  95. package/template/src/components/ThemeToggle.tsx +47 -0
  96. package/template/src/components/auth/social.tsx +90 -0
  97. package/template/src/components/auth/turnstile.tsx +67 -0
  98. package/template/src/components/root/navigation-progress.tsx +32 -0
  99. package/template/src/components/root/not-found.tsx +20 -0
  100. package/template/src/components/root/pending.tsx +9 -0
  101. package/template/src/components/root/root-document.tsx +37 -0
  102. package/template/src/components/root/root-error.tsx +34 -0
  103. package/template/src/components/shells.tsx +201 -0
  104. package/template/src/components/ui/styles.ts +9 -0
  105. package/template/src/db/auth.schema.ts +455 -0
  106. package/template/src/db/helper.ts +38 -0
  107. package/template/src/db/index.ts +15 -0
  108. package/template/src/db/platform.schema.ts +122 -0
  109. package/template/src/db/schema.ts +2 -0
  110. package/template/src/env.ts +65 -0
  111. package/template/src/hooks/use-theme.ts +32 -0
  112. package/template/src/integrations/better-auth/header-user.tsx +36 -0
  113. package/template/src/integrations/better-auth/session-query.ts +11 -0
  114. package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
  115. package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
  116. package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
  117. package/template/src/integrations/react-email/theme.ts +75 -0
  118. package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
  119. package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
  120. package/template/src/lib/auth-client.ts +27 -0
  121. package/template/src/lib/auth-paths.ts +94 -0
  122. package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
  123. package/template/src/lib/auth-plugins/field-rules.ts +84 -0
  124. package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
  125. package/template/src/lib/auth-timing.ts +9 -0
  126. package/template/src/lib/auth.ts +279 -0
  127. package/template/src/lib/captcha.ts +11 -0
  128. package/template/src/lib/email.tsx +162 -0
  129. package/template/src/lib/github.ts +18 -0
  130. package/template/src/lib/locale-cookie.ts +12 -0
  131. package/template/src/lib/localized-pages.ts +25 -0
  132. package/template/src/lib/oauth-metadata.ts +19 -0
  133. package/template/src/lib/permissions.ts +29 -0
  134. package/template/src/lib/security-headers.ts +20 -0
  135. package/template/src/lib/site.ts +59 -0
  136. package/template/src/lib/theme.ts +42 -0
  137. package/template/src/lib/user-update-guard.ts +35 -0
  138. package/template/src/middlewares/admin.ts +46 -0
  139. package/template/src/middlewares/auth-rate-limit.ts +37 -0
  140. package/template/src/middlewares/better-auth.ts +15 -0
  141. package/template/src/middlewares/body-limit.ts +62 -0
  142. package/template/src/middlewares/database.ts +13 -0
  143. package/template/src/middlewares/protected.ts +45 -0
  144. package/template/src/routeTree.gen.ts +536 -0
  145. package/template/src/router.tsx +53 -0
  146. package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
  147. package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
  148. package/template/src/routes/__root.tsx +175 -0
  149. package/template/src/routes/admin/audit.tsx +74 -0
  150. package/template/src/routes/admin/index.tsx +7 -0
  151. package/template/src/routes/admin/oauth-apps.tsx +164 -0
  152. package/template/src/routes/admin/route.tsx +27 -0
  153. package/template/src/routes/admin/users.tsx +215 -0
  154. package/template/src/routes/api/auth/$.ts +14 -0
  155. package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
  156. package/template/src/routes/api/webhooks/creem.ts +69 -0
  157. package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
  158. package/template/src/routes/api/webhooks/stripe.ts +74 -0
  159. package/template/src/routes/dashboard/account.tsx +430 -0
  160. package/template/src/routes/dashboard/index.tsx +128 -0
  161. package/template/src/routes/dashboard/route.tsx +17 -0
  162. package/template/src/routes/forgot-password.tsx +65 -0
  163. package/template/src/routes/index.tsx +85 -0
  164. package/template/src/routes/oauth/consent.tsx +77 -0
  165. package/template/src/routes/reset-password.tsx +78 -0
  166. package/template/src/routes/sign-in.tsx +174 -0
  167. package/template/src/routes/sign-up.tsx +135 -0
  168. package/template/src/routes/two-factor.tsx +74 -0
  169. package/template/src/server/README.md +3 -0
  170. package/template/src/server/account-security.ts +163 -0
  171. package/template/src/server/admin-audit.ts +37 -0
  172. package/template/src/server/admin-users.ts +112 -0
  173. package/template/src/server/auth.ts +44 -0
  174. package/template/src/server/demo-checkout.ts +67 -0
  175. package/template/src/server/oauth-clients.ts +83 -0
  176. package/template/src/server/payment-toggles.ts +39 -0
  177. package/template/src/server.ts +20 -0
  178. package/template/src/services/README.md +3 -0
  179. package/template/src/services/account-security.ts +893 -0
  180. package/template/src/services/admin-security.ts +164 -0
  181. package/template/src/services/admin-users.ts +181 -0
  182. package/template/src/services/auth-rate-limit.ts +32 -0
  183. package/template/src/services/creem.ts +83 -0
  184. package/template/src/services/demo-checkout.ts +100 -0
  185. package/template/src/services/nowpayments.ts +154 -0
  186. package/template/src/services/oauth-client.ts +85 -0
  187. package/template/src/services/oauth-clients.ts +15 -0
  188. package/template/src/services/payment-providers.ts +157 -0
  189. package/template/src/services/payment-toggles.ts +48 -0
  190. package/template/src/services/purchase.ts +233 -0
  191. package/template/src/services/stripe.ts +106 -0
  192. package/template/src/start.ts +35 -0
  193. package/template/src/styles/layout.css +93 -0
  194. package/template/src/styles/theme.css +84 -0
  195. package/template/src/styles/ui.css +233 -0
  196. package/template/src/styles.css +90 -0
  197. package/template/src/utils/api-error.ts +129 -0
  198. package/template/src/utils/auth-errors.ts +157 -0
  199. package/template/src/utils/auth-redirect.ts +61 -0
  200. package/template/src/utils/body-limit.ts +84 -0
  201. package/template/src/utils/cn.ts +6 -0
  202. package/template/src/utils/input-rules.ts +172 -0
  203. package/template/src/utils/locale-paths.ts +18 -0
  204. package/template/src/utils/same-page-locale-href.ts +20 -0
  205. package/template/src/utils/search-params.ts +71 -0
  206. package/template/tsconfig.json +30 -0
  207. package/template/tsr.config.json +6 -0
  208. package/template/vite.config.ts +24 -0
  209. package/template/vitest.config.ts +26 -0
  210. package/template/worker-configuration.d.ts +14660 -0
  211. package/template/wrangler.jsonc +39 -0
@@ -0,0 +1,135 @@
1
+ import { createFileRoute, Link, redirect } from "@tanstack/react-router";
2
+ import { useRef, useState } from "react";
3
+ import { AuthDivider, GithubButton } from "@/components/auth/social";
4
+ import { Turnstile, type TurnstileHandle } from "@/components/auth/turnstile";
5
+ import {
6
+ AuthShell,
7
+ buttonClass,
8
+ Field,
9
+ inputClass,
10
+ linkClass,
11
+ mutedTextClass,
12
+ PageAlert,
13
+ } from "@/components/shells";
14
+ import { authClient } from "@/lib/auth-client";
15
+ import { CAPTCHA_ENABLED } from "@/lib/captcha";
16
+ import { GITHUB_OAUTH_ENABLED } from "@/lib/github";
17
+ import { NOINDEX_META, pageTitle } from "@/lib/site";
18
+ import * as m from "@/paraglide/messages";
19
+ import { afterAuthTarget } from "@/utils/auth-redirect";
20
+
21
+ export const Route = createFileRoute("/sign-up")({
22
+ validateSearch: (search: Record<string, unknown>) => search,
23
+ beforeLoad: ({ context: { session } }) => {
24
+ if (session?.user) {
25
+ throw redirect({ to: "/dashboard" });
26
+ }
27
+ },
28
+ head: () => ({
29
+ meta: [...NOINDEX_META, { title: pageTitle(m.auth_sign_up_title()) }],
30
+ }),
31
+ component: SignUpPage,
32
+ });
33
+
34
+ function SignUpPage() {
35
+ const [name, setName] = useState("");
36
+ const [email, setEmail] = useState("");
37
+ const [password, setPassword] = useState("");
38
+ const [error, setError] = useState<string | null>(null);
39
+ const [info, setInfo] = useState<string | null>(null);
40
+ const [pending, setPending] = useState(false);
41
+ const [captchaToken, setCaptchaToken] = useState<string | null>(null);
42
+ const turnstile = useRef<TurnstileHandle>(null);
43
+
44
+ async function onSubmit(e: React.FormEvent) {
45
+ e.preventDefault();
46
+ setPending(true);
47
+ setError(null);
48
+ setInfo(null);
49
+ if (CAPTCHA_ENABLED && !captchaToken) {
50
+ setError(m.auth_captcha_required());
51
+ setPending(false);
52
+ return;
53
+ }
54
+ const { error: err } = await authClient.signUp.email({
55
+ email,
56
+ password,
57
+ name,
58
+ callbackURL: afterAuthTarget(),
59
+ fetchOptions: {
60
+ headers: { "x-captcha-response": captchaToken ?? "" },
61
+ },
62
+ });
63
+ turnstile.current?.reset();
64
+ setPending(false);
65
+ if (err) {
66
+ setError(err.message ?? m.auth_sign_up_failed());
67
+ return;
68
+ }
69
+ setInfo(m.auth_check_email());
70
+ }
71
+
72
+ return (
73
+ <AuthShell title={m.auth_sign_up_title()}>
74
+ <form onSubmit={onSubmit}>
75
+ <Field label={m.field_display_name()}>
76
+ <input
77
+ className={inputClass}
78
+ type="text"
79
+ autoComplete="name"
80
+ required
81
+ value={name}
82
+ onChange={(ev) => setName(ev.target.value)}
83
+ />
84
+ </Field>
85
+ <Field label={m.field_email()}>
86
+ <input
87
+ className={inputClass}
88
+ type="email"
89
+ autoComplete="email"
90
+ required
91
+ value={email}
92
+ onChange={(ev) => setEmail(ev.target.value)}
93
+ />
94
+ </Field>
95
+ <Field label={m.field_password()} hint={m.auth_password_hint()}>
96
+ <input
97
+ className={inputClass}
98
+ type="password"
99
+ autoComplete="new-password"
100
+ required
101
+ minLength={8}
102
+ value={password}
103
+ onChange={(ev) => setPassword(ev.target.value)}
104
+ />
105
+ </Field>
106
+ <Turnstile ref={turnstile} onToken={setCaptchaToken} />
107
+ {error ? <PageAlert>{error}</PageAlert> : null}
108
+ {info ? <PageAlert tone="success">{info}</PageAlert> : null}
109
+ <button
110
+ type="submit"
111
+ className={buttonClass}
112
+ disabled={pending || (CAPTCHA_ENABLED && !captchaToken)}
113
+ >
114
+ {pending ? m.auth_creating() : m.nav_sign_up()}
115
+ </button>
116
+ </form>
117
+
118
+ {GITHUB_OAUTH_ENABLED ? (
119
+ <>
120
+ <AuthDivider />
121
+ <div className="mt-4">
122
+ <GithubButton label={m.auth_signup_github()} />
123
+ </div>
124
+ </>
125
+ ) : null}
126
+
127
+ <p className={`mt-4 text-center ${mutedTextClass}`}>
128
+ {m.auth_have_account()}{" "}
129
+ <Link to="/sign-in" className={linkClass}>
130
+ {m.nav_sign_in()}
131
+ </Link>
132
+ </p>
133
+ </AuthShell>
134
+ );
135
+ }
@@ -0,0 +1,74 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { useState } from "react";
3
+ import {
4
+ AuthShell,
5
+ buttonClass,
6
+ Field,
7
+ inputClass,
8
+ mutedTextClass,
9
+ PageAlert,
10
+ } from "@/components/shells";
11
+ import { authClient } from "@/lib/auth-client";
12
+ import { NOINDEX_META, pageTitle } from "@/lib/site";
13
+ import * as m from "@/paraglide/messages";
14
+ import { afterAuthTarget } from "@/utils/auth-redirect";
15
+
16
+ export const Route = createFileRoute("/two-factor")({
17
+ validateSearch: (search: Record<string, unknown>) => search,
18
+ head: () => ({
19
+ meta: [...NOINDEX_META, { title: pageTitle(m.auth_two_factor_title()) }],
20
+ }),
21
+ component: TwoFactorPage,
22
+ });
23
+
24
+ function TwoFactorPage() {
25
+ const [code, setCode] = useState("");
26
+ const [error, setError] = useState<string | null>(null);
27
+ const [pending, setPending] = useState(false);
28
+
29
+ async function onSubmit(e: React.FormEvent) {
30
+ e.preventDefault();
31
+ setPending(true);
32
+ setError(null);
33
+ const { error: err } = await authClient.twoFactor.verifyTotp({
34
+ code,
35
+ });
36
+ if (err) {
37
+ // Try backup code if TOTP fails.
38
+ const backup = await authClient.twoFactor.verifyBackupCode({
39
+ code,
40
+ });
41
+ setPending(false);
42
+ if (backup.error) {
43
+ setError(err.message ?? m.auth_invalid_code());
44
+ return;
45
+ }
46
+ } else {
47
+ setPending(false);
48
+ }
49
+ window.location.href = afterAuthTarget();
50
+ }
51
+
52
+ return (
53
+ <AuthShell title={m.auth_two_factor_title()}>
54
+ <p className={`mb-4 ${mutedTextClass}`}>{m.auth_two_factor_blurb()}</p>
55
+ <form onSubmit={onSubmit}>
56
+ <Field label={m.field_code()}>
57
+ <input
58
+ className={inputClass}
59
+ type="text"
60
+ inputMode="numeric"
61
+ autoComplete="one-time-code"
62
+ required
63
+ value={code}
64
+ onChange={(ev) => setCode(ev.target.value)}
65
+ />
66
+ </Field>
67
+ {error ? <PageAlert>{error}</PageAlert> : null}
68
+ <button type="submit" className={buttonClass} disabled={pending}>
69
+ {pending ? m.auth_verifying() : m.auth_verify()}
70
+ </button>
71
+ </form>
72
+ </AuthShell>
73
+ );
74
+ }
@@ -0,0 +1,3 @@
1
+ # Server functions
2
+
3
+ The single home for TanStack Start server functions (`createServerFn`). Define them here and import into routes/components; keep the actual business logic in `src/services/` and call it from these thin server entrypoints.
@@ -0,0 +1,163 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { getRequestHeaders } from "@tanstack/react-start/server";
3
+ import { z } from "zod";
4
+ import { betterAuthMiddleware } from "@/middlewares/better-auth";
5
+ import {
6
+ changePasswordWithOtp,
7
+ checkPasswordChangeOtp,
8
+ disableTwoFactorWithCode,
9
+ enableTwoFactorWithOtp,
10
+ reauthenticate,
11
+ regenerateBackupCodesWithCode,
12
+ removePasskeyWithFreshSession,
13
+ requestPasswordChangeOtp,
14
+ requestReauthOtp,
15
+ } from "@/services/account-security";
16
+ import { validated } from "@/utils/api-error";
17
+ import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH } from "@/utils/input-rules";
18
+
19
+ // Security-page server functions. betterAuthMiddleware only — auth.api runs on
20
+ // the raw connection; an open RLS transaction would buy nothing.
21
+ //
22
+ // One read function per page, not one per panel. A server function is its own
23
+ // HTTP request and has to establish the caller itself, so splitting the account
24
+ // page's panels across two of them bought two session reads for one render.
25
+
26
+ export const getSecurityOverview = createServerFn({ method: "GET" })
27
+ .middleware([betterAuthMiddleware])
28
+ .handler(async ({ context }) => {
29
+ const headers = getRequestHeaders();
30
+ const session = await context.auth.api.getSession({ headers });
31
+ if (!session?.user) return null;
32
+ const [accounts, passkeys, sessions] = await Promise.all([
33
+ context.auth.api.listUserAccounts({ headers }),
34
+ context.auth.api.listPasskeys({ headers }),
35
+ context.auth.api.listSessions({ headers }),
36
+ ]);
37
+ const u = session.user as typeof session.user & {
38
+ twoFactorEnabled?: boolean | null;
39
+ locale?: string | null;
40
+ };
41
+ return {
42
+ email: session.user.email,
43
+ name: session.user.name ?? null,
44
+ locale: u.locale ?? null,
45
+ emailVerified: Boolean(session.user.emailVerified),
46
+ hasPassword: accounts.some((a) => a.providerId === "credential"),
47
+ githubLinked: accounts.some((a) => a.providerId === "github"),
48
+ twoFactorEnabled: Boolean(u.twoFactorEnabled),
49
+ sessions: sessions.map((s) => ({
50
+ id: s.id,
51
+ token: s.token,
52
+ createdAt: new Date(s.createdAt).toISOString(),
53
+ expiresAt: new Date(s.expiresAt).toISOString(),
54
+ ipAddress: s.ipAddress ?? null,
55
+ userAgent: s.userAgent ?? null,
56
+ isCurrent: s.token === session.session.token,
57
+ })),
58
+ passkeys: passkeys.map((p) => ({
59
+ id: p.id,
60
+ name: p.name ?? null,
61
+ createdAt: p.createdAt ? new Date(p.createdAt).toISOString() : null,
62
+ })),
63
+ };
64
+ });
65
+
66
+ // `revokeOtherSessions` keeps its own authoritative read: a privileged
67
+ // mutation must not trust the cookie cache, which may still be serving a
68
+ // session that was revoked seconds ago.
69
+ export const revokeOtherSessions = createServerFn({ method: "POST" })
70
+ .middleware([betterAuthMiddleware])
71
+ .handler(async ({ context }) => {
72
+ const headers = getRequestHeaders();
73
+ const session = await context.auth.api.getSession({
74
+ headers,
75
+ query: { disableCookieCache: true },
76
+ });
77
+ if (!session?.user) return { ok: false as const, code: "unauthorized" };
78
+ await context.auth.api.revokeOtherSessions({ headers });
79
+ return { ok: true as const };
80
+ });
81
+
82
+ export const requestPasswordOtp = createServerFn({ method: "POST" })
83
+ .middleware([betterAuthMiddleware])
84
+ .handler(({ context }) =>
85
+ requestPasswordChangeOtp(context.auth, getRequestHeaders()),
86
+ );
87
+
88
+ export const verifyPasswordOtp = createServerFn({ method: "POST" })
89
+ .middleware([betterAuthMiddleware])
90
+ .validator(validated(z.object({ otp: z.string().trim().min(1).max(16) })))
91
+ .handler(({ context, data }) =>
92
+ checkPasswordChangeOtp(context.auth, getRequestHeaders(), data),
93
+ );
94
+
95
+ export const confirmPasswordChange = createServerFn({ method: "POST" })
96
+ .middleware([betterAuthMiddleware])
97
+ .validator(
98
+ validated(
99
+ z.object({
100
+ otp: z.string().trim().min(1).max(16),
101
+ newPassword: z
102
+ .string()
103
+ .min(MIN_PASSWORD_LENGTH)
104
+ .max(MAX_PASSWORD_LENGTH),
105
+ }),
106
+ ),
107
+ )
108
+ .handler(({ context, data }) =>
109
+ changePasswordWithOtp(context.auth, getRequestHeaders(), data),
110
+ );
111
+
112
+ export const requestReauthCode = createServerFn({ method: "POST" })
113
+ .middleware([betterAuthMiddleware])
114
+ .handler(({ context }) =>
115
+ requestReauthOtp(context.auth, getRequestHeaders()),
116
+ );
117
+
118
+ export const confirmReauth = createServerFn({ method: "POST" })
119
+ .middleware([betterAuthMiddleware])
120
+ .validator(
121
+ validated(
122
+ z.object({
123
+ code: z.string().trim().min(1).max(32),
124
+ backupCode: z.boolean().optional(),
125
+ }),
126
+ ),
127
+ )
128
+ .handler(({ context, data }) =>
129
+ reauthenticate(context.auth, getRequestHeaders(), data),
130
+ );
131
+
132
+ export const beginTwoFactorEnable = createServerFn({ method: "POST" })
133
+ .middleware([betterAuthMiddleware])
134
+ .validator(validated(z.object({ otp: z.string().trim().min(1).max(16) })))
135
+ .handler(({ context, data }) =>
136
+ enableTwoFactorWithOtp(context.auth, getRequestHeaders(), data),
137
+ );
138
+
139
+ const secondFactorInput = z.object({
140
+ code: z.string().trim().min(1).max(32),
141
+ backupCode: z.boolean().optional(),
142
+ });
143
+
144
+ export const disableTwoFactor = createServerFn({ method: "POST" })
145
+ .middleware([betterAuthMiddleware])
146
+ .validator(validated(secondFactorInput))
147
+ .handler(({ context, data }) =>
148
+ disableTwoFactorWithCode(context.auth, getRequestHeaders(), data),
149
+ );
150
+
151
+ export const regenerateBackupCodes = createServerFn({ method: "POST" })
152
+ .middleware([betterAuthMiddleware])
153
+ .validator(validated(secondFactorInput))
154
+ .handler(({ context, data }) =>
155
+ regenerateBackupCodesWithCode(context.auth, getRequestHeaders(), data),
156
+ );
157
+
158
+ export const removePasskey = createServerFn({ method: "POST" })
159
+ .middleware([betterAuthMiddleware])
160
+ .validator(validated(z.object({ passkeyId: z.string().trim().min(1) })))
161
+ .handler(({ context, data }) =>
162
+ removePasskeyWithFreshSession(context.auth, getRequestHeaders(), data),
163
+ );
@@ -0,0 +1,37 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { desc } from "drizzle-orm";
3
+ import { z } from "zod";
4
+ import { adminAction } from "@/db/schema";
5
+ import { adminMiddleware } from "@/middlewares/admin";
6
+ import { databaseMiddleware } from "@/middlewares/database";
7
+ import { validated } from "@/utils/api-error";
8
+
9
+ export const listAuditEvents = createServerFn({ method: "GET" })
10
+ .middleware([databaseMiddleware, adminMiddleware])
11
+ .validator(
12
+ validated(
13
+ z.object({
14
+ limit: z.number().int().min(1).max(200).default(50),
15
+ }),
16
+ ),
17
+ )
18
+ .handler(async ({ context, data }) => {
19
+ const rows = await context.db
20
+ .select({
21
+ id: adminAction.id,
22
+ actorId: adminAction.actorId,
23
+ action: adminAction.action,
24
+ targetType: adminAction.targetType,
25
+ targetId: adminAction.targetId,
26
+ detail: adminAction.detail,
27
+ createdAt: adminAction.createdAt,
28
+ })
29
+ .from(adminAction)
30
+ .orderBy(desc(adminAction.createdAt))
31
+ .limit(data.limit);
32
+
33
+ return rows.map((r) => ({
34
+ ...r,
35
+ createdAt: r.createdAt.toISOString(),
36
+ }));
37
+ });
@@ -0,0 +1,112 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { getRequestHeaders } from "@tanstack/react-start/server";
3
+ import { eq } from "drizzle-orm";
4
+ import { z } from "zod";
5
+ import { user } from "@/db/schema";
6
+ import { adminMiddleware } from "@/middlewares/admin";
7
+ import { databaseMiddleware } from "@/middlewares/database";
8
+ import {
9
+ assertAdminMayActOn,
10
+ assertNotLastAdmin,
11
+ isAdminRole,
12
+ logAdminAction,
13
+ revokeUserCredentials,
14
+ } from "@/services/admin-security";
15
+ import { applyAdminUserUpdate, listAdminUsers } from "@/services/admin-users";
16
+ import { HttpError, validated } from "@/utils/api-error";
17
+
18
+ const listSchema = z.object({
19
+ page: z.number().int().min(0).default(0),
20
+ pageSize: z.number().int().min(1).max(100).default(20),
21
+ search: z.string().default(""),
22
+ sort: z.object({ id: z.string(), desc: z.boolean().optional() }).optional(),
23
+ filters: z
24
+ .object({
25
+ role: z.string().optional(),
26
+ banned: z.string().optional(),
27
+ })
28
+ .default({}),
29
+ });
30
+
31
+ export const getUsers = createServerFn({ method: "GET" })
32
+ .middleware([databaseMiddleware, adminMiddleware])
33
+ .validator(validated(listSchema))
34
+ .handler(({ context, data }) => listAdminUsers(context.db, data));
35
+
36
+ export const updateUser = createServerFn({ method: "POST" })
37
+ .middleware([databaseMiddleware, adminMiddleware])
38
+ .validator(
39
+ validated(
40
+ z.object({
41
+ userId: z.string().min(1),
42
+ emailVerified: z.boolean(),
43
+ // Superadmin role is not assignable from UI (SQL bootstrap only).
44
+ role: z.enum(["user", "admin"]).optional(),
45
+ banned: z.boolean(),
46
+ }),
47
+ ),
48
+ )
49
+ .handler(async ({ context, data }) => {
50
+ const actorId = context.session.user.id;
51
+ const [current] = await context.db
52
+ .select({
53
+ role: user.role,
54
+ banned: user.banned,
55
+ emailVerified: user.emailVerified,
56
+ })
57
+ .from(user)
58
+ .where(eq(user.id, data.userId))
59
+ .limit(1);
60
+ if (!current) {
61
+ throw new HttpError({
62
+ status: 404,
63
+ error: "not_found",
64
+ message: "User not found.",
65
+ });
66
+ }
67
+
68
+ if (data.banned && !current.banned && actorId === data.userId) {
69
+ throw new HttpError({
70
+ status: 403,
71
+ error: "forbidden",
72
+ message: "You cannot ban your own account.",
73
+ });
74
+ }
75
+ assertAdminMayActOn({
76
+ actorId,
77
+ targetId: data.userId,
78
+ targetRole: current.role,
79
+ nextRole: data.role,
80
+ banning: data.banned && !current.banned,
81
+ });
82
+
83
+ const demotingAdmin =
84
+ data.role !== undefined &&
85
+ isAdminRole(current.role) &&
86
+ !isAdminRole(data.role);
87
+ const banningAdmin =
88
+ data.banned && !current.banned && isAdminRole(current.role);
89
+ if (demotingAdmin || banningAdmin) {
90
+ await assertNotLastAdmin(context.db, data.userId);
91
+ }
92
+
93
+ const headers = getRequestHeaders();
94
+ await applyAdminUserUpdate(
95
+ {
96
+ db: context.db,
97
+ auth: {
98
+ setRole: (args) => context.auth.api.setRole(args),
99
+ banUser: (args) => context.auth.api.banUser(args),
100
+ unbanUser: (args) => context.auth.api.unbanUser(args),
101
+ },
102
+ headers,
103
+ actorId,
104
+ log: (entry) => logAdminAction(context.db, entry),
105
+ revokeCredentials: (userId) =>
106
+ revokeUserCredentials(context.db, userId),
107
+ },
108
+ current,
109
+ data,
110
+ );
111
+ return { ok: true };
112
+ });
@@ -0,0 +1,44 @@
1
+ import { createServerFn } from "@tanstack/react-start";
2
+ import { getRequestHeaders } from "@tanstack/react-start/server";
3
+ import { betterAuthMiddleware } from "@/middlewares/better-auth";
4
+
5
+ export type RootSession = {
6
+ user: {
7
+ id: string;
8
+ email: string;
9
+ name: string;
10
+ emailVerified: boolean;
11
+ role: string | null;
12
+ twoFactorEnabled: boolean;
13
+ locale: string | null;
14
+ };
15
+ };
16
+
17
+ /**
18
+ * One session read per request for the root route.
19
+ * Child routes should prefer `context.session` when a full session is enough;
20
+ * privileged surfaces may still re-read with cookie-cache disabled.
21
+ */
22
+ export const getSession = createServerFn({ method: "GET" })
23
+ .middleware([betterAuthMiddleware])
24
+ .handler(async ({ context }): Promise<RootSession | null> => {
25
+ const headers = getRequestHeaders();
26
+ const session = await context.auth.api.getSession({ headers });
27
+ if (!session?.user) return null;
28
+ const u = session.user as typeof session.user & {
29
+ role?: string | null;
30
+ twoFactorEnabled?: boolean | null;
31
+ locale?: string | null;
32
+ };
33
+ return {
34
+ user: {
35
+ id: session.user.id,
36
+ email: session.user.email,
37
+ name: session.user.name,
38
+ emailVerified: Boolean(session.user.emailVerified),
39
+ role: u.role ?? null,
40
+ twoFactorEnabled: Boolean(u.twoFactorEnabled),
41
+ locale: u.locale ?? null,
42
+ },
43
+ };
44
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Demo checkout server fns. sessionMiddleware + explicit withRlsService:
3
+ * system_config and purchase writes need service GUC; provider HTTP must not
4
+ * run inside a held transaction (client-bundle: import providers in handler).
5
+ */
6
+ import { createServerFn } from "@tanstack/react-start";
7
+ import { z } from "zod";
8
+ import { withRlsService } from "@/db/helper";
9
+ import { sessionMiddleware } from "@/middlewares/protected";
10
+ import { startDemoCheckout } from "@/services/demo-checkout";
11
+ import { getPaymentToggles } from "@/services/payment-toggles";
12
+ import { createPendingPurchase } from "@/services/purchase";
13
+ import { validated } from "@/utils/api-error";
14
+
15
+ // sessionMiddleware → betterAuth → database (raw db, no user RLS tx).
16
+ // Purchase / system_config writes use withRlsService; provider HTTP stays outside.
17
+
18
+ export const getDemoCheckoutMethods = createServerFn({ method: "GET" })
19
+ .middleware([sessionMiddleware])
20
+ .handler(async ({ context }) => {
21
+ const { isPaymentProviderConfigured } = await import(
22
+ "@/services/payment-providers"
23
+ );
24
+ const toggles = await withRlsService(context.db, (tx) =>
25
+ getPaymentToggles(tx),
26
+ );
27
+ return {
28
+ stripe: toggles.stripe && isPaymentProviderConfigured("stripe"),
29
+ creem: toggles.creem && isPaymentProviderConfigured("creem"),
30
+ nowpayments:
31
+ toggles.nowpayments && isPaymentProviderConfigured("nowpayments"),
32
+ };
33
+ });
34
+
35
+ export const startDemoPurchase = createServerFn({ method: "POST" })
36
+ .middleware([sessionMiddleware])
37
+ .validator(
38
+ validated(
39
+ z.object({
40
+ provider: z.enum(["stripe", "creem", "nowpayments"]),
41
+ }),
42
+ ),
43
+ )
44
+ .handler(async ({ context, data }) => {
45
+ const { createHostedCheckoutSession, isPaymentProviderConfigured } =
46
+ await import("@/services/payment-providers");
47
+
48
+ // The order (gate → host session → pending row) lives in
49
+ // `startDemoCheckout` and is covered there. This handler only supplies
50
+ // the Worker-side edges: RLS-scoped reads and writes, and provider HTTP
51
+ // that must stay outside any held transaction.
52
+ return startDemoCheckout(
53
+ {
54
+ getToggles: () =>
55
+ withRlsService(context.db, (tx) => getPaymentToggles(tx)),
56
+ isConfigured: isPaymentProviderConfigured,
57
+ createHostedSession: createHostedCheckoutSession,
58
+ createPendingPurchase: (input) =>
59
+ withRlsService(context.db, (tx) => createPendingPurchase(tx, input)),
60
+ },
61
+ {
62
+ userId: context.session.user.id,
63
+ userEmail: context.session.user.email,
64
+ provider: data.provider,
65
+ },
66
+ );
67
+ });