Skip to content

Commit 9a45a93

Browse files
dev-xodavidmytton
andauthored
feat: add Arcjet Security (#368)
Co-authored-by: David Mytton <david@mytton.net>
1 parent b15bf01 commit 9a45a93

32 files changed

+3095
-3715
lines changed

.env.example

+4
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ STRIPE_WEBHOOK_ENDPOINT=""
2525
# Honeypot.
2626
# https://github.com/sergiodxa/remix-utils?tab=readme-ov-file#form-honeypot
2727
HONEYPOT_ENCRYPTION_SEED="AUTOMATICALLY_GENERATED_ON_TEMPLATE_INITIALIZATION"
28+
29+
# Arcjet Security (Optional) - Bot detection, spam signup detection, and attack prevention.
30+
# Get your free API key at https://arcjet.com and increase your app security.
31+
ARCJET_KEY=""

.eslintrc.cjs

+78-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,82 @@
11
/**
2-
* @type {import('eslint').Linter.Config}
2+
* ESLint configuration for the application.
3+
* Based on the Official Remix Starters.
34
*/
5+
6+
/** @type {import('eslint').Linter.Config} */
47
module.exports = {
5-
extends: ['@remix-run/eslint-config', '@remix-run/eslint-config/node', 'prettier'],
8+
root: true,
9+
parserOptions: {
10+
ecmaVersion: 'latest',
11+
sourceType: 'module',
12+
ecmaFeatures: {
13+
jsx: true,
14+
},
15+
},
16+
env: {
17+
browser: true,
18+
commonjs: true,
19+
es6: true,
20+
},
21+
ignorePatterns: ['!**/.server', '!**/.client', 'remix.init/*'],
22+
23+
// Base configuration.
24+
extends: ['eslint:recommended', 'prettier'],
25+
26+
overrides: [
27+
// React.
28+
{
29+
files: ['**/*.{js,jsx,ts,tsx}'],
30+
plugins: ['react'],
31+
extends: [
32+
'plugin:react/recommended',
33+
'plugin:react/jsx-runtime',
34+
'plugin:react-hooks/recommended',
35+
],
36+
settings: {
37+
react: {
38+
version: 'detect',
39+
},
40+
formComponents: ['Form'],
41+
linkComponents: [
42+
{ name: 'Link', linkAttribute: 'to' },
43+
{ name: 'NavLink', linkAttribute: 'to' },
44+
],
45+
'import/resolver': {
46+
typescript: {},
47+
},
48+
},
49+
},
50+
51+
// Typescript.
52+
{
53+
files: ['**/*.{ts,tsx}'],
54+
plugins: ['@typescript-eslint', 'import'],
55+
parser: '@typescript-eslint/parser',
56+
settings: {
57+
'import/internal-regex': '^~/',
58+
'import/resolver': {
59+
node: {
60+
extensions: ['.ts', '.tsx'],
61+
},
62+
typescript: {
63+
alwaysTryTypes: true,
64+
},
65+
},
66+
},
67+
extends: [
68+
'plugin:@typescript-eslint/recommended',
69+
'plugin:import/recommended',
70+
'plugin:import/typescript',
71+
],
72+
},
73+
74+
// Node.
75+
{
76+
files: ['.eslintrc.cjs'],
77+
env: {
78+
node: true,
79+
},
80+
},
81+
],
682
}

README.md

+11
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,14 @@ If you found **Remix SaaS** helpful, consider supporting it with a ⭐ [Star](ht
4242
## Acknowledgments
4343

4444
Special thanks to [@mw10013](https://github.com/mw10013) who has been part of the Remix SaaS development.
45+
46+
## Sponsors
47+
48+
Remix SaaS is proudly supported by [Arcjet](https://launch.arcjet.com/hdXzPbO).
49+
50+
<a href="https://launch.arcjet.com/hdXzPbO" target="_arcjet-home">
51+
<picture>
52+
<source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/logo/arcjet-dark-lockup-voyage-horizontal.svg">
53+
<img src="https://arcjet.com/logo/arcjet-light-lockup-voyage-horizontal.svg" alt="Arcjet Logo" height="128" width="auto">
54+
</picture>
55+
</a>

app/components/ui/dropdown-menu.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable */
12
import * as React from 'react'
23
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
34
import { Check, ChevronRight, Circle } from 'lucide-react'

app/components/ui/select.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable */
12
import * as React from 'react'
23
import * as SelectPrimitive from '@radix-ui/react-select'
34
import { Check, ChevronDown, ChevronUp } from 'lucide-react'

app/components/ui/switch.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable */
12
import * as React from 'react'
23
import * as SwitchPrimitives from '@radix-ui/react-switch'
34

app/entry.client.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ import { getInitialNamespaces } from 'remix-i18next/client'
88
import * as i18n from '#app/modules/i18n/i18n'
99

1010
async function main() {
11+
// eslint-disable-next-line import/no-named-as-default-member
1112
await i18next
12-
.use(initReactI18next) // Initialize `react-i18next`.
13-
.use(I18nextBrowserLanguageDetector) // Setup client-side language detector.
13+
// Initialize `react-i18next`.
14+
.use(initReactI18next)
15+
// Setup client-side language detector.
16+
.use(I18nextBrowserLanguageDetector)
1417
.init({
1518
...i18n,
1619
ns: getInitialNamespaces(),

app/modules/email/templates/auth-email.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ export function renderAuthEmailEmail(args: AuthEmailOptions) {
106106
* Senders.
107107
*/
108108
export async function sendAuthEmail({ email, code, magicLink }: AuthEmailOptions) {
109-
const html = renderAuthEmailEmail({ email, code, magicLink })
109+
const html = await renderAuthEmailEmail({ email, code, magicLink })
110110

111111
await sendEmail({
112112
to: email,

app/modules/email/templates/subscription-email.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export function SubscriptionErrorEmail({ email }: SubscriptionEmailOptions) {
7777
<Text style={{ fontSize: '16px', lineHeight: '26px' }}>
7878
We were unable to process your subscription to PRO tier.
7979
<br />
80-
But don't worry, we'll not charge you anything.
80+
But don&apos;t worry, we&apos;ll not charge you anything.
8181
</Text>
8282
<Text style={{ fontSize: '16px', lineHeight: '26px' }}>
8383
The <Link href="http://localhost:3000">domain-name.com</Link> team.
@@ -108,7 +108,7 @@ export async function sendSubscriptionSuccessEmail({
108108
email,
109109
subscriptionId,
110110
}: SubscriptionEmailOptions) {
111-
const html = renderSubscriptionSuccessEmail({ email, subscriptionId })
111+
const html = await renderSubscriptionSuccessEmail({ email, subscriptionId })
112112

113113
await sendEmail({
114114
to: email,
@@ -121,7 +121,7 @@ export async function sendSubscriptionErrorEmail({
121121
email,
122122
subscriptionId,
123123
}: SubscriptionEmailOptions) {
124-
const html = renderSubscriptionErrorEmail({ email, subscriptionId })
124+
const html = await renderSubscriptionErrorEmail({ email, subscriptionId })
125125

126126
await sendEmail({
127127
to: email,

app/root.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { MetaFunction, LinksFunction, LoaderFunctionArgs } from '@remix-run/node'
1+
import type {
2+
MetaFunction,
3+
LinksFunction,
4+
LoaderFunctionArgs,
5+
TypedResponse,
6+
} from '@remix-run/node'
27
import type { Theme } from '#app/utils/hooks/use-theme'
38
import {
49
Links,
@@ -46,6 +51,11 @@ export const links: LinksFunction = () => {
4651
return [{ rel: 'stylesheet', href: RootCSS }]
4752
}
4853

54+
export type LoaderData = Exclude<
55+
Awaited<ReturnType<typeof loader>>,
56+
Response | TypedResponse<unknown>
57+
>
58+
4959
export async function loader({ request }: LoaderFunctionArgs) {
5060
const sessionUser = await authenticator.isAuthenticated(request)
5161
const user = sessionUser?.id

app/routes/_home+/_index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ export default function Index() {
346346
<svg viewBox="0 0 259 84" className="h-9" fillRule="evenodd">
347347
<title id="title-F7R838wtvsn8DF6B"></title>
348348
<desc id="description-F7R838wtzc_8DF6R"></desc>
349-
<g buffered-rendering="static">
349+
<g>
350350
<path
351351
d="M57.413 10.134h9.454c8.409 0 15.236 6.827 15.236 15.236v33.243c0 8.409-6.827 15.236-15.236 15.236h-.745c-4.328-.677-6.205-1.975-7.655-3.072l-12.02-9.883a1.692 1.692 0 0 0-2.128 0l-3.905 3.211-10.998-9.043a1.688 1.688 0 0 0-2.127 0L12.01 68.503c-3.075 2.501-5.109 2.039-6.428 1.894C2.175 67.601 0 63.359 0 58.613V25.37c0-8.409 6.827-15.236 15.237-15.236h9.433l-.017.038-.318.927-.099.318-.428 1.899-.059.333-.188 1.902-.025.522-.004.183.018.872.043.511.106.8.135.72.16.663.208.718.54 1.52.178.456.94 1.986.332.61 1.087 1.866.416.673 1.517 2.234.219.296 1.974 2.569.638.791 2.254 2.635.463.507 1.858 1.999.736.762 1.216 1.208-.244.204-.152.137c-.413.385-.805.794-1.172 1.224a10.42 10.42 0 0 0-.504.644 8.319 8.319 0 0 0-.651 1.064 6.234 6.234 0 0 0-.261.591 5.47 5.47 0 0 0-.353 1.606l-.007.475a5.64 5.64 0 0 0 .403 1.953 5.44 5.44 0 0 0 1.086 1.703c.338.36.723.674 1.145.932.359.22.742.401 1.14.539a6.39 6.39 0 0 0 2.692.306h.005a6.072 6.072 0 0 0 2.22-.659c.298-.158.582-.341.848-.549a5.438 5.438 0 0 0 1.71-2.274c.28-.699.417-1.446.405-2.198l-.022-.393a5.535 5.535 0 0 0-.368-1.513 6.284 6.284 0 0 0-.285-.618 8.49 8.49 0 0 0-.67-1.061 11.022 11.022 0 0 0-.354-.453 14.594 14.594 0 0 0-1.308-1.37l-.329-.28.557-.55 2.394-2.5.828-.909 1.287-1.448.837-.979 1.194-1.454.808-1.016 1.187-1.587.599-.821.85-1.271.708-1.083 1.334-2.323.763-1.524.022-.047.584-1.414a.531.531 0 0 0 .02-.056l.629-1.962.066-.286.273-1.562.053-.423.016-.259.019-.978-.005-.182-.05-.876-.062-.68-.31-1.961c-.005-.026-.01-.052-.018-.078l-.398-1.45-.137-.403-.179-.446Zm4.494 41.455a3.662 3.662 0 0 0-3.61 3.61 3.663 3.663 0 0 0 3.61 3.609 3.665 3.665 0 0 0 3.611-3.609 3.663 3.663 0 0 0-3.611-3.61Z"
352352
fill="url(#a)"

app/routes/auth+/verify.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export default function Verify() {
8484
<div className="mb-2 flex flex-col gap-2">
8585
<p className="text-center text-2xl text-primary">Check your inbox!</p>
8686
<p className="text-center text-base font-normal text-primary/60">
87-
We've just emailed you a temporary password.
87+
We&apos;ve just emailed you a temporary password.
8888
<br />
8989
Please enter it below.
9090
</p>

app/routes/onboarding+/username.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export default function OnboardingUsername() {
110110
<span className="mb-2 animate-pulse select-none text-6xl">👋</span>
111111
<h3 className="text-center text-2xl font-medium text-primary">Welcome!</h3>
112112
<p className="text-center text-base font-normal text-primary/60">
113-
Let's get started by choosing a username.
113+
Let&apos;s get started by choosing a username.
114114
</p>
115115
</div>
116116

app/utils/env.server.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const schema = z.object({
1616
})
1717

1818
declare global {
19+
// eslint-disable-next-line @typescript-eslint/no-namespace
1920
namespace NodeJS {
2021
interface ProcessEnv extends z.infer<typeof schema> {}
2122
}
@@ -41,11 +42,9 @@ export function getSharedEnvs() {
4142
}
4243
}
4344

44-
type ENV = ReturnType<typeof getSharedEnvs>
45-
4645
declare global {
47-
let ENV: ENV
48-
interface Window {
49-
ENV: ENV
46+
// eslint-disable-next-line @typescript-eslint/no-namespace
47+
namespace NodeJS {
48+
interface ProcessEnv extends z.infer<typeof schema> {}
5049
}
5150
}

app/utils/misc.server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function combineHeaders(
6060
* Singleton Server-Side Pattern.
6161
*/
6262
export function singleton<Value>(name: string, value: () => Value): Value {
63-
const globalStore = global as any
63+
const globalStore = global as unknown as { __singletons?: Record<string, Value> }
6464

6565
globalStore.__singletons ??= {}
6666
globalStore.__singletons[name] ??= value()

app/utils/misc.ts

+5-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import type { SerializeFrom } from '@remix-run/node'
21
import type { ClassValue } from 'clsx'
3-
import type { loader as rootLoader } from '#app/root'
2+
import type { LoaderData as RootLoaderData } from '#app/root'
43
import { useFormAction, useNavigation, useRouteLoaderData } from '@remix-run/react'
54
import { clsx } from 'clsx'
65
import { twMerge } from 'tailwind-merge'
@@ -16,14 +15,14 @@ export function cn(...inputs: ClassValue[]) {
1615
/**
1716
* Use root-loader data.
1817
*/
19-
function isUser(user: any): user is SerializeFrom<typeof rootLoader>['user'] {
18+
function isUser(user: RootLoaderData['data']['user']) {
2019
return user && typeof user === 'object' && typeof user.id === 'string'
2120
}
2221

2322
export function useOptionalUser() {
24-
const data = useRouteLoaderData<typeof rootLoader>('root')
25-
if (!data || !isUser(data.user)) return undefined
26-
return data.user
23+
const loaderData = useRouteLoaderData<RootLoaderData>('root')
24+
if (!loaderData || !isUser(loaderData.data.user)) return undefined
25+
return loaderData.data.user
2726
}
2827

2928
export function useUser() {

docker-entrypoint.js

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env node
2+
/* eslint-disable */
23

34
import { spawn } from 'node:child_process'
45

docs/README.md

+11
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,14 @@ Deployment and some other docs are available in the [Deployment](./guide/09-depl
142142
# Support
143143

144144
If you found **Remix SaaS** helpful, consider supporting it with a ⭐ [Star](https://github.com/dev-xo/remix-saas). It helps the repository grow and provides the required motivation to continue maintaining the project. Thank you!
145+
146+
## Sponsors
147+
148+
Remix SaaS is proudly supported by [Arcjet](https://launch.arcjet.com/hdXzPbO).
149+
150+
<a href="https://launch.arcjet.com/hdXzPbO" target="_arcjet-home">
151+
<picture>
152+
<source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/logo/arcjet-dark-lockup-voyage-horizontal.svg">
153+
<img src="https://arcjet.com/logo/arcjet-light-lockup-voyage-horizontal.svg" alt="Arcjet Logo" height="128" width="auto">
154+
</picture>
155+
</a>

docs/guide/05-utilities.md

+23
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,29 @@ You can authenticate as `admin` by using the following credentials:
3434

3535
The default admin email can be changed in the `/prisma/seed.ts` file.
3636

37+
## Arcjet Security
38+
39+
Remix SaaS includes an optional integration with [Arcjet](https://launch.arcjet.com/hdXzPbO), a security-as-code product that helps secure your application with bot protection, rate limiting, and signup form spam protection.
40+
41+
Arcjet's philosophy is that proper security protections need the full context of the application, which is why security rules and protections should be located alongside the code they are protecting.
42+
43+
Arcjet security-as-code means you can version control your security rules, track changes through pull requests, and test them locally before deploying to production.
44+
45+
### Arcjet Configuration
46+
47+
You can enable this integration by [signing up for a free account](https://launch.arcjet.com/hdXzPbO) and setting the `ARCJET_KEY` environment variable in the `.env` file.
48+
49+
An easier approach is to simply initialize the template opting into Arcjet, as this will automatically add the `ARCJET_KEY` environment variable to the `.env` file and update a few other files to fully enable Arcjet for you.
50+
51+
By opting into Arcjet, you will benefit from:
52+
53+
- Bot protection on the website index. The rules are defined in the loader of `app/routes/_home+/_index.tsx`.
54+
- Bot protection, rate limiting, email verification, and validation on the login form. This will help protect against fraudulent or spam signups. The rules are defined in the action of `app/routes/auth+/login.tsx`.
55+
56+
Both of these use a central client with a base rule to detect common attacks that is applied everywhere the client is used. This is defined in `app/utils/arcjet.server.ts`.
57+
58+
See [the Arcjet documentation](https://docs.arcjet.com) for full details.
59+
3760
## Toasts
3861

3962
Toasts are a way to display messages to the user. They are defined in the `/utils/toasts` directory. Some of the toasts that are available are:

0 commit comments

Comments
 (0)