diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fb49460 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Contributing + +Thanks for your willingness to contribute! Please make sure to check with me +before doing a bunch of work on something. + +## Project setup + +If you do need to set the project up locally yourself, feel free to follow these +instructions: + +### System Requirements + +- [Node.js](https://nodejs.org/) >= 20.0.0 +- [npm](https://npmjs.com/) >= 8.18.0 +- [git](https://git-scm.com/) >= 2.38.0 + +### Setup steps + +1. Fork and clone the repo +2. Copy `.env.example` into `.env` +3. Run `npm install && npm run setup -s` to install dependencies and run + validation +4. Create a branch for your PR with `git checkout -b pr/your-branch-name` + +> Tip: Keep your `main` branch pointing at the original repository and make pull +> requests from branches on your fork. To do this, run: +> +> ``` +> git remote add upstream https://github.com/epicweb-dev/epic-stack.git +> git fetch upstream +> git branch --set-upstream-to=upstream/main main +> ``` +> +> This will add the original repository as a "remote" called "upstream," Then +> fetch the git information from that remote, then set your local `main` branch +> to use the upstream main branch whenever you run `git pull`. Then you can make +> all of your pull request branches based on this `main` branch. Whenever you +> want to update your version of `main`, do a regular `git pull`. + +If the setup script doesn't work, you can try to run the commands manually: + +```sh +git clone +cd ./epic-stack + +# copy the .env.example to .env +# everything's mocked out during development so you shouldn't need to +# change any of these values unless you want to hit real environments. +cp .env.example .env + +# Install deps +npm install + +# setup database +prisma migrate reset --force + +# Install playwright browsers +npm run test:e2e:install + +# run build, typecheck, linting +npm run validate +``` + +If that all worked without trouble, you should be able to start development +with: + +```sh +npm run dev +``` + +And open up `http://localhost:3000` and rock! + +## Help Needed + +There's something to be said for custom code and the ability that grants with +regard to tuning it to be exactly what you need. But there's also something to +be said for offloading maintenance onto external dependencies. There are likely +several bits of code in this codebase that could benefit from externalization. +There could even be some things that could be improved by existing libraries. + +Feel free to take any code from within this project and turn it into an open +source library (appropriate attribution is appreciated). Then come back and make +a PR to use your new library. + +NOTE: Actual adoption of your library is not guaranteed. Offloading maintenance +and adaptability is a delicate balance. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7c76ba3 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,18 @@ +Copyright © 2023 Kent C. Dodds + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/app/components/error-boundary.tsx b/app/components/error-boundary.tsx index 715b8c8..80c1406 100644 --- a/app/components/error-boundary.tsx +++ b/app/components/error-boundary.tsx @@ -39,7 +39,7 @@ export function GeneralErrorBoundary({ ? (statusHandlers?.[error.status] ?? defaultStatusHandler)({ error, params, - }) + }) : unexpectedErrorHandler(error)} ) diff --git a/app/components/ui/icons/README.md b/app/components/ui/icons/README.md deleted file mode 100644 index 4047a10..0000000 --- a/app/components/ui/icons/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Icons - -This directory contains SVG icons that are used by the app. - -Everything in this directory is generated by `npm run build:icons`. diff --git a/app/components/ui/icons/name.d.ts b/app/components/ui/icons/name.d.ts deleted file mode 100644 index 6830243..0000000 --- a/app/components/ui/icons/name.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -// This file is generated by npm run build:icons - -export type IconName = - | 'arrow-left' - | 'arrow-right' - | 'avatar' - | 'camera' - | 'check' - | 'clock' - | 'cross-1' - | 'dots-horizontal' - | 'download' - | 'envelope-closed' - | 'exit' - | 'file-text' - | 'github-logo' - | 'laptop' - | 'link-2' - | 'lock-closed' - | 'lock-open-1' - | 'magnifying-glass' - | 'moon' - | 'pencil-1' - | 'pencil-2' - | 'plus' - | 'question-mark-circled' - | 'reset' - | 'sun' - | 'trash' - | 'update' diff --git a/app/components/ui/icons/sprite.svg b/app/components/ui/icons/sprite.svg deleted file mode 100644 index 81f92f7..0000000 --- a/app/components/ui/icons/sprite.svg +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/root.tsx b/app/root.tsx index 250be14..9507025 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -26,7 +26,6 @@ import { } from '@remix-run/react' import { withSentry } from '@sentry/remix' import { useRef } from 'react' -import { AuthenticityTokenProvider } from 'remix-utils/csrf/react' import { HoneypotProvider } from 'remix-utils/honeypot/react' import { z } from 'zod' import { GeneralErrorBoundary } from './components/error-boundary.tsx' @@ -47,7 +46,6 @@ import { EpicToaster } from './components/ui/sonner.tsx' import tailwindStyleSheetUrl from './styles/tailwind.css' import { getUserId, logout } from './utils/auth.server.ts' import { ClientHintCheck, getHints, useHints } from './utils/client-hints.tsx' -import { csrf } from './utils/csrf.server.ts' import { prisma } from './utils/db.server.ts' import { getEnv } from './utils/env.server.ts' import { honeypot } from './utils/honeypot.server.ts' @@ -131,7 +129,6 @@ export async function loader({ request }: LoaderFunctionArgs) { } const { toast, headers: toastHeaders } = await getToast(request) const honeyProps = honeypot.getInputProps() - const [csrfToken, csrfCookieHeader] = await csrf.commitToken() return json( { @@ -147,13 +144,11 @@ export async function loader({ request }: LoaderFunctionArgs) { ENV: getEnv(), toast, honeyProps, - csrfToken, }, { headers: combineHeaders( { 'Server-Timing': timings.toString() }, toastHeaders, - csrfCookieHeader ? { 'set-cookie': csrfCookieHeader } : null, ), }, ) @@ -285,10 +280,10 @@ function App() { function Logo() { return ( - + epic - + notes @@ -298,11 +293,9 @@ function Logo() { function AppWithProviders() { const data = useLoaderData() return ( - - - - - + + + ) } diff --git a/app/routes/_auth+/forgot-password.tsx b/app/routes/_auth+/forgot-password.tsx index 6844ebf..fb249cc 100644 --- a/app/routes/_auth+/forgot-password.tsx +++ b/app/routes/_auth+/forgot-password.tsx @@ -8,13 +8,11 @@ import { type MetaFunction, } from '@remix-run/node' import { Link, useFetcher } from '@remix-run/react' -import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { HoneypotInputs } from 'remix-utils/honeypot/react' import { z } from 'zod' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { ErrorList, Field } from '#app/components/forms.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { sendEmail } from '#app/utils/email.server.ts' import { checkHoneypot } from '#app/utils/honeypot.server.ts' @@ -27,7 +25,6 @@ const ForgotPasswordSchema = z.object({ export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData() - await validateCSRF(formData, request.headers) checkHoneypot(formData) const submission = await parse(formData, { schema: ForgotPasswordSchema.superRefine(async (data, ctx) => { @@ -142,7 +139,6 @@ export default function ForgotPasswordRoute() {
-
@@ -270,7 +267,6 @@ export default function LoginPage() {
- @@ -177,7 +174,6 @@ export default function SignupRoute() { className="mx-auto min-w-full max-w-sm sm:min-w-[368px]" {...form.props} > -
-
- diff --git a/app/routes/resources+/download-user-data.tsx b/app/routes/resources+/download-user-data.tsx index a3d12e2..307d41d 100644 --- a/app/routes/resources+/download-user-data.tsx +++ b/app/routes/resources+/download-user-data.tsx @@ -48,7 +48,7 @@ export async function loader({ request }: LoaderFunctionArgs) { ? { ...user.image, url: `${domain}/resources/user-images/${user.image.id}`, - } + } : null, notes: user.notes.map(note => ({ ...note, diff --git a/app/routes/settings+/profile.change-email.tsx b/app/routes/settings+/profile.change-email.tsx index 4b1214a..9808d81 100644 --- a/app/routes/settings+/profile.change-email.tsx +++ b/app/routes/settings+/profile.change-email.tsx @@ -10,7 +10,6 @@ import { type ActionFunctionArgs, } from '@remix-run/node' import { Form, useActionData, useLoaderData } from '@remix-run/react' -import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { z } from 'zod' import { ErrorList, Field } from '#app/components/forms.tsx' import { Icon } from '#app/components/ui/icon.tsx' @@ -21,7 +20,6 @@ import { type VerifyFunctionArgs, } from '#app/routes/_auth+/verify.tsx' import { requireUserId } from '#app/utils/auth.server.ts' -import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { sendEmail } from '#app/utils/email.server.ts' import { useIsPending } from '#app/utils/misc.tsx' @@ -106,7 +104,6 @@ export async function loader({ request }: LoaderFunctionArgs) { export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request) const formData = await request.formData() - await validateCSRF(formData, request.headers) const submission = await parse(formData, { schema: ChangeEmailSchema.superRefine(async (data, ctx) => { const existingUser = await prisma.user.findUnique({ @@ -234,7 +231,6 @@ export default function ChangeEmailIndex() {

- -
{otherSessionsCount ? ( - - - { @@ -155,7 +152,6 @@ export default function PhotoRoute() { onReset={() => setNewImageSrc(null)} {...form.props} > - Save Photo @@ -231,8 +227,8 @@ export default function PhotoRoute() { pendingIntent === 'delete' ? 'pending' : lastSubmissionIntent === 'delete' - ? actionData?.status ?? 'idle' - : 'idle' + ? actionData?.status ?? 'idle' + : 'idle' } > diff --git a/app/routes/settings+/profile.two-factor.disable.tsx b/app/routes/settings+/profile.two-factor.disable.tsx index 793a6df..8805b58 100644 --- a/app/routes/settings+/profile.two-factor.disable.tsx +++ b/app/routes/settings+/profile.two-factor.disable.tsx @@ -5,12 +5,10 @@ import { type ActionFunctionArgs, } from '@remix-run/node' import { useFetcher } from '@remix-run/react' -import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { requireRecentVerification } from '#app/routes/_auth+/verify.tsx' import { requireUserId } from '#app/utils/auth.server.ts' -import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { useDoubleCheck } from '#app/utils/misc.tsx' import { redirectWithToast } from '#app/utils/toast.server.ts' @@ -29,7 +27,6 @@ export async function loader({ request }: LoaderFunctionArgs) { export async function action({ request }: ActionFunctionArgs) { await requireRecentVerification(request) - await validateCSRF(await request.formData(), request.headers) const userId = await requireUserId(request) await prisma.verification.delete({ where: { target_type: { target: userId, type: twoFAVerificationType } }, @@ -47,7 +44,6 @@ export default function TwoFactorDisableRoute() { return (
-

Disabling two factor authentication is not recommended. However, if you would like to do so, click here: diff --git a/app/routes/settings+/profile.two-factor.index.tsx b/app/routes/settings+/profile.two-factor.index.tsx index 1198d4f..e080dda 100644 --- a/app/routes/settings+/profile.two-factor.index.tsx +++ b/app/routes/settings+/profile.two-factor.index.tsx @@ -6,11 +6,9 @@ import { type ActionFunctionArgs, } from '@remix-run/node' import { Link, useFetcher, useLoaderData } from '@remix-run/react' -import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { requireUserId } from '#app/utils/auth.server.ts' -import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { generateTOTP } from '#app/utils/totp.server.ts' import { twoFAVerificationType } from './profile.two-factor.tsx' @@ -31,7 +29,6 @@ export async function loader({ request }: LoaderFunctionArgs) { export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request) - await validateCSRF(await request.formData(), request.headers) const { otp: _otp, ...config } = generateTOTP() const verificationData = { ...config, @@ -82,7 +79,6 @@ export default function TwoFactorRoute() { to log in.

- @@ -180,7 +177,6 @@ export default function TwoFactorRoute() {

- { @@ -212,7 +209,6 @@ export function NoteEditor({ {...form.props} encType="multipart/form-data" > - {/* This hidden submit button is here to ensure that when the user hits "enter" on an input field, the primary form function is submitted diff --git a/app/routes/users+/$username_+/notes.$noteId.tsx b/app/routes/users+/$username_+/notes.$noteId.tsx index 39b6f16..681f79e 100644 --- a/app/routes/users+/$username_+/notes.$noteId.tsx +++ b/app/routes/users+/$username_+/notes.$noteId.tsx @@ -14,7 +14,6 @@ import { type MetaFunction, } from '@remix-run/react' import { formatDistanceToNow } from 'date-fns' -import { AuthenticityTokenInput } from 'remix-utils/csrf/react' import { z } from 'zod' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { floatingToolbarClassName } from '#app/components/floating-toolbar.tsx' @@ -23,7 +22,6 @@ import { Button } from '#app/components/ui/button.tsx' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { requireUserId } from '#app/utils/auth.server.ts' -import { validateCSRF } from '#app/utils/csrf.server.ts' import { prisma } from '#app/utils/db.server.ts' import { getNoteImgSrc, useIsPending } from '#app/utils/misc.tsx' import { @@ -71,7 +69,6 @@ const DeleteFormSchema = z.object({ export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request) const formData = await request.formData() - await validateCSRF(formData, request.headers) const submission = parse(formData, { schema: DeleteFormSchema, }) @@ -172,7 +169,6 @@ export function DeleteNote({ id }: { id: string }) { return ( -

Epic Notes Users

-
+
diff --git a/app/utils/connections.server.ts b/app/utils/connections.server.ts index db0c0af..524c5fa 100644 --- a/app/utils/connections.server.ts +++ b/app/utils/connections.server.ts @@ -7,7 +7,7 @@ import { type Timings } from './timing.server.ts' export const connectionSessionStorage = createCookieSessionStorage({ cookie: { name: 'en_connection', - sameSite: 'lax', + sameSite: 'lax', // CSRF protection is advised if changing to 'none' path: '/', httpOnly: true, maxAge: 60 * 10, // 10 minutes diff --git a/app/utils/csrf.server.ts b/app/utils/csrf.server.ts deleted file mode 100644 index 5edcf91..0000000 --- a/app/utils/csrf.server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createCookie } from '@remix-run/node' -import { CSRF, CSRFError } from 'remix-utils/csrf/server' - -const cookie = createCookie('csrf', { - path: '/', - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - secrets: process.env.SESSION_SECRET.split(','), -}) - -export const csrf = new CSRF({ cookie }) - -export async function validateCSRF(formData: FormData, headers: Headers) { - try { - await csrf.validate(formData, headers) - } catch (error) { - if (error instanceof CSRFError) { - throw new Response('Invalid CSRF token', { status: 403 }) - } - throw error - } -} diff --git a/app/utils/db.server.ts b/app/utils/db.server.ts index 9f48a96..fdbad3c 100644 --- a/app/utils/db.server.ts +++ b/app/utils/db.server.ts @@ -22,12 +22,12 @@ export const prisma = remember('prisma', () => { e.duration < logThreshold * 1.1 ? 'green' : e.duration < logThreshold * 1.2 - ? 'blue' - : e.duration < logThreshold * 1.3 - ? 'yellow' - : e.duration < logThreshold * 1.4 - ? 'redBright' - : 'red' + ? 'blue' + : e.duration < logThreshold * 1.3 + ? 'yellow' + : e.duration < logThreshold * 1.4 + ? 'redBright' + : 'red' const dur = chalk[color](`${e.duration}ms`) console.info(`prisma:query - ${dur} - ${e.query}`) }) diff --git a/app/utils/misc.tsx b/app/utils/misc.tsx index 57832f6..f6190db 100644 --- a/app/utils/misc.tsx +++ b/app/utils/misc.tsx @@ -216,7 +216,7 @@ export function useDoubleCheck() { : e => { e.preventDefault() setDoubleCheck(true) - } + } const onKeyUp: React.ButtonHTMLAttributes['onKeyUp'] = e => { diff --git a/app/utils/session.server.ts b/app/utils/session.server.ts index 249c5a4..f155000 100644 --- a/app/utils/session.server.ts +++ b/app/utils/session.server.ts @@ -3,7 +3,7 @@ import { createCookieSessionStorage } from '@remix-run/node' export const authSessionStorage = createCookieSessionStorage({ cookie: { name: 'en_session', - sameSite: 'lax', + sameSite: 'lax', // CSRF protection is advised if changing to 'none' path: '/', httpOnly: true, secrets: process.env.SESSION_SECRET.split(','), diff --git a/app/utils/verification.server.ts b/app/utils/verification.server.ts index 563b1c4..6cfc4a8 100644 --- a/app/utils/verification.server.ts +++ b/app/utils/verification.server.ts @@ -3,7 +3,7 @@ import { createCookieSessionStorage } from '@remix-run/node' export const verifySessionStorage = createCookieSessionStorage({ cookie: { name: 'en_verification', - sameSite: 'lax', + sameSite: 'lax', // CSRF protection is advised if changing to 'none' path: '/', httpOnly: true, maxAge: 60 * 10, // 10 minutes diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e330723 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# Epic Stack Documentation + +The goal of The Epic Stack is to provide solid opinions for teams to hit the +ground running on their web applications. + +We recommend you watch Kent's introduction to the Epic Stack to get an +understanding of the "why" behind the Stack: + +[![Epic Stack Talk slide showing Flynn Rider with knives, the text "I've been around and I've got opinions" and Kent speaking in the corner](https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/277818553-47158e68-4efc-43ae-a477-9d1670d4217d.png)](https://www.epicweb.dev/talks/the-epic-stack) + +More of a reader? Read [the announcement post](https://epicweb.dev/epic-stack) +or +[an AI generated summary of the video](https://www.summarize.tech/www.youtube.com/watch?v=yMK5SVRASxM). + +This stack is still under active development. Documentation will rapidly improve +in the coming weeks. Stay tuned! + +# Top Pages + +- [Getting Started](./getting-started.md) - Instructions for how to get started + with the Epic Stack. +- [Features](./features.md) - List of features the Epic Stack provides out of + the box. +- [Deployment](./deployment.md) - If you skip the deployment step when starting + your app, these are the manual steps you can follow to get things up and + running. +- [Decisions](./decisions/README.md) - The reasoning behind various decisions + made for the Epic Stack. A good historical record. +- [Guiding Principles](./guiding-principles.md) - The guiding principles behind + the Epic Stack. +- [Examples](./examples.md) - Examples of the Epic Stack with various tools. + Most new feature requests people have for the Epic Stack start as examples + before being integrated into the framework. +- [Managing Updates](./managing-updates.md) - How to manage updates to the Epic + Stack for both the generated stack code as well as npm dependencies. diff --git a/docs/apis.md b/docs/apis.md new file mode 100644 index 0000000..415ade0 --- /dev/null +++ b/docs/apis.md @@ -0,0 +1,21 @@ +# APIs + +Remix routes have the ability to handle both backend code and UI code in the +same file. Remix `loader`s and `action`s are backend code that's tightly coupled +to the UI code for that route. + +Additionally, you can define routes that don't have any UI at all. These are +called [resource routes](https://remix.run/docs/en/main/guides/resource-routes). +This allows you to create REST endpoints or a GraphQL endpoint to make your app +data and logic consumable by third parties or additional clients (like a mobile +app). You can also use this to generate PDFs, images, stream multi-media and +more. + +The Epic Stack has a few resource routes in place for managing images, the +cache, and even has a few +["full stack components"](https://www.epicweb.dev/full-stack-components) for +components that manage the connection with their associated backend code. +[Watch the talk](https://www.youtube.com/watch?v=30HAT5Quvgk&list=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf). + +So, yes, you can absolutely use the Epic Stack to build APIs for consumption by +third party clients. diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..683257b --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,55 @@ +# Authentication + +The Epic Stack manages its own authentication using web standards and +established libraries and tools. + +By default, the Epic Stack offers you two mechanisms for authentication: + +1. Username and password authentication +2. Provider authentication + +## Username and password authentication + +When a user wishes to sign up for an account, they are asked for their email +address. The Epic Stack will send them an email with a code as well as a link. +The user can then enter the code or click the link to verify their email address +which takes them through the onboarding flow which will allow them to set their +username and password. + +The password is stored using the [bcrypt](https://npm.im/bcrypt) algorithm. + +## Provider authentication + +The Epic Stack ships with a system for third party authentication allowing you +to easily add SSO (Single Sign On) to your application. The Epic Stack ships +with support for GitHub OAuth2 authentication out of the box. But you can easily +remove that and/or add other providers. It's all built using +[`remix-auth`](https://npm.im/remix-auth), so any provider supported there, can +be added, including [`web-oidc`](https://npm.im/web-oidc) which handles OpenID +Connect authentication and exports a `remix-auth` compatible auth strategy. + +You can check [this example](https://github.com/kentcdodds/epic-oidc) which +shows using OpenID Connect to add Google authentication to the Epic Stack. You +can expand beyond this to add any other provider you'd like, and if you need to +support SAML, you may look into +[`@boxyhq/remix-auth-sso`](https://github.com/boxyhq/remix-auth-sso). + +## TOTP and Two-Factor Authentication + +Two factor authentication is built-into the Epic Stack. It's managed using a the +[`@epic-web/totp`](https://npm.im/@epic-web/totp) (Time-based One Time +Passwords) utility. + +You can read more about the decision to use TOTP in +[the totp decision document](./decisions/014-totp.md). The secret and other +pertinent information is stored in a `verification` model (check the Prisma +schema). This verification model is used as the basis for all TOTP secrets. This +is used for non-expiring Two-Factor Authentication secrets as well as temporary +TOTP codes which are emailed to verify a user's ownership of an email/account. +So it's used for onboarding, forgot password, and change email flows. + +When a user has 2FA enabled on their account, they also are required to enter +their 2FA code within 2 hours of performing destructive actions like changing +their email or disabling 2FA. This time is controlled by the +`shouldRequestTwoFA` utility in the `login` full stack component in the resource +routes. diff --git a/docs/caching.md b/docs/caching.md new file mode 100644 index 0000000..31d902c --- /dev/null +++ b/docs/caching.md @@ -0,0 +1,84 @@ +# Caching + +The Epic Stack comes with caching utilities and a management dashboard that +allows you to view and clear your cache. There are two caches built into the +Epic Stack: + +- **SQLite**: This is a separate database from the main application database. + It's managed by LiteFS so the data is replicated across all instances of your + app. This can be used for long-lived cached values. +- **LRU**: This is an in-memory cache that is used to store the results of + expensive queries or help deduplicate requests for data. It's not replicated + across instances and as it's in-memory it will be cleared when your app is + restarted. So this should be used for short-lived cached values. + +Caching is intended to be used for data that is expensive and/or slow to compute +or retrieve. It can help you avoid costs or rate limits associated with making +requests to third parties. + +It's important to note that caching should not be the first solution to slowness +issues. If you've got a slow query, look into optimizing it with database +indexes before caching the results. + +## Using the cache + +You won't typically interact directly with the caches. Instead, you will use +[`cachified`](https://npm.im/cachified) which is a nice abstraction for cache +management. We have a small abstraction on top of it which allows you to pass +`timings` to work seamlessly with +[the server timing utility](./server-timing.md). + +Let's say we're making a request to tito to get a list of events. Tito's API is +kinda slow and our event details don't change much so we're ok speeding things +up by caching them and utilizing the stale-while-revalidate features in +cachified. Here's how you would use cachified to do this: + +```tsx +import { cachified, cache } from '#app/utils/cache.server.ts' +import { type Timings } from '#app/utils/timing.server.ts' + +const eventSchema = z.object({ + /* the schema for events */ +}) + +export async function getScheduledEvents({ + timings, +}: { + timings?: Timings +} = {}) { + const scheduledEvents = await cachified({ + key: 'tito:scheduled-events', + cache, + timings, + getFreshValue: () => { + // do a fetch request to the tito API and stuff here + return [ + /* the events you got from tito */ + ] + }, + checkValue: eventSchema.array(), + // Time To Live (ttl) in milliseconds: the cached value is considered valid for 24 hours + ttl: 1000 * 60 * 60 * 24, + // Stale While Revalidate (swr) in milliseconds: if the cached value is less than 30 days + // expired, return it while fetching a fresh value in the background + staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30, + }) + return scheduledEvents +} +``` + +With this setup, the first time you call `getScheduledEvents` it will make a +request to the tito API and return the results. It will also cache the results +in the `cache` (which is the SQLite cache). The next time you call +`getScheduledEvents` it will return the cached value if the cached value is less +than 30 days old. If the cached value is older than 24 hours, it will also make +a request to the tito API. If the cache value is more than 30 days old, it will +wait until the tito request is complete and then return the fresh value. + +Bottom line: You make the request much less often and users are never waiting +for it. Every situation will require you think through the implications of +caching and acceptable stale-ness, but the point is you have those levers to +pull. + +A lot more needs to be said on this subject (an entire workshop full!), but this +should be enough to get you going! diff --git a/docs/client-hints.md b/docs/client-hints.md new file mode 100644 index 0000000..2dc2641 --- /dev/null +++ b/docs/client-hints.md @@ -0,0 +1,13 @@ +# Client Hints + +> **NOTE:** Find background on this concept in the decision document: +> `0005-client-pref-cookies.md`. + +[Watch the tip](https://www.epicweb.dev/tips/use-client-hints-to-eliminate-content-layout-shift) +on [EpicWeb.dev](https://www.epicweb.dev): + +[![Kent smiling with VSCode showing code in the client-hints.tsx file](https://github.com/epicweb-dev/epic-stack/assets/1500684/ede18d0a-c117-4c65-9f1e-a87f262e4ce1)](https://www.epicweb.dev/tips/use-client-hints-to-eliminate-content-layout-shift) + +This functionality has been moved into `@epic-web/client-hints` and is the +primary source for documentation on this feature. Our integration can be found +in `app/utils/client-hints.tsx`. diff --git a/docs/community.md b/docs/community.md new file mode 100644 index 0000000..9d0b813 --- /dev/null +++ b/docs/community.md @@ -0,0 +1,21 @@ +# Community + +Here you can find useful learning resources and tools built and maintained by +the community, such as libraries, examples, articles, and videos. + +## Learning resources + +The primary learning resources for the Epic Stack is +[EpicWeb.dev](https://www.epicweb.dev), [EpicReact.dev](https://epicreact.dev), +and [TestingJavaScript.com](https://testingjavascript.com). On these you will +find free and paid premium content that will help you build epic web +applications (with or without the Epic Stack). + +The community has put together some additional learning resources that you may +enjoy! + +### Videos + +- **Dark Mode Toggling using Client-preference cookies** by + [@rajeshdavidbabu](https://github.com/rajeshdavidbabu) - Youtube + [link](https://www.youtube.com/watch?v=UND-kib_iw4) diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..b8acb5f --- /dev/null +++ b/docs/database.md @@ -0,0 +1,384 @@ +# Database + +## Primary Instance + +The way that LiteFS works is that it has a "primary instance" that is the only +instance that can write to the database. All other instances are "replicas" that +can only read from the database. This is a pretty common pattern for databases +and it's how Fly's Postgres service works as well. + +The primary instance is determined by Fly's [consul](https://www.consul.io/) +service and can change over time. By default, all instances are allowed to be +primary instances. However, writes will be slower for people who are not +geographically close to the primary instance, so the Epic Stack has configured +consul to only allow instances in the primary region and it's recommended that +you choose a primary region that's closest to most of your users. This +configuration can be found in `other/litefs.yml`. The `PRIMARY_REGION` is +determined from the `primary_region` property in the `fly.toml` file. + +When you initialize an Epic Stack app, it will ask you which region you wish to +deploy to and this will set the `primary_region` in the `fly.toml` file. It is +recommended that you deploy two instances in this region so you have zero +downtime deploys. + +You can determine which instance is the primary instance by running the +following command: + +```sh +fly status --app [YOUR_APP_NAME] +``` + +This will show you a table of all your instances and the primary instance will +have "ROLE" set to "primary". + +To deploy more regions, you can run `fly scale count`. For example, if I wanted +to have two instances in `sjc` (my primary region) and one in `ams`, I would +run: + +```sh +fly scale count 2 --region sjc +fly scale count 1 --region ams +``` + +## Connecting to your production database + +The location of the sqlite database is kinda funny. The real location is in +`/data/litefs/dbs/sqlite.db`. However, during development you connect to it via +the fake filesystem managed by LiteFS so it can propagate any changes to your +database to all replicas. + +So to connect to your database, you'll want to connect to it at +`/litefs/data/sqlite.db` in the deployed application. Because it is SQLite, you +cannot connect to it unless you're running a command-line session on the +machine. You can do this using `fly ssh console`. The Dockerfile simplifies this +further by adding a `database-cli` command. You can connect to the live database +by running `fly ssh console -C database-cli`. + +To connect to the deployed database from your local machine using Prisma Studio, +you can utilize Fly's `ssh` and `proxy` commands. + +- Run in one terminal the command to start Prisma Studio on your desired Fly app + ```sh + fly ssh console -C "npm run prisma:studio" --app [YOUR_APP_NAME] + ``` +- Run in a second terminal the command to proxy your local port 5556 to Prisma + Studio + ```sh + fly proxy 5556:5555 --app [YOUR_APP_NAME] + ``` + +If you have multiple instances of your app running, and you'd like to make edits +to your database, you will need to run `prisma:studio` on the primary instance. + +- Get a list of your app instances, the `ROLE` column will show which instance + is `primary` + ```sh + fly status --app [YOUR_APP_NAME] + ``` +- Run the console command with the `-s` select flag + ```sh + fly ssh console -C "npm run prisma:studio" -s --app [YOUR_APP_NAME] + ``` +- Use your arrow keys to select the primary instance + +To work with Prisma Studio and your deployed app's database, simply open +`http://localhost:5556` in your browser. + +> **Note**: You may want to add `--select` to the `fly ssh console` command to +> select the instance you want to connect to if you have multiple instances +> running. Otherwise you could connect to a non-primary instance. The easiest +> way to determine the primary instance (because it can change) is to run +> `fly status` and the row that has "ROLE" set to "primary" is the primary +> instance. Alternatively, you can open the deployed application and check the +> request headers. One of them will be `Fly-Primary-Instance` which will tell +> you the instance ID of the primary instance. + +## Migrations + +Thanks to Prisma, we've got a great mechanism for handling database migrations. +Any migrations necessary are run (by the primary instance only) as part of the +deploy process. You can find this in the `other/litefs.yml` file. + +We deploy to multiple instances at once and the way we deploy means we don't +have any downtime during deploys. However, to make this work, you do need to +make sure you can run two versions of your app at once. Specifically, you should +not deploy "breaking schema changes" to your app in a way that will break any +running instances. This is a pretty typical requirement for production +applications. + +The basic idea is that you maintain support for any client that is currently +running. In Epic Stack apps that's normally just the currently running version +of the app. So you really only need to worry about "breaking schema changes" +whenever you deploy a new version, but you don't have to worry about avoiding +breaking schema changes for old versions of the app that are no longer running. + +In practice, this means you should adopt a "widen then narrow" strategy for +schema migrations. This is a pretty common practice, but here's a simple example +of how this works (each step here is an individual deploy): + +1. Widen app to consume A or B +2. Widen db to provide A and B and the app to write to both A and B +3. Narrow app to consume B and only write to B +4. Narrow db to provide B + +So, let's say that today your app allows users to provide a "name" and you want +to change that to `firstName` and `lastName` instead. Here's how you'd do that +(again, each of these steps end in a deploy): + +1. Widen app to consume `firstName` and `lastName` or `name`. So all new code + that references the `firstName` and `lastName` fields should fallback to the + `name` field and not error if the `firstName` and `lastName` fields don't + exist yet, which it won't at this point. +2. Widen db to provide `firstName` and `lastName` and `name`. So the `name` + field should be populated with the `firstName` and `lastName` fields. You can + do this as part of the migration SQL script that you run. The easiest way to + do this is to generate the migration script to add the fields using + `prisma migrate` and then modify the script to copy the existing data in the + `name` field to the `firstName` field (maybe with the help of VSCode Copilot + 😅). +3. Narrow app to consume `firstName` and `lastName` by only writing to those + fields and removing the fallback to the `name` field. +4. Narrow db to provide `firstName` and `lastName` by removing the `name` field. + So now you can remove the `name` field from the db schema. + +By following this strategy, you can ensure zero downtime deploys and schema +migrations. + +## Seeding Production + +In this application we have Role-based Access Control implemented. We initialize +the database with `admin` and `user` roles with appropriate permissions. + +This is done in the `migration.sql` file that's included in the template. If you +need to seed the production database, modifying migration files manually is the +recommended approach to ensure it's reproducible. + +The trick is not all of us are really excited about writing raw SQL (especially +if what you need to seed is a lot of data), so here's an easy way to help out: + +1. Create a script very similar to our `prisma/seed.ts` file which creates all + the data you want to seed. +1. Run the script locally to generate the data you want to seed. +1. Create a "dump" of the seed database using the `sqlite3` command line tool. + ```sh nonumber + sqlite3 seed.db .dump > seed.sql + ``` +1. Copy the relevant bits from the `seed.sql` file into your `migration.sql` + file. The `seed.sql` will include create table/index lines etc. which should + already be in your `migration.sql`. You probably just want `INSERT` commands. +1. Deploy your app and verify that the data was seeded correctly. + +If your app has already applied all migrations, then the changes to the +`migration.sql` won't be applied (because prisma's already applied it). So then +you can run the following command to apply the migration: + +```sh nonumber +fly ssh console -C "npx prisma migrate reset --skip-seed --force" --app [YOUR_APP_NAME] +``` + +> **WARNING**: This will reset your database and apply all migrations. Continue +> reading if you want to avoid this. + +If you have existing data in your production database and you'd like to seed it +with more data without performing a migration, then it's a bit more involved. + +1. Backup your production database. +1. Create a new database file (locally) with the data you want to seed. +1. Create a "dump" of the seed database using the `sqlite3` command line tool. + ```sh nonumber + sqlite3 seed.db .dump > seed.sql + ``` +1. Copy the `seed.sql` file to your production volume next to your database (via + `fly sftp`) +1. SSH into your production server and run the following command: + ```sh nonumber + sqlite3 data.db < seed.sql + ``` +1. Verify that your production database has been seeded correctly. If it hasn't, + then restore your backup (asap). + +## Backups + +### LiteFS Cloud Backups + +LiteFS Cloud is a service offered by Fly.io for managing backup and restore +functionality. + +This is the simplest method for backing up your database. + +It offers the ability to restore your database to any point in time in the last +30 days, with 5 minute granularity. + +Fly.io has some great documentation on how to set this up: + +- [Pricing](https://fly.io/docs/about/pricing/#litefs-cloud) +- [LiteFS Cloud Setup](https://fly.io/docs/litefs/cloud-backups/) +- [Restoring DB with LiteFS Cloud](https://fly.io/docs/litefs/cloud-restore/) +- [Disaster Recovery with LiteFS Cloud](https://fly.io/docs/litefs/disaster-recovery/) + +The following is a summary of the steps to set up LiteFS Cloud: + +1. Create a LiteFS Cloud cluster in your Fly.io dashboard: + https://fly.io/dashboard/personal/litefs + - Take note of the auth token, you'll need it in the next step +1. Set the `LITEFS_CLOUD_TOKEN` to the token from your dashboard: + ```sh + fly secrets set LITEFS_CLOUD_TOKEN="LITEFS_CLOUD_TOKEN_HERE" --app [YOUR_APP_NAME] + ``` +1. You should now be able to restore backups from the LiteFS dashboard. + +### Manual DB Backups + +Manual DB backups can be taken/restored using `litefs` commands: + +- `litefs export`: https://fly.io/docs/litefs/export/ +- `litefs import`: https://fly.io/docs/litefs/import/ + +**Make sure to keep the backup in a secure location. Your DB backup will contain +user information and password hashes!** + +You can manually create a backup for your database using the following steps: + +1. SSH into your fly instance: + ```sh + fly ssh console --app [YOUR_APP_NAME] + ``` +1. Create a `backups` folder: + ```sh + mkdir /backups + ``` +1. Create a backup file using `litefs export`, and exit the console (it is + recommended to name the exported file with the current date): + ```sh + litefs export -name sqlite.db /backups/backup-2023-10-10.db + exit + ``` +1. Use sftp to download the backup file: + ```sh + fly ssh sftp get /backups/backup-2023-10-10.db --app [YOUR_APP_NAME] + ``` + +You can now store this backup file wherever you like, such as an S3 bucket +(again, make sure it's a secure location!). + +See the fly docs for more info: https://fly.io/docs/litefs/backup/ + +### Manual DB restoration + +**WARNING - THIS OVERWRITES YOUR DATABASE, YOU CAN LOSE DATA!! TAKE ANOTHER +BACKUP OF THE CURRENT DATABASE BEFORE DOING THIS!!** + +1. Establish an sftp session with the fly instance and upload the backup file to + the server using `put`: + ```sh + fly ssh sftp shell --app [YOUR_APP_NAME] + put backup-2023-10-10.db + ``` +1. Quit the sftp session with CTRL+C +1. SSH into the fly instance: + ```sh + fly ssh console --app [YOUR_APP_NAME] + ``` +1. Restore the database from the backup file using `litefs import` + ```sh + litefs import -name sqlite.db /backup-2023-10-10.db + ``` +1. Exit the ssh session + ```sh + exit + ``` + +## Troubleshooting + +### Faulty Prisma Migration + +If you accidentally run a faulty migration on prod DB, and you see this message +in the logs: + +```sh +migrate found failed migrations in the target database, new migrations will not be applied. Read more about how to resolve migration issues in a production database: https://pris.ly/d/migrate-resolve +``` + +You've got a few options: + +1. If you don't care about the data, you can delete the app on fly and re-deploy + it after deleting/fixing the faulty migration. +2. If you have a recent backup of the DB, you can restore it and re-deploy the + app after deleting/fixing the faulty migration. **(Make sure that the backup + isn't too old, otherwise you'll lose data)** + - You can fix a faulty migration by either editing the migration SQL file, or + by deleting the particular migration folder from `prisma/migrations` and + re-generating the migration after fixing the error. +3. If you do care about the data and don't have a backup, you can follow these + steps: + + 1. Comment out the + [`exec` section from `litefs.yml` file](https://github.com/epicweb-dev/epic-stack/blob/main/other/litefs.yml#L31-L37). + + ```yml + # exec: + # - cmd: node ./other/setup-swap.js + + # - cmd: npx prisma migrate deploy + # if-candidate: true + + # - cmd: npm start + ``` + + 2. Commit this change and deploy the app to fly. + - This will make sure that after building the dockerfile and deploying it + to the fly machine, the `node ./other/setup-swap.js`, + `npx prisma migrate deploy` and `npm start` commands won't be executed. + 3. Now that the main machine is up and running, you can SSH into it by + running `fly ssh console --app [YOUR_APP_NAME]` in the terminal. + 4. Create a backup of the DB and download it by following the steps mentioned + in the [Manual DB Backups](#manual-db-backups) section. + 5. Make a copy of the downloaded DB backup file and store it in a secure + location (If something were to go wrong, we have a backup of the backup). + 6. Write SQL or use a tool like + [DB Browser for SQLite](https://sqlitebrowser.org/) to remove the last + (failed) entry from `_prisma_migrations` table from the downloaded DB + backup file. + - If you're using DB Browser for SQLite but your DB isn't selectable when + selecting a file to open, change DB file extension to `sqlite` from + `db`. **Make sure to change it back to `db` before uploading it to the + fly machine**. + 7. Follow the steps mentioned in the + [Manual DB restoration](#manual-db-restoration) section to restore the + modified DB backup file to the fly machine. + 8. Now the DB is restored, but if you deploy the app with the faulty + migration, it will fail again. So you need to delete/fix the faulty + migration. + - You can fix a faulty migration by either editing the migration SQL file, + or by deleting the particular migration folder from `prisma/migrations` + and re-generating the migration after fixing the error. + 9. Uncomment the + [`exec` section from `litefs.yml` file](https://github.com/epicweb-dev/epic-stack/blob/main/other/litefs.yml#L31-L37). + + ```yml + exec: + - cmd: node ./other/setup-swap.js + + - cmd: npx prisma migrate deploy + if-candidate: true + + - cmd: npm start + ``` + + 10. Commit this change and deploy the app to fly :tada: + + NOTE: Steps 5-7 can be skipped if you can write SQL to remove the faulty + migration from the DB. You can use the following SQL query to remove the + faulty migration from the DB: + + ```sql + DELETE FROM _prisma_migrations WHERE name = 'migration_name'; + ``` + + Make sure to replace `migration_name` with the name of the faulty migration. + +This is particularly hard because of how CONSUL leases work. You cannot just +simply spin up another machine and access your database from it, because only +one machine can get the primary lease. So you have to disable exec commands so +the machine that has primary lease can start up, then you can SSH into it and +fix the DB. diff --git a/docs/decisions/000-template.md b/docs/decisions/000-template.md new file mode 100644 index 0000000..374aa1c --- /dev/null +++ b/docs/decisions/000-template.md @@ -0,0 +1,12 @@ +# Title + +Date: YYYY-MM-DD + +Status: proposed | rejected | accepted | deprecated | … | superseded by +[0005](0005-example.md) + +## Context + +## Decision + +## Consequences diff --git a/docs/decisions/001-typescript-only.md b/docs/decisions/001-typescript-only.md new file mode 100644 index 0000000..f28ebd8 --- /dev/null +++ b/docs/decisions/001-typescript-only.md @@ -0,0 +1,44 @@ +# TypeScript Only + +Date: 2023-05-08 + +Status: accepted + +## Context + +The `create-remix` CLI allows users to select whether they want to use +JavaScript instead of TypeScript. This will auto-convert everything to +JavaScript. + +There is (currently) no way to control this behavior. + +Teams and individuals building modern web applications have many great reasons +to build them with TypeScript. + +One of the challenges with TypeScript is getting it configured properly. This is +not an issue with a stack which starts you off on the right foot without needing +to configure anything. + +Another challenge with TypeScript is handling dependencies that are not written +in TypeScript. This is increasingly becoming less of an issue with more and more +dependencies being written in TypeScript. + +## Decision + +We strongly advise the use of TypeScript even for simple projects and those +worked on by single developers. So instead of working on making this project +work with the JavaScript option of the `create-remix` CLI, we've decided to +throw an error informing the user to try again and select the TypeScript option. + +We've also made the example script in the `README.md` provide a selected option +of `--typescript` so folks shouldn't even be asked unless they leave off that +flag in which case our error will be thrown. + +## Consequences + +This makes the initial experience not great for folks using JavaScript. +Hopefully the Remix CLI will eventually allow us to have more control over +whether that question is asked. + +This also may anger some folks who really don't like TypeScript. For those +folks, feel free to fork the starter. diff --git a/docs/decisions/002-email-service.md b/docs/decisions/002-email-service.md new file mode 100644 index 0000000..17bc397 --- /dev/null +++ b/docs/decisions/002-email-service.md @@ -0,0 +1,41 @@ +# Email Service + +Date: 2023-05-08 + +Status: superseded by [017](017-resend-email.md) + +## Context + +When you're building a web application, you almost always need to send emails +for various reasons. Packages like `nodemailer` make it quite easy to send your +own emails through your own mailserver or a third party's SMTP server as well. + +Unfortunately, +[deliverability will suffer if you're not using a service](https://cfenollosa.com/blog/after-self-hosting-my-email-for-twenty-three-years-i-have-thrown-in-the-towel-the-oligopoly-has-won.html). +The TL;DR is you either dedicate your company's complete resources to "play the +game" of email deliverability, or you use a service that does. Otherwise, your +emails won't reliably make it through spam filters (and in some cases it can +just get deleted altogether). + +[The guiding principles](https://github.com/epicweb-dev/epic-stack/blob/main/docs/guiding-principles.md) +discourage services and encourage quick setup. + +## Decision + +We will use a service for sending email. If emails don't get delivered then it +defeats the whole purpose of sending email. + +We selected [Mailgun](https://www.mailgun.com/) because it has a generous free +tier and has proven itself in production. However, to help with quick setup, we +will allow deploying to production without the Mailgun environment variables set +and will instead log the email to the console so during the experimentation +phase, developers can still read the emails that would have been sent. + +During local development, the Mailgun APIs are mocked and logged in the terminal +as well as saved to the fixtures directory for tests to reference. + +## Consequences + +Developers will need to either sign up for Mailgun or update the email code to +use another service if they prefer. Emails will actually reach their +destination. diff --git a/docs/decisions/003-sqlite.md b/docs/decisions/003-sqlite.md new file mode 100644 index 0000000..0a78a26 --- /dev/null +++ b/docs/decisions/003-sqlite.md @@ -0,0 +1,83 @@ +# SQLite + +Date: 2023-05-13 + +Status: accepted + +## Context + +SQLite is a sql-based database with a particularly unique feature: the entire +database is in a single file. Largely for this reason, I've historically seen it +as a simple database for simple use cases. + +However, in recent years SQLite has received a great deal of development and +attention making it a simple database for even more advanced use cases. The fact +that SQLite is a single file on disk comes with a major benefit of 0 latency +which +[drastically reduces the "n+1 problem"](https://www.sqlite.org/np1queryprob.html). + +Another issue is database size, however, +[SQLite is capable of handling databases that are an Exabyte in size](https://sqlite.org/hctree/doc/hctree/doc/hctree/index.html) +(that's one million Terabytes, or one billion Gigabytes 🤯). + +SQLite does not support subscriptions which can be a limitation on certain +real-time use cases. However, there are plenty of reasons to recommend against +using database subscriptions for real-time use cases anyway, and that is the +case in the Epic Stack (as of today, we don't have real-time examples or docs, +but when we do it's likely we wouldn't use database subscriptions anyway). + +SQLite being a file on disk does make connecting from external clients +effectively impossible. This makes it more difficult to connect to it using +database admin tools. However, it is possible to run tools like `prisma studio` +on the machine where the sqlite volume is mounted if necessary. And the +`Dockerfile` is already configured to allow easy SSH connections into the sqlite +CLI. This is nowhere near as good as a proper admin tool, but there's likely +more that could be done here to improve the experience. On my own website +(kentcdodds.com), I am able to have prisma studio run in production protected by +authentication and I expect we'll be able to add something like that to the Epic +Stack in the future. + +SQLite does not support plugins like +[TimescaleDB](https://github.com/timescale/timescaledb) for Postgres. While +time-series data is possible with SQLite, I do not have experience with this use +case and can't speak to the challenges there. My intuition says it's not +advisable to use SQLite for that use case. + +SQLite does not support enums which means you're forced to use strings. I have +mixed feelings about this, but I mostly don't like enums anyway. The main +drawback to this is when it comes to the typings for the client which doesn't +allow you to ensure all values of a column are only within a set of specific +possible values for the string. However, with Prisma client extensions, handling +this kind of enforcement at the client (and typing) level should be possible. +This would need to be documented in the future +[#29](https://github.com/epicweb-dev/epic-stack/issues/29). + +As a file on disk, you cannot "distribute" SQLite directly. However, with tools +like [Turso](https://turso.tech/) or [LiteFS](https://fly.io/docs/litefs), you +can make that work. And both of these tools even have built-in solutions to the +"Read Replica Consistency" challenge. So if you need your app to run in multiple +instances, you need to use one of these tools. + +Using SQLite is an _enormous_ simplification both during development and +production. With one less service to worry about (especially something as +critical as your database), you're less likely to experience an outage. +Additionally, all you need is a persisted volume for your application (which you +would need for a database service anyway), so it's less costly as well. + +## Decision + +We'll use SQLite because it satisfies the use cases of our target audience. + +## Consequences + +This means we need to have a way to connect to our SQLite database in +production. We'll also need to have a way to easily seed the database +(documentation will need to be written and perhaps some scripts). We want to +support multi-region so LiteFS needs to be configured. + +This also means real-time use cases will need to find a solution that doesn't +rely on database subscriptions. + +Ultimately, this decision drastically simplifies development, deployment, +maintenance, and services for the web application and reduces running costs. A +huge win for the majority of web applications. diff --git a/docs/decisions/004-github-actions.md b/docs/decisions/004-github-actions.md new file mode 100644 index 0000000..036e398 --- /dev/null +++ b/docs/decisions/004-github-actions.md @@ -0,0 +1,49 @@ +# GitHub Actions + +Date: 2023-05-15 + +Status: accepted + +## Context + +Deploying serious web applications to production on a regular basis requires +automation. Running testing, linting, and a build before deployment is a +accepted practice to ensure a quality product is deployed. + +Alongside this, we want to run this automation any time code is merged (or will +soon be merged) from contributors to the project. This is called "Continuous +Integration" and is necessary for teams to move confidently and focus on their +value proposition. Make the robots do the boring stuff so we can focus on the +creative work. + +We can run this automation on our own machines during development, but it can be +easy to forget to do this. It's even harder to be confident the automation was +run when you are trying to combine the work of multiple people (who's +responsible to run the deploy script?). So it's best to have this automation run +on a separate machine that's dedicated to this task. + +I don't want to have that machine running in my closet, so instead we need to +look to outside services for managing this for us. There are many such services. +Most of the target audience of the Epic Stack are familiar with GitHub and many +use it already for other projects. The vast majority already have accounts on +GitHub as well. + +GitHub has a CI service called +[GitHub Actions](https://docs.github.com/en/actions) which satisfies all the +necessary use cases for the Epic Stack. It does not require an additional +account, though it does necessitate you use GitHub for hosting the code. Also, +it is free for open source projects, but paid for private projects. + +## Decision + +We've decided to use GitHub Actions for Continuous Integration in the Epic +Stack. We have a single action that handles running linting, type checking, +tests, and deployment to both staging (`dev` branch) and production (`main` +branch). + +## Consequences + +This means users of the Epic Stack must host their repositories within GitHub +and pay for private repositories. This reduces the number of extra services +users of the Epic Stack need to sign up for since the vast majority already have +accounts with GitHub anyway. diff --git a/docs/decisions/005-client-pref-cookies.md b/docs/decisions/005-client-pref-cookies.md new file mode 100644 index 0000000..719a4a0 --- /dev/null +++ b/docs/decisions/005-client-pref-cookies.md @@ -0,0 +1,94 @@ +# Client Preference Cookies + +Date: 2023-05-16 + +Status: accepted + +## Context + +Server rendering is preferred for most things to avoid content layout shift +(which causes a poor user experience). Unfortunately, there are a number of +limitations with this because the browser doesn't give the server enough +information about the user's preferences. For example: + +- `prefers-color-scheme` (light/dark mode) +- `prefers-reduced-data` +- time zone offset +- locale + +And much more. + +The problem is that if what you display to the user relies on these values, then +what the server renders could be wrong and the user will see the incorrect UI +until client-side JavaScript can take over and correct it which causes a "Flash +of Incorrect UI." This is a terrible user experience. + +This is such an issue that the web platform will (hopefully soon) be adding new +user preferences headers to each request for us to know these values on the +server. Read, +[User preference media features client hints headers](https://web.dev/user-preference-media-features-headers/) +and +[User Locale Preferences](https://github.com/romulocintra/user-locale-client-hints). +However, there's no telling when these features will become standard and +implemented in all browsers Epic Stack apps target so we cannot rely on this or +wait for it. + +One solution I've used on my personal website is to include a tiny bit of inline +JavaScript in the client that runs before the user has a chance to see anything +to correct any flaws in the UI. This is normally enough, but on extremely slow +connections the user will still see a flash of incorrect UI. It's also quite +complex and pretty hacky. + +Another solution I've seen from +[Jacob Paris](https://www.jacobparis.com/content/remix-ssr-dates) is to simply +use cookies to solve this problem. You simply set a cookie in the browser from +the client for every property your application needs and then the server knows +the user preference during the document request and server render. There are two +limitations here: + +1. First time users won't have the cookie set +2. The cookie will be stale if the user changes their preference + +To solve the first problem, we can simply check that the cookies are set and if +they are not, then we instead send a minimal document that includes a tiny bit +of JavaScript that sets the cookies and then reloads the page. This is not +ideal, however it's effectively as harmful to the user as a redirect which many +websites do anyway (for example, go to `https://youtube.com` and you instantly +get redirected to `https://www.youtube.com`). Additionally, this is effectively +how +[the draft specification](https://wicg.github.io/user-preference-media-features-headers/#usage-example) +for the web platform's solution works as well. + +To solve the second problem, we can simply keep a tiny bit of JS in the head of +the document that does a quick check of the cookie values and if they are stale, +then it sets them again and triggers a reload. Still not ideal, but again, it's +better than a content layout shift. And hopefully this solution isn't permanent +and we can remove it once the web platform offers a better solution. + +To take things further, we can future proof this solution a bit by trying to +adhere to the web platform's proposed solution as closely as possible, so that +when it does become available, we can simply switch from the cookies to headers +and remove the JS, leaving us with few changes to make. + +## Decision + +Even though the web platform is working on a solution for this, we cannot wait +for it. Despite page reloads being a sub-optimal user experience, it's better +than the content layout shift (flash of incorrect UI) alternative. Therefore, we +will use cookies and reloads to solve this problem. + +## Consequences + +The user's first page load will be a bit slower than normal (as will any page +load after their preferences change) because we have to do a page reload to set +the cookies. However, this is a one-time cost and the user will not experience +this again until they change their preferences. + +The user will not experience content layout shift for any user preferences our +app depends on for the server render. This is a huge win for user experience. + +If a user has cookies disabled, then we need to detect that and fallback to +default values. Users in this situation will experience content layout shift, +but there's nothing else we can do about that. Additionally, users who have +cookies disabled will not be able to authenticate which is a separate concern +that should be addressed in a different decision document. diff --git a/docs/decisions/006-native-esm.md b/docs/decisions/006-native-esm.md new file mode 100644 index 0000000..fbb941b --- /dev/null +++ b/docs/decisions/006-native-esm.md @@ -0,0 +1,38 @@ +# Native ESM + +Date: 2023-05-18 + +Status: accepted + +## Context + +Oh boy, where do I start? The history of JavaScript modules is long and +complicated. I discuss this a bit in my talk +[More than you want to know about ES6 Modules](https://kentcdodds.com/talks/more-than-you-want-to-know-about-es-6-modules). +Many modern packages on npm are now publishing esm-only versions of their +packages. This is fine, but it does mean that using them from a CommonJS module +system requires dynamic imports which is limiting. + +In Remix v2, ESM will be the default behavior. Everywhere you look, ESM is +becoming more and more the standard module option. CommonJS modules aren't going +anywhere, but it's a good idea to stay on top of the latest. + +Sadly, this is a bit of a "who moved my cheese" situation. Developers who are +familiar with CommonJS modules will be annoyed by things they were used to doing +in CJS that they can't do the same way in ESM. The biggest is dynamic (and +synchronous) requires. Another is the way that module resolution changes. There +are some packages which aren't quite prepared for ESM and therefore you end up +having to import their exports directly from the files (like radix for example). +This is hopefully a temporary problem. + +## Decision + +We're adopting ESM as the default module system for the Epic Stack. + +## Consequences + +Experienced developers will hit a couple bumps along the way as they change +their mental model for modules. But it's time to do this. + +Some tools aren't very ergonomic with ESM. This will hopefully improve over +time. diff --git a/docs/decisions/007-sessions.md b/docs/decisions/007-sessions.md new file mode 100644 index 0000000..4e41755 --- /dev/null +++ b/docs/decisions/007-sessions.md @@ -0,0 +1,44 @@ +# Sessions + +Date: 2023-05-25 + +Status: accepted + +## Context + +There are a lot of ways to manage authentication in applications on the web. +We've chosen to use cookies to identify a user. However, you can just stick the +user's ID in that cookie and then sign it with a secret and then you'll always +know that the user ID in the cookie is legitimate and you won't need to go to +the database at all to determine who is making the request (or, at least to know +their ID). + +The limitation here is that once a client has a signed cookie with the user ID, +it cannot be revoked. You can set a `maxAge` or a `expires`, but you can't +proactively revoke it. There's also not a great way to know how many sessions +are currently active for a given user. + +For many applications, being able to proactively invalidate sessions is +necessary. My own site has this capability. Many sites will even associate some +identifiable information about each session as well as the last time that +session was used to display that information to the user and allow them to +revoke specific sessions. + +## Decision + +We will add a sessions table to the built-in template's schema allowing a +capability for a simple "revoke all" feature. Adding more information to the +session model would be simple for anyone needing a more sophisticated session +management strategy. This simple implementation gets people on the right foot +with regards to session management. + +## Consequences + +This requires doing a database lookup for every authenticated request to +determine whether there is an active session in the database for the user's +request. This is not a problem for us since we're using SQLite and there's 0 +latency, so the query is extremely fast. + +The sessions table does not currently have the capability to proactively delete +expired sessions which means it could fill up indefinitely. This would be a good +problem for built-in cron job support to solve eventually. diff --git a/docs/decisions/008-content-security-policy.md b/docs/decisions/008-content-security-policy.md new file mode 100644 index 0000000..f29216e --- /dev/null +++ b/docs/decisions/008-content-security-policy.md @@ -0,0 +1,32 @@ +# Content Security Policy + +Date: 2023-05-27 + +Status: accepted + +Update: [022-report-only-csp.md](./022-report-only-csp.md) + +## Context + +A Content Security Policy (CSP) allows a server to inform the browser about the +sources from which it expects to load resources. This helps to prevent +cross-site scripting (XSS) attacks by not allowing the browser to load resources +from any other location than the ones specified in the CSP. + +CSPs that are overly strict can be a major pain to work with, especially when +using third-party libraries. Still, for the most security, the CSP should be as +strict as possible. Additional sources can be added to the CSP as needed. + +## Decision + +We configure a tight CSP for the default application using +[helmet](https://npm.im/helmet) which is a de-facto standard express middleware +for configuring security headers. + +## Consequences + +Applications using the Epic Stack will start with a safer default configuration +for their CSP. It's pretty simple to add additional sources to the CSP as +needed, but it could definitely be confusing for folks who are unaware of the +CSP to load resources. Documentation will be needed to help people understand +what to do when they get CSP errors. diff --git a/docs/decisions/009-region-selection.md b/docs/decisions/009-region-selection.md new file mode 100644 index 0000000..6a04ba8 --- /dev/null +++ b/docs/decisions/009-region-selection.md @@ -0,0 +1,32 @@ +# Region Selection + +Date: 2023-06-02 + +Status: accepted + +## Context + +Fly supports running your app in +[34 regions](https://fly.io/docs/reference/regions/) all over the world. The +Epic Stack is set up to allow you to run in as many of these regions as you +like, but for cost reasons, it's best to start out with a single region until +your app needs that level of scale. + +Region selection has an important impact on the performance of your app. When +you're choosing a single region, you're choosing who your app is going to be +slower for. So you really should choose the region that's closest to the most +critical/closest users. + +Unfortunately, there's no way for us to know this for every app. We can't just +select a region for you. And we also can't just select the region that's closest +to you. We need you to actually think about and make this decision. + +## Decision + +Ask which region the app should be deployed to during setup. + +## Consequences + +Forces the developer to make a choice (goes against the "Minimize Setup +Friction" guiding principle). However, we can make it slightly better by +defaulting to the region that's closest to the developer. diff --git a/docs/decisions/010-memory-swap.md b/docs/decisions/010-memory-swap.md new file mode 100644 index 0000000..a85e25f --- /dev/null +++ b/docs/decisions/010-memory-swap.md @@ -0,0 +1,41 @@ +# Memory Swap + +Date: 2023-06-02 + +Status: accepted + +## Context + +Node.js based apps can use a lot of memory. And while we can scale up the memory +on the instances that run your app, we can't scale it up infinitely. Especially +when we want to be cost sensitive. So we need to be able to handle the case +where your app uses more memory than is available on the instance. A solution to +this is to use swap memory. + +Swap memory is a way to use disk space as memory. It's not as fast as real +memory, but it's better than crashing. And it's a lot cheaper than scaling up +the memory on your instances. It makes sense for many types of apps (even at +scale) to use swap memory. Especially for apps just getting off the ground, +making use of swap memory can be a great way to keep costs down. + +Because our app is running in a container with a mounted volume, we can't use +the normal swap memory mechanisms. Instead, we need to use a swap file. This +means we need to create a file on the mounted volume and then use that file as +swap memory using `fallocate`, `mkswap`, and `swapon`. + +Size of the swap file is pretty subjective to the application and situation. The +Epic Stack app memory starts at 256MB on Fly. Based on that amount of memory, a +good rule of thumb for the size of the swap file is 2-4x the size of memory, +which would put the swap file at 512MB-1GB (for a 2GB+ RAM system, you typically +want the swap file to be the same size as the memory). Considering our volumes +are set to 1GB for starters, we'll start with a 512MB swap file. + +## Decision + +During app startup, we'll create a swap file on the mounted volume and then use +that file as swap memory for the application. + +## Consequences + +In high utilization situations, we will have degraded performance instead of a +crash. This is a good tradeoff for most apps. diff --git a/docs/decisions/011-sitemaps.md b/docs/decisions/011-sitemaps.md new file mode 100644 index 0000000..09a7703 --- /dev/null +++ b/docs/decisions/011-sitemaps.md @@ -0,0 +1,29 @@ +# Sitemaps + +Date: 2023-06-05 + +Status: accepted + +## Context + +[Sitemaps](https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview) +are useful to help website crawlers (like search engines) find all the content +on your website. Most of the time they aren't necessary if you're linking +between pages well. However, for large websites with lots of content that are +highly search engine sensitive, they can be useful. + +It's normally not a big deal to get them wrong if you don't care about it, but +if you really don't care about it, having the code for it can get in the way and +it's kind of annoying. + +## Decision + +Instead of building a sitemap into the template, we'll use +[an example](/docs/examples.md) people can reference to add a sitemap to their +Epic Stack sites if they like. + +## Consequences + +This turns sitemaps into an opt-in for developers using the Epic Stack. Most +people using the Epic Stack probably don't need a sitemap, and those who do will +only need a few minutes of following the example to get it working. diff --git a/docs/decisions/012-cuid.md b/docs/decisions/012-cuid.md new file mode 100644 index 0000000..bd35860 --- /dev/null +++ b/docs/decisions/012-cuid.md @@ -0,0 +1,81 @@ +# CUID + +Date: 2023-06-06 + +Status: accepted + +## Context + +I noticed there were a couple models that were using `uuid`. To verify my +understanding of the `uuid` vs `cuid` debate, I took it to ChatGPT. + +
+ChatGPT conversation: CUID vs UUID Debate + +**CUID vs UUID Debate** - +[June 6, 2023](https://chat.openai.com/share/cc0eb433-9bc0-48ea-861a-dfc7ed5acf43) + +**Kent:** Please argue both sides of the cuid vs uuid debate. + +**ChatGPT:** Sure! Let's explore both sides of the debate between CUID +(Collision-resistant Unique ID) and UUID (Universally Unique Identifier). + +Arguments in favor of CUID: + +1. Simplicity and readability: CUIDs are designed to be simple and + human-readable, consisting of a combination of lowercase letters and + numbers. This makes them easier to work with and understand compared to the + alphanumeric strings of UUIDs. +2. Shorter length: CUIDs are typically shorter in length compared to UUIDs, + which can be advantageous in certain scenarios where storage or transmission + efficiency is a concern. The shorter length can lead to reduced memory usage + and faster processing times. +3. Collision resistance: CUIDs are specifically designed to minimize the + chances of collision, meaning the probability of generating duplicate IDs is + extremely low. This makes them suitable for scenarios where uniqueness is + crucial, such as database keys or distributed systems. + +Arguments in favor of UUID: + +1. Universality and standardization: UUIDs are widely recognized and used + across different systems and programming languages. They have a standardized + format and are supported by various libraries and frameworks, making them + easier to integrate into existing systems and ensure compatibility. +2. Higher entropy and randomness: UUIDs typically have a higher degree of + randomness due to the inclusion of a timestamp and a unique identifier from + the host or network. This can be beneficial in situations where security and + unpredictability are essential, such as generating secure session tokens or + cryptographic keys. +3. Widely adopted solutions: UUIDs have been widely adopted and proven to be + effective in various use cases, including distributed systems, databases, + and web applications. This extensive usage has resulted in a wealth of + knowledge, libraries, and best practices associated with working with UUIDs, + making them a reliable choice. + +In summary, the choice between CUID and UUID depends on the specific +requirements of the application or system. CUIDs offer simplicity, shorter +length, and collision resistance, making them suitable for scenarios where +readability and uniqueness are critical. On the other hand, UUIDs provide +universality, standardization, higher entropy, and wider adoption, making them a +strong choice for applications that require compatibility, security, and a +proven track record. + +
+ +That pretty much checks out for my understanding of the debate. The primary +benefit of CUIDs for the Epic Stack is their emphasis on collision resistance +which is good for distributed systems (which is the Epic Stack). Also, it's nice +to keep things smaller and more readable. + +## Decision + +Use CUIDs for all models instead of UUIDs. + +## Consequences + +No migration is necessary since the data type for CUIDs and UUIDs is both +"string". If any existing application wants to proactively update old values to +CUIDs, that's fine, but not completely necessary. All that needs to be updated +is the prisma schema for a few of the models that were using UUIDs. Generating a +new version of the client (which happens automatically at build time) is +sufficient to handle this schema change. diff --git a/docs/decisions/013-email-code.md b/docs/decisions/013-email-code.md new file mode 100644 index 0000000..c65d90f --- /dev/null +++ b/docs/decisions/013-email-code.md @@ -0,0 +1,40 @@ +# Email Verification Code + +Date: 2023-06-05 + +Status: accepted + +## Context + +When a new user registers, we need to collect their email address so we can send +them a password reset link if they forget their password. Applications may also +need the email for other reasons, but whatever the case may be, we need their +email address, and to reduce spam and user error, we want to verify the email as +well. + +Currently, the Epic Stack will send the email with a link which the user can +then click and start the onboarding process. This works fine, but it often means +the user is left with a previous dead-end tab open which is kind of annoying +(especially if they are on mobile and the email client opens the link in a +different browser). + +An alternative to this is to include a verification code in the email and have +the user enter that code into the application. This is a little more work for +the user, but it's not too bad and it means that the user can continue their +work from the same tab they started in. + +This also has implications if people want to add email verification for +sensitive operations like password resets. If a code system is in place, it +becomes much easier to add that verification to the password reset process as +well. + +## Decision + +We will support both options. The email will include a code and a link, giving +the user the option between the two so they can select the one that works best +for them in the situation. + +## Consequences + +This requires a bit more work, but will ultimately be a better UX and will pave +the way for other features in the future. diff --git a/docs/decisions/014-totp.md b/docs/decisions/014-totp.md new file mode 100644 index 0000000..0d11027 --- /dev/null +++ b/docs/decisions/014-totp.md @@ -0,0 +1,114 @@ +# TOTP (Time-based One-time Password Algorithm) + +Date: 2023-06-06 + +Status: accepted + +## Context + +As explained in [013-email-code.md](./013-email-code.md), user registration +requires email verification. To verify the email address, we send the user an +email with a magic link that includes an encrypted payload. We do something +similar for the "forgot password" flow as well. + +Early on, it was decided to not bother creating a record with the database for +new users. This simplifies things a bit, but it also means that the salt is the +same for all users which technically opens us up for +[rainbow table](https://en.wikipedia.org/wiki/Rainbow_table) attacks. We could +also have implemented this using JWTs because the payload doesn't actually +contain any sensitive information and all we need is to verify that someone else +didn't generate the token (which is what JWTs enable). + +The real issue is that since the payload is known (our source for this is +literally open) and the salt is known (it's the same for all users), a dedicated +adversary with enough time and resources could brute-force their way to +determine the `ENCRYPTION_SECRET`... Or a disgruntled employee could leak it. + +If the `ENCRYPTION_SECRET` were to somehow determined by an adversary, it would +allow them to generate magic links for any email address and login as any user. +Even if the risk is a little low, it's still a risk that would be nice to avoid +provided it didn't impose an enormous effort. + +One way I explored for reducing this risk is rotating the encryption secret by +having a special database table for storing the secret and then having a +background job that rotates the secret every so often. This would also mean that +we need to keep old secrets around for as long as the encrypted data is valid so +we can decrypt it. We don't yet have background job support, but we're planning +on adding it eventually (we probably should do something like this for our +`INTERNAL_COMMAND_TOKEN` in the future). In any case, it would be nice to avoid +the extra complexity. + +And so we come to TOTP (Time-based One-Time Passwords). TOTP is a standard for +generating one-time passwords that are only valid for a specific amount of time. +The most common example of this is the 2FA codes that you get when logging into +a website. +[Read how TOTP works on Wikipedia](https://en.wikipedia.org/wiki/Time-based_one-time_password). +In our case it's pretty simple because we are both the client and server. + +Here's the TL;DR: + +1. Generate a secret key +2. Generate the TOTP with that key +3. Store the key and the TOTP in the database along with the thing you're + verifying (like the user's email address) +4. Send the TOTP to the user (email it to them) +5. When the user enters the TOTP, verify it against the one in the database + that's associated with the thing they're verifying +6. If the TOTP is valid, delete it from the database and allow the user to + proceed + +So yeah, it's literally a short-lived, one-time password. + +Think of it this way: Before, we generated a one-time password (the token of the +magic link) using the same secret. Now we generate a one-time password using a +random secret. It becomes impossible for someone to brute-force discovery of our +secret. + +**Why not just send the secret key instead of generating a TOTP?** First of all, +we want to make sure this is something easy for a user to type in. A 6-digit +number is much easier to type than a 32-character string. So we could make the +secret key be 6 digits long, but the other benefit of the TOTP is that it +expires after a certain amount of time by design. So brute force attacks are +much less likely to succeed. + +On top of all that, putting this together makes features like 2FA much easier to +implement because we can just reuse the same code. + +One unfortunate aspect of using a TOTP instead of an environment variable is you +now need to store the secret key in the database. Personally I really liked that +the email verification was effectively stateless, but I think the security +benefits of using a TOTP outweigh the costs. + +As for generating the TOTP, there are a few libraries for this, but every one +that I could find either did way too much or hasn't been updated in many years +and has a number of flaws and limitations. So we'll need to implement our own +based on the simplest implementation I could find: [`notp`](https://npm.im/notp) +(it doesn't support custom algorithms and uses `sha1` 😬). A perfectly good TOTP +can be generated in less than a hundred lines of Node.js code. + +A lot of this is inspired by +[this conversation with ChatGPT 4](https://chat.openai.com/share/a1bbd00d-c9d7-4846-a9af-12c6a475cd20). +It's a really good conversation and I recommend reading it. + +## Decision + +We'll change the magic link token to be an TOTP instead of an encrypted payload. + +## Consequences + +This makes it much easier to implement the email code verification feature +decided on in [013-email-code.md](./013-email-code.md). It also makes it easier +to implement 2FA which we'll do in the future. This also allows us to remove the +`ENCRIPTION_SECRET` from the list of environment variables you need to manage. +And we can swap the `encryption.server.ts` utility for a `totp.server.ts` +utility which, while not simpler is a fair trade (and could definitely be +developed and externalized into an open source library). **Update:** This has +now been published as [@epic-web/totp](https://npm.im/@epic-web/totp). + +This also means we now need a new table in the database. This can be designed in +such a way that there's no migration cost and it's purely additive. + +Eventually, we'll want to set up a background job that deletes expired TOTPs +from the database. It's not a ship stopper for this feature, but something we'll +want to have implemented eventually (the same applies to expired sessions as +well). diff --git a/docs/decisions/015-monitoring.md b/docs/decisions/015-monitoring.md new file mode 100644 index 0000000..545f2a5 --- /dev/null +++ b/docs/decisions/015-monitoring.md @@ -0,0 +1,36 @@ +# Monitoring + +Date: 2023-06-09 + +Status: accepted + +## Context + +Unless you want to be watching your metrics and logs 24/7 you probably want to +be notified when users experience errors in your application. There are great +tools for monitoring your application. I've used Sentry for years and it's +great. + +One of the guiding principles of the project is to avoid services. The nature of +application monitoring requires that the monitor not be part of the application. +So, we necessarily need to use a service for monitoring. + +One nice thing about Sentry is it is open source so we can run it ourselves if +we like. However, that may be more work than we want to take on at first. + +## Decision + +We'll set up the Epic Stack to use Sentry and document how you could get it +running yourself if you prefer to self-host it. + +We'll also ensure that we defer the setup requirement to later so you can still +get started with the Epic Stack without monitoring in place which is very useful +for experiments and makes it easier to remove or adapt to a different solution +if you so desire. + +## Consequences + +We tie the Epic Stack to Sentry a bit, but I think that's a solid trade-off for +the benefit of production error monitoring that Sentry provides. People who need +the scale where Sentry starts to cost money (https://sentry.io/pricing/) will +probably be making money at that point and will be grateful for the monitoring. diff --git a/docs/decisions/016-source-maps.md b/docs/decisions/016-source-maps.md new file mode 100644 index 0000000..d7c2de3 --- /dev/null +++ b/docs/decisions/016-source-maps.md @@ -0,0 +1,70 @@ +# Source Maps + +Date: 2023-06-14 + +Status: superseded by [034-source-maps](034-source-maps.md) + +## Context + +If you're unfamiliar with source maps, check out +[What are source maps?](https://web.dev/source-maps/) on web.dev. + +For anyone familiar with source maps, it's pretty obvious that you do want these +in production for debugging purposes (read +[Should I Use Source Maps in Production? ](https://css-tricks.com/should-i-use-source-maps-in-production/)). +However, when you enable source maps with Remix, you get a warning that looks +like this: + +``` +> remix build --sourcemap + +Building Remix app in production mode... + +⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️ +You have enabled source maps in production. This will make your server-side code visible to the public and is highly discouraged! If you insist, please ensure you are using environment variables for secrets and not hard-coding them into your source! +⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️ +``` + +It's pretty well understood that source maps allow your code to be visible to +the public and yet it's still pretty well understood that's fine because +client-side apps send the code anyway and that can typically be reverse +engineered (especially with the help of modern AI tools as well). + +The reason it's a special concern for some frameworks like Remix today is +because the source maps for the client-side code include some of the server-side +code as well. This is because Remix and other frameworks like it have the +ability for you to write your server code in the same file as your browser code. + +The ability for the public to view your server-side code understandably raises +some concerns for folks. This is especially dangerous if you have secrets +hard-coded into your server-side code. Additionally, if you're exercising +"security through obscurity" then you may be vulnerable to attacks if your +server-side code is visible. + +On the flip side, you really shouldn't be hard-coding secrets into your +server-side code anyway. You should be using environment variables for that. +Additionally, if you're relying on "security through obscurity" then you're +probably not as secure as you think you are. + +Also, source maps are necessary for error monitoring with tools like Sentry. +Without source maps, you'll only see the minified code in your error monitoring +tools. This makes it much harder to debug errors in production. And if you're +debugging your application in production you'll also be limited to minified code +as well. + +It may be possible to generate the source maps and make them available to +Sentry, but then prevent them from being sent to the client. More research is +needed to determine whether this is possible. + +## Decision + +We've decided to enable source maps in production by default. This will allow +for better error monitoring and debugging in production. It will also allow for +easier debugging of server-side code in production. + +## Consequences + +Developers using the Epic Stack will see an warning message during the build and +if they don't practice good secret "hygiene" they may be vulnerable to attacks. +So we'll add documentation explaining how to properly use environment variables +for secrets and not hard-code them into your source code. diff --git a/docs/decisions/017-resend-email.md b/docs/decisions/017-resend-email.md new file mode 100644 index 0000000..a52f7c8 --- /dev/null +++ b/docs/decisions/017-resend-email.md @@ -0,0 +1,29 @@ +# Migrating to Resend + +Date: 2023-06-20 + +Status: accepted + +## Context + +Mailgun changed their pricing model to make it more difficult to understand what +is available within the free tier which motivated us to re-evaluate our +selection here. While mailgun is still a fine service, +[Resend](https://resend.com/) has caught the attention of several users of the +Epic Stack. It has a generous (and obvious) free tier of 3k emails a month. They +check all the boxes regarding table-stakes features you'd expect from an email +service. On top of those things, the UI is simple and easy to use. It's also a +lot cheaper than Mailgun. + +## Decision + +We'll migrate to Resend. As a part of this migration, we're going to avoid +coupling ourselves too closely to it to make it easier to switch to another +provider if you so desire. So we'll be using the REST API instead of the SDK. + +## Consequences + +Code changes are relatively minimal. Only the `app/utils/email.server.ts` util +and the mock for it need to be changed. Then we also need to update +documentation to use the Resend API key instead of the mailgun sending domain, +etc. diff --git a/docs/decisions/018-images.md b/docs/decisions/018-images.md new file mode 100644 index 0000000..c94b743 --- /dev/null +++ b/docs/decisions/018-images.md @@ -0,0 +1,61 @@ +# Images + +Date: 2023-06-23 + +Status: accepted (for now) + +## Context + +One of the guiding principles of the Epic Stack is to limit services (including +the self-managed variety). Depending on the needs of your application, you may +be justified in reaching for a service to solve this problem. If you have many +large images, then finding a service that can host them for you makes a lot of +sense. + +Currently, the Epic Stack stores images in the SQLite database as a blob of +bytes. At first glance, you may think this is a really bad idea, and for some +use cases it definitely would be. But it scales surprisingly well (in some +cases, +[serving small files from SQLite can be faster than the file system](https://www.sqlite.org/fasterthanfs.html)). +In fact, thanks to LiteFS, you get the benefits of replicated storage to all +your app nodes. + +Currently, the setup is pretty sub-optimal. There's currently no optimization or +compression of these images. Whatever goes in is what comes out regardless of +needs of the client requesting the image. And if you plan on handling a lot of +images, you could bump against the limits of SQLite + LiteFS (it's been tested +up to 10GBs). + +These limits should be fine for a large number of applications, but we don't +want "fine" we want Epic! + +Another guiding principle of the Epic Stack is to make things adaptable. We +haven't really come around to this for images yet, but hopefully in the future +there will be a good solution to making it easy to swap from the self-hosted +images to a service. + +We also have plans to support automatic optimization of images a la +Cloudinary/Cloudflare. + +One thing we're waiting on is +[LiteFS to support object storage](https://github.com/superfly/litefs/issues/327). +Once that's done, then we'll probably move the images to files in your volume +and we'll also be able to use that to cache optimized versions of the images. +This will have limited scale, but should be Epic for many applications. + +But all of this is work that hasn't been done yet, so if you're adopting the +Epic Stack, you may consider adjusting the image to use a service. And if you've +got big plans for images in your site, you may want to consider a service. + +## Decision + +We'll leave things as they are for now mostly due to time constraints. Examples +of using the Epic Stack with services are encouraged and welcome. We definitely +want to make it easy to swap out the self-hosted images for a service, so help +there would be appreciated as well. + +## Consequences + +People may start off projects that have ambitious image needs without realizing +the image setup here will not satisfy their requirements. A migration would be +annoying, but definitely possible. diff --git a/docs/decisions/019-components.md b/docs/decisions/019-components.md new file mode 100644 index 0000000..fb0bbfd --- /dev/null +++ b/docs/decisions/019-components.md @@ -0,0 +1,80 @@ +# Components + +Date: 2023-06-27 + +Status: accepted + +## Context + +The web platform is severely lacking in terms of UI components. There's pretty +minimal by way of built-in components, and for many that do exist, they are +extremely difficult (if not impossible) to style. + +I have yet to build a web application where the product owner was happy with the +user agent styles and capabilities of components that are built into the web +platform. + +Unfortunately, building components that work nicely with assistive technologies +in any way you would consider "accessible" is non-trivial. It's certainly not +something that you'd want to do in every new project. + +So using a library that provides accessible components users expect is +definitely a good idea. However, many component libraries are difficult to +customize from a styling perspective. + +What's best is to get a "headless" UI library: One which handles the logic of +accessible, reusable components, but leaves the styling up to you. + +While it may make sense to just say "let's use web components" I'm going to +leave the argument against web components to +[Rich Harris](https://dev.to/richharris/why-i-don-t-use-web-components-2cia) +(he's right, and it pains me just like it does him). The Epic Stack comes with +React, so a component library that leans on React is no problem and actually a +nice benefit. + +Having been around the block a few times myself (and even having built a handful +of component libraries), I've determined the library that does this best is +[Radix](https://www.radix-ui.com/). It's a terrific collection of primitive +components that has a fantastically composable API. + +The Epic Stack started with Radix from the start for this reason. + +That leaves us with the decision about how to style things. The Epic Stack +started with Tailwind for styling (no decision document has been written about +this choice yet), and shipped with things styled by Tailwind. It has worked +relatively well, but the structure has been challenging for folks adopting the +Epic Stack. It's left adopters of the Epic Stack with a lot challenges around +customization. + +Customization is always the biggest challenge when it comes to styling +components. Every company wants its own take on the UI, so having a component +library that comes with its styles baked in is a non-starter. This is why we +chose a headless component library in the first place. + +This is where [shadcn/ui](https://ui.shadcn.com/) comes into the picture. It's +not a component library, but more of a code registry where you can +copy/paste/modify the code to your heart's content. Additionally, it comes +opinionated with our own opinions! It's built with Tailwind and Radix. + +Additionally, while you can easily copy/paste/modify from the website, you can +also use the CLI to download components as needed. So we can add a configuration +file to the Epic Stack and the CLI will know exactly where to place files. + +On top of that, shadcn/ui assumes a Tailwind setup that relies heavily on CSS +variables for color styles which makes it much easier to adapt to the light/dark +mode theme of the Epic Stack. + +## Decision + +We'll adopt shadcn/ui, Radix, and Tailwind as the UI component solution for the +Epic Stack. We'll move most of the custom components that are currently in the +Epic Stack to shadcn/ui components. We'll customize those components as needed. + +## Consequences + +It's important to keep in mind that because shadcn/ui is not a component +library, updates for these components are similar to updates in the Epic Stack +itself: manual. There is no way to get automated updates here. And this is +actually a good thing, even though it's a bit more work. It's a good thing +because it means that you can customize the components as much as you want +without worrying about breaking changes. diff --git a/docs/decisions/020-icons.md b/docs/decisions/020-icons.md new file mode 100644 index 0000000..d32a8dc --- /dev/null +++ b/docs/decisions/020-icons.md @@ -0,0 +1,42 @@ +# Icons + +Date: 2023-06-28 + +Status: accepted + +## Context + +Icons are a critical part to every application. It helps users quickly identify +different actions they can take and the meaning of different elements on the +page. It's pretty well accepted that SVGs are the way to go with icons, but +there are a few different options for how to go about doing this. + +Because the Epic Stack is using React, it may feel obvious to just use a +component per icon and inline the SVG in the component. This is fine, but it's +sub-optimal. I'm not going to spend time explaining why, because +[this article does a great job of that](https://benadam.me/thoughts/react-svg-sprites/). + +SVG sprites are no less ergonomic than inline SVGs in React because in either +case you need to do some sort of transformation of the SVG to make it useable in +the application. If you inline SVGs, you have [SVGR](https://react-svgr.com/) to +automate this process. So if we can automate the process of creating and +consuming a sprite, we're in a fine place. + +And [rmx-cli](https://github.com/kiliman/rmx-cli) has support for automating the +creation of an SVG sprite. + +One drawback to sprites is you don't typically install a library of icons and +then use them like regular components. You do need to have a process for adding +these to the sprite. And you wouldn't want to add every possible icon as there's +no "tree-shaking" for sprites. + +## Decision + +Setup the project to use SVG sprites with `rmx-cli`. + +## Consequences + +We'll need to document the process of adding SVGs. It's still possible to simply +install a library of icons and use them as components if you're ok with the +trade-offs of that approach. But the default in the starter will be to use +sprites. diff --git a/docs/decisions/021-node-version.md b/docs/decisions/021-node-version.md new file mode 100644 index 0000000..8406358 --- /dev/null +++ b/docs/decisions/021-node-version.md @@ -0,0 +1,58 @@ +# Node.js LTS + +Date: 2023-07-03 + +Status: accepted + +## Context + +Node.js has a regular release cycle which is documented in the +[release schedule](https://nodejs.org/en/about/releases/). At the time of this +writing, there are 3 stable maintained releases: 16, 18, and 20. I'll refer you +to that documentation to understand how the release cycle works. + +Deciding which version of Node.js to use for a project is a trade-off between +using the latest features and stability. + +The Epic Stack is more focused on stably shipping web apps than experimenting +with the latest features which is where the Active Long-Term Support (LTS) +version really shines. + +We deploy our apps in Docker containers, and there are various base images we +can use as options which you can find on +[the Node.js Docker Hub](https://hub.docker.com/_/node). Aside from the version, +there flavors of the base image which are based on the Linux distribution used. +Feel free to read more about the different flavors on Docker Hub. One of the +goals for us here is to not ship more than we need in production. + +An additional consideration we'll add as context here is what version of Linux +to have our base image built on. With the same pragmatic approach as the Node.js +version we want to balance latest features with stability. We'll use the +[Debian release cycle](https://wiki.debian.org/DebianReleases) as a guide for +this. + +## Decision + +Use the current LTS version of Node.js as the default in the starter. + +We'll use the `slim` flavor of the node.js images. + +We'll use the `bookworm` flavor of the node.js images (which is the current +stable version of Debian: v12). + +## Consequences + +Folks should hopefully run into few compatibility issues. It's possible they +will need features that are not back-ported to the current active LTS version, +however it's trivial to update the Node.js version. Added documentation to the +[managing updates](#app/managing-updates.md) docs should help people manage +this. + +We'll need to update the Node.js version in the starter whenever the active LTS +version changes. + +Folks who need a bunch more out of their operating system packages will need to +switch from the `slim` flavor which only involves updating the `Dockerfile`. It +is possible some will not realize they need more than `slim` until they run the +Docker image (which many people will only do in production). However the +likelihood of this impacting anyone is pretty low. diff --git a/docs/decisions/022-report-only-csp.md b/docs/decisions/022-report-only-csp.md new file mode 100644 index 0000000..4a7b7c5 --- /dev/null +++ b/docs/decisions/022-report-only-csp.md @@ -0,0 +1,31 @@ +# Report-only CSP + +Date: 2023-07-14 + +Status: accepted + +## Context + +The Epic Stack uses a strict +[Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP). +All the reasons for this explained in +[the decision document](./008-content-security-policy.md) still apply. However, +As people adapt the Epic Stack to their own needs, they may easily forget to add +important sources to the CSP. This can lead to a frustrating experience for new +users of the Epic Stack. + +There's an option for CSPs called `report-only` which allows the browser to +report CSP violations without actually blocking the resource. This turns the CSP +into an opt-in which follows our [guiding principle](#app/guiding-principles.md) +of "Minimize Setup Friction" (similar to deferring setup of third-party services +until they're actually needed). + +## Decision + +Enable report-only on the CSP by default. + +## Consequences + +New users of the Epic Stack won't be blocked by the CSP by default. But this +also means they won't be as safe by default. We'll need to make sure enforcing +the CSP is documented well. diff --git a/docs/decisions/023-route-based-dialogs.md b/docs/decisions/023-route-based-dialogs.md new file mode 100644 index 0000000..4fd92eb --- /dev/null +++ b/docs/decisions/023-route-based-dialogs.md @@ -0,0 +1,45 @@ +# Route-based Dialogs (aka Modals) + +Date: 2023-07-14 + +Status: accepted + +## Context + +Dialogs (also known as modals) are often a crutch for poor UX design. They are +often used when you haven't thought through the design of the page within the +context of the user's intentions. + +They aren't always bad though. Sometimes they are useful to provide a +confirmation step before a destructive action. For this we already have the +`useDoubleCheck` hook which makes it easier to help the user confirm their +action, but using a dialog gives you the opportunity to explain to the user a +bit more before the action is completed. + +However, using Dialogs for routes is problematic. Dialogs without animations are +poor UX. But server rendering animations is problematic because it means the +user has to wait for the animation code to load before they see the content they +came for. + +Unsplash solves this problem by using dialogs for images when you click on them, +but when you refresh the page you see that image's page. This is an intentional +decision by them and I'm sure they weighed the pros and cons for this UX. +However, it's not often this is a good user experience. + +Until today, the Epic Stack used route-based dialogs for the 2FA flow and the +avatar edit experience. I like using routes for these so it's easy to link the +user directly to these pages and makes it easier to navigate in and out of them. + +These are definitely not a good use of route-based dialogs. It certainly doesn't +make sense to render it as a dialog for a client-navigation but something else +for landing on that page like unsplash does for its images. + +## Decision + +Remove route-based dialogs from the Epic Stack. + +## Consequences + +A better UX. What used to be dialogs will now simply be pages. To help with +navigation, we'll need to use breadcrumbs to help the user orient themselves and +find a way back to where they came from. diff --git a/docs/decisions/024-change-email.md b/docs/decisions/024-change-email.md new file mode 100644 index 0000000..e084ed9 --- /dev/null +++ b/docs/decisions/024-change-email.md @@ -0,0 +1,68 @@ +# Change Email + +Date: 2023-07-26 + +Status: accepted + +## Context + +For most websites, the user's email is the primary mechanism for authenticity. +You sign up with your email, password reset involves the email, and often you +sign in with your email. + +For various reasons, users may wish to maintain their account data, but change +their email address. There are a few important considerations for managing this: + +1. The user may no longer have access to the current email address. +1. If the user typos their email address, then they may lose access to their + account. +1. If an adversary gets temporary access to a user's account, they may be able + to change the victim's email address to one they own. + +There are a few ways to address these concerns. Here are a few things you could +do (some in combination) + +- Let the user change the email with no validation +- Notify the new email address of the change +- Notify the old email address of the change +- Require confirmation of the new address before allowing the change +- Require confirmation of the old address before allowing the change +- Require a two-factor code before allowing the change + +The ultimate secure approach would be: + +- Require a two-factor code and confirmation from the old and new address before + allowing the change. + +This has a few problems: + +1. Not all users have 2FA enabled +2. Users don't always have access to their old address + +If you really needed that level of security, you could require 2FA and users +could reach out to support if they don't have access to the old email to plead +their case. + +However, there's a middle-ground: + +- Require a two-factor code from users who have it enabled, receive confirmation + of the new address, and notify the old address. + +This strikes a good balance of being easy for the user, reducing the number of +support requests, and security. + +## Decision + +We're going to require recent (within the last 2 hours) verification of the +two-factor code if the user has it enabled, require confirmation of the new +address, and notify the old address of the change. + +## Consequences + +This will require supporting a mechanism for tracking when the last 2FA code was +entered (just storing the time in the session). This will also require a new +verification for confirming the new address (utilizing existing verification +utilities we have for onboarding the user in the first place). + +It's a little bit more complicated than just letting the user change their email +address, but will hopefully reduce the problems described. diff --git a/docs/decisions/025-rate-limiting.md b/docs/decisions/025-rate-limiting.md new file mode 100644 index 0000000..4419cbf --- /dev/null +++ b/docs/decisions/025-rate-limiting.md @@ -0,0 +1,62 @@ +# Rate Limiting + +Date: 2023-08-10 + +Status: accepted + +## Context + +Adversaries can sometimes attempt to break into user's accounts by guessing +their passwords. This is known as a brute force attack. + +Or, sometimes a bad guy may not like you and want to do you harm so they will +trigger you to send people a lot of emails by hitting your `/signup` or +`/settings/profile/change-email` endpoint over and over again. Doing this will +reduce your reputation with email providers and may cause your emails to be +flagged as spam. + +A common way to reduce the impact and likelihood of this is to rate limit +requests. This means that you only allow a certain number of requests from a +given IP address within a certain time period. + +There are established patterns and libraries for doing this. The most popular +and well maintained library for express is +[express-rate-limit](https://npm.im/express-rate-limit). + +One challenge with rate limiting in a production environment is if you have +multiple instances of your application running behind a load balancer (which in +our case is Fly). In this case, you need to ensure that the rate limit is +applied across all instances and not just to each individual instance. +`express-rate-limit` allows you to do this by using a shared storage mechanism. +A common solution is Redis or memcached. + +Rate limiting doesn't completely eliminate the problem of triggering unsolicited +emails (CSRF tokens will do an even better job of reducing those) but it does +help a great deal. + +Another thing to consider is what the rate limit levels will be. The entire +application should not necessarily have the same rate limit. Users of web +applications will often perform many more GET requests than they do POST +requests for example. So some endpoints and methods will require "stronger" rate +limits than others. + +## Decision + +We will use `express-rate-limit` to rate limit requests to our application. We +will also use the built-in memory storage mechanism as the default. This is good +enough for many applications and is the simplest to implement. Evolving to a +Redis based solution should not require a great deal of extra effort for folks +requiring that additional level of protection. + +We'll have a stronger rate limit on non-GET requests in general and an even +stronger rate limit on certain endpoints that are more likely to be abused. + +## Consequences + +This could mean that folks who are using the application from a shared IP +address (such as a corporate network) may be rate limited more aggressively than +we would like. This is a trade-off we are willing to make for now. + +Our default levels for rate limiting could also be overly aggressive for some +people's use cases causing confusion. So we'll need to document this to help +people be made aware of the potential issue and how to resolve it. diff --git a/docs/decisions/026-path-aliases.md b/docs/decisions/026-path-aliases.md new file mode 100644 index 0000000..5d11cf3 --- /dev/null +++ b/docs/decisions/026-path-aliases.md @@ -0,0 +1,36 @@ +# Path Aliases + +Date: 2023-08-14 + +Status: superseded by [031-imports](./031-imports.md) + +## Context + +It's pretty common to configure TypeScript to have path aliases for imports. +This allows you to avoid relative imports and makes it easier to move files +around without having to update imports. + +When the Epic Stack started, we used path imports that were similar to those in +the rest of the Remix ecosystem: `#` referenced the `app/` directory. We added +`tests/` to make it easier to import test utils. + +However, we've found that this is confusing for new developers. It's not clear +what `#` means, and seeing `import { thing } from 'tests/thing'` is confusing. I +floated the idea of adding another alias for `@/` to be the app directory and or +possibly just moving the `#` to the root and having that be the only alias. But +at the end of the day, we're using TypeScript which will prevent us from making +mistakes and modern editors will automatically handle imports for you anyway. + +At first it may feel like a pain, but less tooling magic is better and editors +can really help reduce the pain. Additionally, we have ESLint configured to sort +imports for us so we don't have to worry about that either. Just let the editor +update the imports and let ESLint sort them. + +## Decision + +Remove the path aliases from the `tsconfig`. + +## Consequences + +This requires updating all the imports that utilized the path aliases to use +relative imports. diff --git a/docs/decisions/027-toasts.md b/docs/decisions/027-toasts.md new file mode 100644 index 0000000..3474456 --- /dev/null +++ b/docs/decisions/027-toasts.md @@ -0,0 +1,36 @@ +# Toasts + +Date: 2023-08-14 + +Status: accepted + +## Context + +In the Epic Stack we used the Shadcn toast implementation. This worked ok, but +it did require a lot of custom code for ourselves and did a poor job of managing +multiple toast messages. + +We also had a shared `flash` session implementation for both toasts and +confetti. This was overly complex. + +There's another library +[someone told me about](https://twitter.com/ayushverma1194/status/1674848096155467788) +that is a better fit. It's simpler and has an API sufficient to our use cases. + +It's also sufficiently customizable from a design perspective as well. And it's +actively developed. + +## Decision + +Remove our own toast implementation and use the library instead. + +Also separate the toast and confetti session implementations. Toasts can +continue to use a regular session, but confetti will be a much simpler cookie. + +## Consequences + +This will limit the level of customizability because we're now relying on a +library for managing toast messages, however it also reduces the maintenance +burden for users of the Epic Stack. + +This will also simplify the confetti implementation. diff --git a/docs/decisions/028-permissions-rbac.md b/docs/decisions/028-permissions-rbac.md new file mode 100644 index 0000000..538ad72 --- /dev/null +++ b/docs/decisions/028-permissions-rbac.md @@ -0,0 +1,84 @@ +# Permissions (RBAC) + +Date: 2023-08-14 + +Status: accepted + +## Context + +Originally, the Epic Stack had a `role` and `permission` model which was quite +limited in its use case. It was not very useful and not based on any real world +scenario: + +```prisma +model Role { + id String @id @unique @default(cuid()) + name String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] + permissions Permission[] +} + +model Permission { + id String @id @unique @default(cuid()) + name String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + roles Role[] +} +``` + +There are various ways to implement permissions, but a common approach is called +[Role Based Access Control (RBAC)](https://auth0.com/intro-to-iam/what-is-role-based-access-control-rbac). +This is a very flexible approach and can be used in many different ways. As a +more established approach it's also easier to find resources to learn about and +understand it. + +## Decision + +We're changing the implementation to follow a RBAC model: + +```prisma +model Permission { + id String @id @default(cuid()) + action String // e.g. create, read, update, delete + entity String // e.g. note, user, etc. + access String // e.g. own or any + description String @default("") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + roles Role[] + + @@unique([action, entity, access]) +} + +model Role { + id String @id @default(cuid()) + name String @unique + description String @default("") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] + permissions Permission[] +} +``` + +This allows us to have much more fine grained control over our permissions. +Additionally, we can create utilities for determining whether a user has +permission to perform an action and disallow them from doing so if they do not. + +## Consequences + +This is a breaking change for the Epic Stack. Anyone wanting to adopt this +permissions model will need to perform a database migration. However, it's +important that we make this change now because the previous model was not great. +This one is. diff --git a/docs/decisions/029-remix-auth.md b/docs/decisions/029-remix-auth.md new file mode 100644 index 0000000..86e5926 --- /dev/null +++ b/docs/decisions/029-remix-auth.md @@ -0,0 +1,22 @@ +# Remix Auth + +Date: 2023-08-14 + +Status: accepted + +## Context + +At the start of Epic Stack, we were using +[remix-auth-form](https://github.com/sergiodxa/remix-auth-form) for our +username/password auth solution. This worked fine, but it really didn't give us +any value over handling the auth song-and-dance ourselves. + +## Decision + +Instead of relying on remix-auth for handling authenticating the user's login +form submission, we'll manage it ourselves. + +## Consequences + +This mostly allows us to remove some code. However, we're going to be keeping +remix auth around for GitHub Auth diff --git a/docs/decisions/030-github-auth.md b/docs/decisions/030-github-auth.md new file mode 100644 index 0000000..6eca20a --- /dev/null +++ b/docs/decisions/030-github-auth.md @@ -0,0 +1,69 @@ +# GitHub Auth + +Date: 2023-08-14 + +Status: accepted + +## Context + +Many applications require integrating with third party authentication providers. +For this reason, we want to support the idea of "connections" as a built-in part +of the Epic Stack. + +There are many different providers we could support, but many people need to +support more than just one. By building things in a way that allows us to +support more than just a single auth provider, it allows us to also make it easy +to swap to a different provider as needed. + +Many auth providers support OAuth2, but increasingly, many are also supporting +OpenID Connect. OpenID Connect is a layer on top of OAuth2 that provides a +standardized way to get user information from the auth provider. + +Sadly, GitHub (a common popular auth provider for many developer-focused apps) +does not support OpenID, however, by using +[`remix-auth`](https://github.com/sergiodxa/remix-auth), we can easily support +GitHub as a built-in implementation and allow people to swap it out for whatever +OAuth2 or OIDC auth provider they have (if OIDC, they can use +[web-oidc](https://github.com/sergiodxa/web-oidc)). + +## Decision + +We will update the database schema to support multiple auth providers with a +model called `Connection`: + +```prisma +model Connection { + id String @id @default(cuid()) + providerName String + providerId String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String + + @@unique([providerName, providerId]) + @@unique([providerId, userId]) +} +``` + +We'll also build the appropriate callback URL handlers and UI to allow people to +manage their connections. + +## Consequences + +With third party auth, this means that users may not have passwords. So we'll +need to handle that situation and allow users to onboard without the use of +passwords. We'll also need to prevent them from deleting all their connections +until they've created a password. + +There are a number of states for the user to be in within the callback as well +which all will need to be considered. All of these states will be tested to +ensure they continue to function properly as people tune things for their needs. + +Additionally, we'll need to account for the fact that some folks don't want to +set up the GitHub login flow from the start (to keep in line with our +[Minimize Setup Friction guiding principle](#app/guiding-principles.md)), so +we'll have to make sure that the app still runs properly without GitHub auth +configured. diff --git a/docs/decisions/031-imports.md b/docs/decisions/031-imports.md new file mode 100644 index 0000000..ebd8f47 --- /dev/null +++ b/docs/decisions/031-imports.md @@ -0,0 +1,76 @@ +# Imports + +Date: 2023-08-16 + +Status: accepted + +## Context + +Recently, we removed the `~/*` and `tests/*` aliases in favor of relative +imports. The arguments for this are described in +[026-path-aliases](./026-path-aliases.md). While the arguments are sound, the +big challenge with this approach is the fact that there are some times where you +need to type out the import for something and doing that is a huge pain with +relative routes. + +The issue is the fact that you can choose one of these options: + +1. Very flat files +2. Long relative imports +3. Path aliases + +Keeping files flat is just not a great option because it requires exceedingly +long filenames for longer routes and it makes it hard to find files. Long +relative imports are just a pain to type out and they are hard to read, copy, +and manually modify. + +Despite the magic of Path aliases, they are actually a standard `package.json` +supported feature. Sort of. +[The `"imports"` field](https://nodejs.org/api/packages.html#imports) in +`package.json` allows you to configure aliases for your imports. It's not +exactly the same as TypeScript Path aliases, and using them doesn't give you +autocomplete with TypeScript +([yet](https://github.com/microsoft/TypeScript/pull/55015)), but if you +configure both, then you can get the best of both worlds! + +By using the `"imports"` field, you don't have to do any special configuration +for `vitest` or `eslint` to be able to resolve imports. They just resolve them +using the standard. + +And by using the `tsconfig.json` `paths` field configured in the same way as the +`"imports"` field, you get autocomplete and type checking for your imports. This +should hopefully be temporary until TypeScript supports the `"imports"` field +directly. + +One interesting requirement for `imports` is that they _must_ start with the `#` +character to disambiguate from other imports. This is a bit annoying, but it's +something that's not difficult to get used to. They also _must not_ start with +`#/`. So you have to do `#app` instead of `#/app`. This is also a bit odd, but +again it's just a matter of familiarity. So it's no big deal. + +## Decision + +We're going to configure `"imports"` in the `package.json` and `paths` in the +`tsconfig.json` to use path aliases for imports. + +We'll set it to `"#*": "./*"` which will allow us to import anything in the root +of the repo with `#/`. + +## Consequences + +This is unfortunately _very_ soon after making the decision to drop the alias. +But I see this as slightly different because we're only using the alias to make +up for a shortcoming in TypeScript temporarily. Once TypeScript supports the +`"imports"` field, we can drop the `paths` field and just use the `"imports"` +standard for Node.js. + +If someone wants to use the Epic Stack without Node.js, and their runtime +doesn't support `package.json` imports (I'm not sure whether other runtimes do +or not) they'll have to continue using the paths configuration. But that's not a +consideration here. + +## Credits + +Big thank you to +[Mateusz Burzyński](https://twitter.com/AndaristRake/status/1691807097078317287) +for helping with this one! diff --git a/docs/decisions/032-csrf.md b/docs/decisions/032-csrf.md new file mode 100644 index 0000000..182f767 --- /dev/null +++ b/docs/decisions/032-csrf.md @@ -0,0 +1,33 @@ +# Cross-Site Request Forgery + +Date: 2023-10-11 + +Status: superseded by [035](./035-remove-csrf.md) + +## Context + +You can learn all about Cross-Site Request Forgery from +[EpicWeb.dev's forms workshop](https://forms.epicweb.dev/07). The TL;DR idea is +that a malicious adversary can trick a user into making a request to your server +that they did not intend to make. This can be used to make requests to your +server that can do anything that the user can do. + +To defend against this attack, we need to ensure that the request is coming from +a page that we control. We do this by adding a CSRF token to the page and +checking that the token is present in the request. The token is generated by our +own server and stored in an HTTP-only cookie. This means that it can't be +accessed by third parties, but it will be sent with every request to our server. +We also send that same token within the form submission and then check that the +token in the form matches the token in the cookie. + +Once set up, this is a fairly straightforward thing to do and there are great +tools to help us do it (`remix-utils` specifically). + +## Decision + +We'll implement CSRF protection to all our authenticated forms. + +## Consequences + +This is a tiny bit invasive to the code, but it doesn't add much complexity. +It's certainly worth the added security. diff --git a/docs/decisions/033-honeypot.md b/docs/decisions/033-honeypot.md new file mode 100644 index 0000000..d906e50 --- /dev/null +++ b/docs/decisions/033-honeypot.md @@ -0,0 +1,35 @@ +# Honeypot Fields + +Date: 2023-10-11 + +Status: accepted + +## Context + +You can learn all about Honeypot Fields from +[EpicWeb.dev's forms workshop](https://forms.epicweb.dev/06). The TL;DR idea is +spam bots go around the internet filling in forms all over the place in hopes of +getting their spammy links on your site among other things. This causes extra +load on your server and in some cases can cause you issues. For example, our +onboarding process sends an email to the user. If a spam bot fills out the form +with a random email address, we'll send an email to that address and cause +confusion in the best case or get marked as spam in the worst case. + +Most of these spam bots are not very sophisticated and will fill in every field +on the form (even if those fields are visually hidden). We can use this to our +advantage by adding a field to the form that is visually hidden and then +checking that it is empty when the form is submitted. If it is not empty, we +know that the form was filled out by a spam bot and we can ignore it. + +There are great tools to help us accomplish this (`remix-utils` specifically). + +## Decision + +We'll implement Honeypot Fields to all our public-facing forms. Authenticated +forms won't need this because they're not accessible to spam bots anyway. + +## Consequences + +This is a tiny bit invasive to the code, but it doesn't add much complexity. +It's certainly worth the added benefits to our server (and email +deliverability). diff --git a/docs/decisions/034-source-maps.md b/docs/decisions/034-source-maps.md new file mode 100644 index 0000000..f60b73b --- /dev/null +++ b/docs/decisions/034-source-maps.md @@ -0,0 +1,24 @@ +# Source Maps + +Date: 2023-11-03 + +Status: accepted + +## Context + +Read [016-source-maps](016-source-maps.md) to come up to speed on the context. + +Because of our built-in sentry support, we need to generate source maps, but we +do not necessarily need to ship source maps to the client. Despite the arguments +made in the original source map decision document, the benefit of shipping +source maps over not shipping them is reduced thanks to Sentry. And the dangers +are still present. + +## Decision + +Delete source maps after they've been uploaded to Sentry. + +## Consequences + +This will mean debugging a production application in the client will be really +hard, but with Sentry properly configured it should definitely not be a problem. diff --git a/docs/decisions/035-remove-csrf.md b/docs/decisions/035-remove-csrf.md new file mode 100644 index 0000000..854c81d --- /dev/null +++ b/docs/decisions/035-remove-csrf.md @@ -0,0 +1,43 @@ +# Remove CSRF + +Date: 2024-01-29 + +Status: accepted + +## Context + +Read more about the original CSRF decision in [032-csrf.md](./032-csrf.md). + +Modern browser support for `SameSite: Lax` and our use of that for all cookies +means that cookies are not sent on cross-site requests. This means that CSRF +protection is not needed for our cookies. + +There are however a few exceptions which motivated the original inclusion of +CSRF: + +- GET requests are not protected by `SameSite: Lax` and so are vulnerable to + CSRF attacks. However, we do not have any GET endpoints that perform mutations + on the server. The only GET endpoints we have are for fetching data and so + there is no meaningful CSRF attack that could be performed. +- The `POST /login` endpoint does not require cookies at all and so is + technically vulnerable to CSRF attacks. But anyone who could exploit this + endpoint would have to know the user's username and password anyway in which + case they could just log in as the user directly. + +With the addition of the honeypot field to prevent bots from submitting the +login form, the lack of vulnerability due to the cookie configuration, and the +fact that CSRF adds a bit of complexity to the code, it just doesn't seem worth +it to keep CSRF tokens around. + +## Decision + +Remove CSRF tokens from the codebase. + +## Consequences + +If someone adds a GET request which does mutate state, then this could be an +issue. However, a CSRF token could be added back for that specific mutation. +Also, if the cookie configuration is changed from `Lax` to `None` (useful in +various contexts, but certainly not a good default), then CSRF tokens would need +to be added back. So we'll add a comment to the code for configuring the cookie +mentioning this. diff --git a/docs/decisions/README.md b/docs/decisions/README.md new file mode 100644 index 0000000..72d0909 --- /dev/null +++ b/docs/decisions/README.md @@ -0,0 +1,7 @@ +# Decisions + +This directory contains all the decisions we've made for this starter template +and serves as a record for whenever we wonder why certain decisions were made. + +Decisions in here are never final. But these documents should serve as a good +way for someone to come up to speed on why certain decisions were made. diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..5ed13d4 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,143 @@ +# Deployment + +When you first create an Epic Stack repo, it should take you through a series of +questions to get your app setup and deployed. However, we'll document the steps +here in case things don't go well for you or you decide to do it manually later. +Here they are! + +## Deploying to Fly.io + +Prior to your first deployment, you'll need to do a few things: + +1. [Install Fly](https://fly.io/docs/getting-started/installing-flyctl/). + + > **Note**: Try `flyctl` instead of `fly` if the commands below won't work. + +2. Sign up and log in to Fly: + + ```sh + fly auth signup + ``` + + > **Note**: If you have more than one Fly account, ensure that you are signed + > into the same account in the Fly CLI as you are in the browser. In your + > terminal, run `fly auth whoami` and ensure the email matches the Fly + > account signed into the browser. + +3. Create two apps on Fly, one for staging and one for production: + + ```sh + fly apps create [YOUR_APP_NAME] + fly apps create [YOUR_APP_NAME]-staging + ``` + + > **Note**: Make sure this name matches the `app` set in your `fly.toml` + > file. Otherwise, you will not be able to deploy. + +4. Initialize Git. + + ```sh + git init + ``` + +- Create a new [GitHub Repository](https://repo.new), and then add it as the + remote for your project. **Do not push your app yet!** + + ```sh + git remote add origin + ``` + +5. Add secrets: + +- Add a `FLY_API_TOKEN` to your GitHub repo. To do this, go to your user + settings on Fly and create a new + [token](https://web.fly.io/user/personal_access_tokens/new), then add it to + [your repo secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) + with the name `FLY_API_TOKEN`. + +- Add a `SESSION_SECRET`, `INTERNAL_COMMAND_TOKEN`, and `HONEYPOT_SECRET` to + your fly app secrets, to do this you can run the following commands: + + ```sh + fly secrets set SESSION_SECRET=$(openssl rand -hex 32) INTERNAL_COMMAND_TOKEN=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME] + fly secrets set SESSION_SECRET=$(openssl rand -hex 32) INTERNAL_COMMAND_TOKEN=$(openssl rand -hex 32) HONEYPOT_SECRET=$(openssl rand -hex 32) --app [YOUR_APP_NAME]-staging + ``` + + > **Note**: If you don't have openssl installed, you can also use + > [1Password](https://1password.com/password-generator) to generate a random + > secret, just replace `$(openssl rand -hex 32)` with the generated secret. + +6. Create production database: + + Create a persistent volume for the sqlite database for both your staging and + production environments. Run the following (feel free to change the GB size + based on your needs and the region of your choice + (`https://fly.io/docs/reference/regions/`). If you do change the region, make + sure you change the `primary_region` in fly.toml as well): + + ```sh + fly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME] + fly volumes create data --region sjc --size 1 --app [YOUR_APP_NAME]-staging + ``` + +7. Attach Consul: + +- Consul is a fly-managed service that manages your primary instance for data + replication + ([learn more about configuring consul](https://fly.io/docs/litefs/getting-started/#lease-configuration)). + + ```sh + fly consul attach --app [YOUR_APP_NAME] + fly consul attach --app [YOUR_APP_NAME]-staging + ``` + +8. Commit! + + The Epic Stack comes with a GitHub Action that handles automatically + deploying your app to production and staging environments. + + Now that everything is set up you can commit and push your changes to your + repo. Every commit to your `main` branch will trigger a deployment to your + production environment, and every commit to your `dev` branch will trigger a + deployment to your staging environment. + +--- + +### Optional: Email service setup + +Find instructions for this optional step in [the email docs](./email.md). + +### Optional: Error monitoring setup + +Find instructions for this optional step in +[the error tracking docs](./monitoring.md). + +### Optional: Connecting to your production database + +Find instructions for this optional step in [the database docs](./database.md). + +### Optional: Seeding Production + +Find instructions for this optional step in [the database docs](./database.md). + +## Deploying locally + +If you'd like to deploy locally you definitely can. You need to (temporarily) +move the `Dockerfile` and the `.dockerignore` to the root of the project first. +Then you can run the deploy command: + +``` +mv ./other/Dockerfile Dockerfile +mv ./other/.dockerignore .dockerignore +fly deploy +``` + +Once it's done, move the files back: + +``` +mv Dockerfile ./other/Dockerfile +mv .dockerignore ./other/.dockerignore +``` + +You can keep the `Dockerfile` and `.dockerignore` in the root if you prefer, +just make sure to remove the move step from the `.github/workflows/deploy.yml`. diff --git a/docs/email.md b/docs/email.md new file mode 100644 index 0000000..c42d80f --- /dev/null +++ b/docs/email.md @@ -0,0 +1,22 @@ +# Email + +This document describes how to get [Resend](https://resend.com) (the Epic Stack +email provider) setup. + +> **NOTE**: this is an optional step. During development the emails will be +> logged to the terminal and in production if you haven't set the proper +> environment variables yet you will get a warning until you set the environment +> variables. + +Create [an API Key](https://resend.com/api-keys) and set `RESEND_API_KEY` in +both prod and staging: + +```sh +fly secrets set RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" --app [YOUR_APP_NAME] +fly secrets set RESEND_API_KEY="re_blAh_blaHBlaHblahBLAhBlAh" --app [YOUR_APP_NAME]-staging +``` + +Setup a [custom sending domain](https://resend.com/domains) and then make sure +to update the `from` email address in `app/utils/email.server.ts` and the +`expect(email.from).toBe` in `tests/e2e/onboarding.test.ts` to the one you want +your emails to come from. diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..2bbee76 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,119 @@ +# Examples + +In keeping with the [guiding principle](guiding-principles.md) to "Minimize +Setup Friction," there are some things that may be pretty common for web +applications to do, but aren't common enough to be included in the main +template. + +This page links to examples of how to implement some things with the Epic Stack. + +- [Framer Motion](https://github.com/kentcdodds/epic-stack-with-framer-motion) + by [@kentcdodds](https://github.com/kentcdodds): Using client hints to avoid + content layout shift with `prefers-reduced-motion` and framer motion + animations. +- [Cross-site Request Forgery Protection (CSRF)](https://github.com/epicweb-dev/epic-stack-example-csrf) + by [@kentcdodds](https://github.com/kentcdodds): An example of the Epic Stack + with CSRF protection on forms. +- [Epic Stack + OpenAI](https://github.com/kentcdodds/epic-ai): by + [@kentcdodds](https://github.com/kentcdodds): An example of the Epic Stack + with OpenAI's GPT API (enhances the notes feature with "generate" buttons). +- [Prisma Client Extensions](https://github.com/L-Steinmacher/epic-stack-with-prisma-client-extensions) + by + [@L-Steinmacher](https://github.com/L-Steinmacher/epic-stack-with-prisma-client-extensions): + An example of the Epic Stack with Prisma Client extensions activated for enum + like behavior in SQLite. +- [Epic Stack + Storybook](https://github.com/moishinetzer/epic-stack-with-storybook): + by [@moishinetzer](https://github.com/moishinetzer): An example of the Epic + Stack with Storybook. It also showcases creating a Remix stub, which is very + helpful for isolating Remix-specific components inside of Storybook. +- [Socket.IO](https://github.com/L-Steinmacher/epic-stack-with-socket.io): by + [@L-Steinmacher](https://github.com/L-Steinmacher): An example of setting up + using websockets in the Epic Stack using the `Socket.IO` library. +- [User Impersonation](https://github.com/alan2207/epic-stack-with-user-impersonation) + by [@alan2207](https://github.com/alan2207): An example Remix application + showcasing how to implement user impersonation in the Epic Stack. +- [Epic Stack + Tailwind CSS Plugin](https://github.com/hakimLyon/epic-stack-with-tailwind-css-plugin) + by [@hakimLyon](https://github.com/hakimLyon): An example of the Epic Stack + with Tailwind CSS Plugin. +- [Epic Stack + GitHub Auth](https://github.com/kentcdodds/epic-github-auth) by + [@kentcdodds](https://github.com/kentcdodds): An example of the Epic Stack + with GitHub Auth. +- [Epic Stack + MongoDB as the Database](https://github.com/hakimLyon/epic-stack-with-prisma-mongodb) + by [@hakimLyon](https://github.com/hakimLyon): An example of the Epic Stack + with Prisma using MongoDB as the database. +- [Epic Stack Custom Themes](https://github.com/kiliman/epic-stack-theme) by + [@kiliman](https://github.com/kiliman): An example showing how to create a + custom theme using the + [`shadcn-custom-theme`](https://github.com/kiliman/shadcn-custom-theme) tool. +- [Epic Stack + OpenID Connect Auth (Google)](https://github.com/kentcdodds/epic-oidc) + by [@kentcdodds](https://github.com/kentcdodds): An example of the Epic Stack + with OpenID Connect Auth (Google) using [web-oidc](https://npm.im/web-oidc) + and [remix-auth](https://npm.im/remix-auth). +- [Epic Stack + Fathom Analytics](https://github.com/xstevenyung/epic-stack-with-fathom-analytics) + by [@xstevenyung](https://github.com/xstevenyung): An example of the Epic + Stack with Fanthom Analytics via CDN +- [Epic Stack + Tenant Users](https://github.com/offseat/epic-stack-tenant) by + [@vinstah](https://github.com/vinstah): An example of the Epic Stack with + tenant users and members starter +- [Epic Stack + i18n](https://github.com/rperon/epic-stack-with-i18n/) by + [@rperon](https://github.com/rperon): An example of the Epic Stack with i18n + using [i18next](https://www.i18next.com/) and + [remix-18next](https://github.com/sergiodxa/remix-i18next) +- [Epic Stack + Argos](https://github.com/jsfez/epic-stack-with-argos) by + [@jsfez](https://github.com/jsfez): An example of the Epic Stack with + [Argos](https://www.argos-ci.com/) for visual testing +- [Epic Stack monorepo with pnpm + turbo](https://github.com/PhilDL/epic-stack-monorepo): + An example of the Epic Stack in a monorepo setup, configs packages, UI + package, and "client-hints" example package. +- [Epic Stack + passkeys/webauthn](https://github.com/rperon/epic-stack-with-passkeys/) + by [@rperon](https://github.com/rperon): An example of the Epic Stack with + passkeys using + [remix-auth-webauthn](https://github.com/alexanderson1993/remix-auth-webauthn) + and [remix-auth](https://npm.im/remix-auth). +- [Epic Stack with jsx-email](https://github.com/djhi/epic-stack-jsx-email): An + example of the Epic Stack that uses [jsx-email](https://jsx.email/) instead of + [react-email](https://react.email/) +- [Epic Stack with Query](https://github.com/gc-victor/epic-stack-with-query) by + [@gc-victor](https://github.com/gc-victor): An example of the Epic Stack using + [Query](https://github.com/gc-victor/query). Using Epic Stack with Query, you + can access the databases directly from your application while enjoying a range + of benefits from Query to manage your databases, including + [Query CLI](https://github.com/gc-victor/query/blob/main/README.md#cli), + [Query API](https://github.com/gc-victor/query/blob/main/README.md#apis), + [Query Funcions](https://github.com/gc-victor/query?tab=readme-ov-file#function) + and [Query Studio](https://github.com/gc-victor/query-studio). +- [`has-js` class](https://github.com/epicweb-dev/epic-stack-example-has-js-class) + by [@kentcdodds](https://github.com/kentcdodds): An example of adding a + `has-js` class on the `` element so you can use that to easily display + different content for users who do not have JavaScript enabled. +- [Confetti](https://github.com/kentcdodds/epic-stack-example-confetti) by + [@kentcdodds](https://github.com/kentcdodds): Display confetti when the user + does something worthy of a celebration. +- [Epic Stack Border animation](https://github.com/hakimLyon/epic-stack-with-css-border-animation) + by [@hakimLyon](https://github.com/hakimLyon): An example of Epic Stack with a + CSS border animation. + +## How to contribute + +[![Kent screencast showing the examples page](https://github.com/epicweb-dev/epic-stack/assets/1500684/7074f1db-c918-42c6-a724-0b082168395f)](https://www.epicweb.dev/tips/contribute-an-epic-stack-example) + +Watch: +[Contribute an Epic Stack Example](https://www.epicweb.dev/tips/contribute-an-epic-stack-example) + +You don't need permission to contribute an example. Feel free to create your own +repository based on the Epic Stack and add whatever you like for your example. +Here are some tips: + +1. Create an `init` commit as soon as you generate the project, before you make + any changes. That way people can look at the commit history of your example + and see what you've added. +2. Update the `README.md` with some information about what your example is about + and call out any interesting things you've done. +3. Add the tags "epic-stack" and "epic-stack-example" so it will appear on + [this page on GitHub](https://github.com/topics/epic-stack-example). +4. It's not normally necessary to deploy your example to production (simply + comment out the deployment part of the GitHub workflow), but you can if you + like. + +Once you've made your repo, simply open a pull request to this page and add your +example to the bottom of the list with a brief description. diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..c9c046c --- /dev/null +++ b/docs/features.md @@ -0,0 +1,55 @@ +# Features + +Here are a few things you get today: + +- [Remix](https://remix.run/) is the Web Framework of choice +- [Fly app deployment](https://fly.io/) with [Docker](https://www.docker.com/) +- Multi-region, distributed, production-ready + [SQLite Database](https://sqlite.org/) with + [LiteFS](https://fly.io/docs/litefs/). +- Healthcheck endpoint for + [Fly backups region fallbacks](https://fly.io/docs/reference/configuration/#services-http_checks) +- [Grafana](https://grafana.com/) Dashboards of the running app +- [GitHub Actions](https://github.com/features/actions) with testing and deploy + on merge for both production and staging environments +- Email/Password Authentication with + [cookie-based sessions](https://remix.run/utils/sessions#md-createcookiesessionstorage) +- Two-Factor Authentication (2fa) with support for authenticator apps. +- Transactional email with [Resend](https://resend.com/) and forgot + password/password reset support. +- Progressively Enhanced and fully type safe forms with + [Conform](https://conform.guide/) +- Database ORM with [Prisma](https://prisma.io/) +- Role-based User Permissions. +- Custom built image hosting +- Caching via [cachified](https://npm.im/cachified): Both in-memory and + SQLite-based (with + [better-sqlite3](https://github.com/WiseLibs/better-sqlite3)) +- Styling with [Tailwind](https://tailwindcss.com/) +- An excellent, customizable component library with + [Radix UI](https://www.radix-ui.com/) +- End-to-end testing with [Playwright](https://playwright.dev/) +- Local third party request mocking with [MSW](https://mswjs.io/) +- Unit testing with [Vitest](https://vitest.dev/) and + [Testing Library](https://testing-library.com/) with pre-configured Test + Database +- Code formatting with [Prettier](https://prettier.io/) +- Linting with [ESLint](https://eslint.org/) +- Static Types with [TypeScript](https://typescriptlang.org/) +- Runtime schema validation with [zod](https://zod.dev/) +- Error monitoring with [Sentry](https://sentry.io/welcome/) +- Light/Dark/System mode (without a flash of incorrect theme) + +Here are some things that will likely find their way into the Epic Stack (or the +docs examples) in the future: + +- Logging +- Ecommerce support with [Stripe](https://stripe.com/) +- Ethical site analytics with [fathom](https://usefathom.com/) +- Internationalization +- Image optimization route and component +- Feature flags +- Documentation on production data seeding process + +Not a fan of bits of the stack? Fork it, change it, and use +`npx create-remix --template your/repo`! Make it your own. diff --git a/docs/fonts.md b/docs/fonts.md new file mode 100644 index 0000000..f86c0df --- /dev/null +++ b/docs/fonts.md @@ -0,0 +1,152 @@ +# Fonts + +The Epic Stack uses the default font that comes with Tailwind CSS. This is a +good default font but you may want to use a custom font for your site and it's +easy to do so. + +## Using Custom Fonts + +You can use custom fonts by adding them to the `./public/fonts` directory, +[Google Fonts](https://fonts.google.com/) is a good place to find open source +fonts. You will also need to add the `css` file for the font to the +`./app/styles` directory, if your font doesn't come with one (Google Fonts +don't) you can generate one using a tool like +[Transfonter](https://transfonter.org/). + +You may need to edit the `url` property in the `css` file to point to the +correct location of the font files, that path is relative to the `public` +folder. So it should look something like +`url('/fonts/yourfont/yourfont-200.woff2')`. + +Now you've added your font, there's a few places you need to update to use it. + +1. Add your font to the CSS variables. + + ```css + /* tailwind.css */ + @layer base { + :root { + --font-sans: ; + } + } + ``` + +2. Add your font to the `fontFamily` property. + + ```ts + // tailwind.config.ts + extend: { + ... + fontFamily: { + ... + sans: ['var(--font-sans)', ...defaultTheme.fontFamily.sans], + } + } + + ``` + +3. Import your font stylesheet. + + ```tsx + // root.tsx + import fontStyleSheetUrl from './styles/yourfont.css' + ``` + + Add the font stylesheet to the links array. + + ```tsx + // root.tsx + ... + { rel: 'preload', href: fontStyleSheetUrl, as: 'style' }, + { rel: 'stylesheet', href: fontStyleSheetUrl }, + ``` + +4. Expose and cache your fonts folder. + + ```ts + // server.ts + ... + app.use( + '/fonts', + // Can aggressively cache fonts as they don't change often + express.static('public/fonts', { immutable: true, maxAge: '1y' }), + ) + ``` + +That's it! You can now use your custom font should now be available to use in +your site. + +## Font Metric Overrides + +When using custom fonts, your site elements may stretch or shrink to accommodate +the font. This is because the browser doesn't know the dimensions of the font +you're using until it arrives, which introduces Cumulative Layout Shift and +impact its web vitals. + +In Epic Stack, we fixed this by introducing +[Font Metric Overrides](https://github.com/epicweb-dev/epic-stack/pull/128/files). + +Follow the steps below to add Font Metric Overrides to your custom fonts. + +1. Generate the overrides for your font. + + You can use [fontpie](https://www.npmjs.com/package/fontpie) utility to + generate the overrides. For each of your fonts, write the following in your + terminal: + + ```bash + npx fontpie ./local/font/location.woff2 -w font-weight -s normal/italic -n YourFont + ``` + + #### Example + + ```sh + npx fontpie ./public/fonts/nunito-sans/nunito-sans-v12-latin_latin-ext-200.woff2 -w 200 -s normal -n NunitoSans + ``` + + ```css + @font-face { + font-family: 'NunitoSans Fallback'; + font-style: normal; + font-weight: 200; + src: local('Arial'); + ascent-override: 103.02%; + descent-override: 35.97%; + line-gap-override: 0%; + size-adjust: 98.13%; + } + ``` + + If you've got a lot of font files to override, you can use + [fontpie-from-css](https://github.com/matt-kinton/fontpie-from-css) to + generate the overrides from a CSS file. + + ```sh + npx fontpie-from-css ./public/fonts/yourfont/yourfont.css + ``` + + **_Note:_** _If you've been following the steps above, you might have to copy + your `yourfont.css` file temporarily to the `./public` folder as + `fontpie-from-css` loads fonts relative to the CSS file._ + +2. Add the overrides to your font stylesheet. + + Use fontpie for every custom font used (including variants) or + fontpie-from-css and add the metric overrides to `yourfont.css`. + + _Ensure the original font has the `font-display: swap` property or the + fallback wouldn't work!_ + +3. Add the font fallback to the stylesheet. + + ```css + /* tailwind.css */ + @layer base { + :root { + --font-sans: ; + } + } + ``` + +That's it! You can now use your custom font without worrying about Cumulative +Layout Shift! diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..bbecf01 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,60 @@ +# Getting Started with the Epic Stack + +The Epic Stack is a [Remix Stack](https://remix.run/stacks). To start your Epic +Stack, run the following [`npx`](https://docs.npmjs.com/cli/v9/commands/npx) +command: + +```sh +npx create-epic-app@latest +``` + +This will prompt you for a project name (the name of the directory to put your +project). Once you've selected that, the CLI will start the setup process. + +Once the setup is complete, go ahead and `cd` into the new project directory and +run `npm run dev` to get the app started. + +Check the project README.md for instructions on getting the app deployed. You'll +want to get this done early in the process to make sure you're all set up +properly. + +If you'd like to skip some of the setup steps, you can set the following +environment variables when you run the script: + +- `SKIP_SETUP` - skips running `npm run setup` +- `SKIP_FORMAT` - skips running `npm run format` +- `SKIP_DEPLOYMENT` - skips deployment setup + +So, if you enabled all of these it would be: + +```sh +SKIP_SETUP=true SKIP_FORMAT=true SKIP_DEPLOYMENT=true npx create-epic-app@latest +``` + +Or, on windows: + +``` +set SKIP_SETUP=true && set SKIP_FORMAT=true && set SKIP_DEPLOYMENT=true && npx create-epic-app@latest +``` + +## Development + +- Initial setup: + + ```sh + npm run setup + ``` + +- Start dev server: + + ```sh + npm run dev + ``` + +This starts your app in development mode, rebuilding assets on file changes. + +The database seed script creates a new user with some data you can use to get +started: + +- Username: `kody` +- Password: `kodylovesyou` diff --git a/docs/guiding-principles.md b/docs/guiding-principles.md new file mode 100644 index 0000000..4e22222 --- /dev/null +++ b/docs/guiding-principles.md @@ -0,0 +1,30 @@ +# Epic Stack Guiding Principles + +Decisions about the Epic Stack should be guided by the following guiding +principles: + +- **Limit Services:** If we can reasonably build, deploy, maintain it ourselves, + do it. Additionally, if we can reasonably run it within our app instance, do + it. This saves on cost and reduces complexity. +- **Include Only Most Common Use Cases:** As a project generator, it is expected + that some code will necessarily be deleted, but implementing support for every + possible type of feature is literally impossible. _The starter app is not + docs_, so to demonstrate a feature or give an example, put that in the docs + instead of in the starter app. +- **Minimize Setup Friction:** Try to keep the amount of time it takes to get an + app to production as small as possible. If a service is necessary, see if we + can defer signup for that service until its services are actually required. + Additionally, while the target audience for this stack is apps that need scale + you have to pay for, we try to fit within the free tier of any services used + during the exploration phase. +- **Optimize for Adaptability:** While we feel great about our opinions, + ever-changing product requirements sometimes necessitate swapping trade-offs. + So while we try to keep things simple, we want to ensure teams using the Epic + Stack are able to adapt by switching between third party services to + custom-built services and vice-versa. +- **Only one way:** Avoid providing more than one way to do the same thing. This + applies to both the pre-configured code and the documentation. +- **Offline Development:** We want to enable offline development as much as + possible. Naturally we need to use third party services for some things (like + email), but for those we'll strive to provide a way to mock them out for local + development. diff --git a/docs/icons.md b/docs/icons.md new file mode 100644 index 0000000..c0f8850 --- /dev/null +++ b/docs/icons.md @@ -0,0 +1,41 @@ +# Icons + +The Epic Stack uses SVG sprites for +[optimal icon performance](https://benadam.me/thoughts/react-svg-sprites/). +You'll find raw SVGs in the `./other/svg-icons` directory. These are then +compiled into a sprite using the `npm run build:icons` script which generates +the `icon.svg` file and an `icons.json` manifest file that allows Typescript to +pick up the names of the icons. + +You can use [Sly](https://github.com/jacobparis-insiders/sly/tree/main/cli) to +add new icons from the command line. + +To add the `trash`, `pencil-1`, and `avatar` icons, run: + +```sh +npx sly add @radix-ui/icons trash pencil-1 avatar +``` + +If you don't specify the icons, Sly will show an interactive list of all the +icons available in the `@radix-ui/icons` collection and let you select the ones +you want to add. + +Sly has been configured in the Epic Stack to automatically add the icons to the +`./other/svg-icons` directory and to run the `npm run build:icons` script +automatically, so there are no extra steps to take. You can see the +configuration in the `./other/sly/sly.json` file. + +The SVGs used by default in the Epic Stack come from +[icons.radix-ui.com](https://icons.radix-ui.com/). You can download additional +SVG icons from there, or provide your own. Once you've added new files in the +directory, run `npm run build:icons` and you can then use the `Icon` component +to render it. The `icon` prop is the name of the file without the `.svg` +extension. We recommend using `kebab-case` filenames rather than `PascalCase` to +avoid casing issues with different operating systems. + +Note that the `./other/build-icons.ts` file automatically removes `width` and +`height` props from your SVGs to ensure they scale properly. + +By default, all the icons will have a height and width of `1em` so they should +match the font-size of the text they're next to. You can also customize the size +using the `size` prop. diff --git a/docs/managing-updates.md b/docs/managing-updates.md new file mode 100644 index 0000000..3a35fe4 --- /dev/null +++ b/docs/managing-updates.md @@ -0,0 +1,175 @@ +# Managing updates + +## Updating Node.js + +The Epic Stack runs a long-running Node.js server. It defaults to the current +active LTS version of node +([read the Node.js version decision document](./decisions/021-node-version.md)). + +If you wish to change the Node.js version, you can do so by updating the +`engines.node` property in the `package.json` file. + +```json +{ + "engines": { + "node": "20.3.1" + } +} +``` + +Make certain you do not use a version range here because this is used in the +`./other/build-server.ts` to compile the express server code. + +You will also want to update the `Dockerfile` to use the same version of Node.js +as the `package.json` file. + +```diff +- FROM node:18-bookworm-slim as base ++ FROM node:20.3.1-bookworm-slim as base +``` + +You'll find the +[Node.js versions available on Docker Hub](https://hub.docker.com/_/node). + +## Within the Epic Stack + +When you create a new project with the Epic Stack, a bunch of code is generated +for you. This code is completely yours and there is no way to update it other +than making manual changes. This is both a good thing and a bad thing. It's good +in the sense that you can tweak it to fit your specific use cases. But it's a +challenge because as the Epic Stack gets improvements there's no way to get +those automatically. You have to keep track of the improvements in the Epic +Stack and make those updates yourself. + +You shouldn't feel compelled to keep up-to-date with the latest of the Epic +Stack template. If what you're using is working fine for you then just keep +going with it. Only adopt changes as you feel the need to do so. Feel free to +peruse +[the Epic Stack's commit history](https://github.com/epicweb-dev/epic-stack/commits/main) +anytime you'd like to see what updates could be made to your project. + +## How to update NPM dependencies + +Another part of the Epic Stack is the dependencies of the project. These you +will also have to keep up-to-date yourself, but there is a bit of an automated +process to help you. + +It’s important to update your packages to get new features, bug fixes, and +security patches. +[NPM Check Updates](https://www.npmjs.com/package/npm-check-updates) is a CLI +that will help you safely make those updates. You can watch +[this youtube video](https://www.youtube.com/watch?v=0XQXGx3lLaU) for a +demonstration of how to do this. + +### See a list of packages that can be updated + +NPM packages follow [semantic versioning](https://semver.org). This command will +show you which packages can be updated and which major, minor, or patch versions +are available. + +```sh +npx npm-check-updates +``` + +Notice the colors: + +- Green = (non-major version zero) patch updates +- Cyan = minor updates +- Red = major or [version zero (0.y.z)](https://semver.org/#spec-item-4) updates + +### Update green patch versions first, all at once + +Since green patch version updates are meant for backward-compatible bug fixes, +it's ok to update them all at once. + +```sh +npx npm-check-updates -u --target patch +... +npm i +``` + +> Note: `npx npm-check-updates -u -t patch` updates all patch versions, +> including major version zero patch versions, which can break your code. If all +> your patch updates are green, feel free to use this command instead to update +> them all at once. + +Assuming package maintainers follow semantic versioning, updating patch versions +shouldn't break anything, but it's good practice to re-run your tests before +committing these changes. + +```sh +npm run test -- run +npm run test:e2e:run +``` + +If all tests pass, commit your changes. + +```sh +git add . +git commit -m "Updated patch versions" +``` + +### Update cyan minor versions second, one by one + +Minor version updates introduce new features in a backward-compatible way. This +is exciting and it's good practice to take some time to explore the new +functionality and apply relevant updates to your code base or plan to apply them +later. It's recommended you do this package by package instead of all at once. + +To check for the new package's features, check its release notes on GitHub. + +> If you haven't updated a fairly active package in a while, reading all its +> release notes can take some time. Take into consideration how important a +> package is for your project when choosing which to update first. + +```sh +npx npm-check-updates -u --filter +npm i +``` + +Again, assuming package maintainers follow semantic versioning updating patch +versions shouldn't break anything, but it's good practice to re-run your tests +to make sure. + +```sh +npm run test -- run +npm run test:e2e:run +``` + +If all tests pass, commit your changes. + +```sh +git add . +git commit -m "Updated minor versions" +``` + +### Update red versions third, one by one + +Red updates can happen on patch or minor versions (for zero major version +(0.y.z) packages) or major versions. Either way, they could be breaking changes. +It's recommended you read its release notes to see what changed and plan +accordingly. + +> Again, you might want to take into consideration how important a package is +> for your project when choosing which to update first. + +```sh +npx npm-check-updates -u -f +npm i +``` + +Make sure you've made all relevant changes and that the tests pass. + +```sh +npm run test -- run +npm run test:e2e:run +``` + +If all tests pass, commit your changes. + +```sh +git add . +git commit -m "Updated major version" +``` + +Then continue for each package. diff --git a/docs/memory.md b/docs/memory.md new file mode 100644 index 0000000..76a914d --- /dev/null +++ b/docs/memory.md @@ -0,0 +1,17 @@ +# Memory + +Epic Stack apps start with a single instance with 256MB of memory. This is a +pretty small amount of memory, but it's enough to get started with. To help +avoid memory pressure even at that scale, we allocate a 512MB swap file. Learn +more about this decision in +[the memory swap decision document](decisions/010-memory-swap.md). + +To modify or increase the swap file, check `other/setup-swap.js`. This file is +executed before running our app within the `litefs.yml` config. + +> **NOTE**: PRs welcome to document how to determine the effectiveness of the +> swap file for your app. + +To increase the memory allocated to your vm, use the +[`fly scale`](https://fly.io/docs/flyctl/scale-memory/) command. You can +[learn more about memory sizing in the Fly docs](https://fly.io/docs/machines/guides-examples/machine-sizing). diff --git a/docs/monitoring.md b/docs/monitoring.md new file mode 100644 index 0000000..79807a2 --- /dev/null +++ b/docs/monitoring.md @@ -0,0 +1,41 @@ +# Monitoring + +This document describes how to get [Sentry](https://sentry.io/) (the Epic +application monitoring provider) set up for error, performance, and replay +monitoring. + +> **NOTE**: this is an optional step and only needed if you want monitoring in +> production. + +## SaaS vs Self-Hosted + +Sentry offers both a [SaaS solution](https://sentry.io/) and +[self-hosted solution](https://develop.sentry.dev/self-hosted/). This guide +assumes you are using SaaS but the guide still works with self-hosted with a few +modifications. + +## Signup + +You can sign up for Sentry and create a Remix project from visiting +[this url](https://sentry.io/signup/?project_platform=javascript-remix) and +filling out the signup form. + +## Onboarding + +Once you see the onboarding page which has the DSN, copy that somewhere (this +becomes `SENTRY_DSN`). Then click +[this](https://sentry.io/orgredirect/settings/:orgslug/developer-settings/new-internal/) +to create an internal integration. Give it a name and add the scope for +`Releases:Admin`. Press Save, find the auth token at the bottom of the page +under "Tokens", and copy that to secure location (this becomes +`SENTRY_AUTH_TOKEN`). Then visit the organization settings page and copy that +organization slug (`SENTRY_ORG_SLUG`). + +Now, set the secrets in Fly.io: + +```sh +fly secrets set SENTRY_DSN= SENTRY_AUTH_TOKEN= SENTRY_ORG= SENTRY_PROJECT=javascript-remix +``` + +Note that `javascript-remix` is the name of the default Remix project in Sentry +and if you use a different project name you'll need to update that value here. diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..a60f78d --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,42 @@ +# Permissions + +The Epic Stack's Permissions model takes after +[Role-Based Access Control (RBAC)](https://auth0.com/intro-to-iam/what-is-role-based-access-control-rbac). +Each user has a set of roles, and each role has a set of permissions. A user's +permissions are the union of the permissions of all their roles (with the more +permissive permission taking precedence). + +The default development seed creates fine-grained permissions that include +`create`, `read`, `update`, and `delete` permissions for `user` and `note` with +the access of `own` and `any`. The default seed also creates `user` and `admin` +roles with the sensible permissions for those roles. + +You can combine these permissions in different ways to support different roles +for different personas of users of your application. + +The Epic Stack comes with built-in utilities for working with these permissions. +Here are some examples to give you an idea: + +```ts +// server-side only utilities +const userCanDeleteAnyUser = await requireUserWithPermission( + request, + 'delete:user:any', +) +const userIsAdmin = await requireUserWithRole(request, 'admin') +``` + +```ts +// UI utilities +const user = useUser() +const userCanCreateTheirOwnNotes = userHasPermission(user, 'create:note:own') +const userIsUser = userHasRole(user, 'user') +``` + +There is currently no UI for managing permissions, but you can use prisma studio +for establishing these. + +## Seeding the production database + +Check [the deployment docs](./deployment.md) for instructions on how to seed the +production database with the roles you want. diff --git a/docs/redirects.md b/docs/redirects.md new file mode 100644 index 0000000..3667332 --- /dev/null +++ b/docs/redirects.md @@ -0,0 +1,88 @@ +# Redirects + +We handle redirects in Express. Incoming requests are handled by Express before +they pass through to Remix, so by redirecting earlier you improve performance. + +## HTTP to HTTPS + +We force all traffic to HTTPS automatically. That way, no part of your +application is open to request interception. This does not affect localhost, as +we use Fly's request headers for determining when to redirect. + +```ts +app.use((req, res, next) => { + const proto = req.get('X-Forwarded-Proto') + const host = getHost(req) + if (proto === 'http') { + res.set('X-Forwarded-Proto', 'https') + res.redirect(`https://${host}${req.originalUrl}`) + return + } + next() +}) +``` + +## Remove trailing slashes + +We also remove trailing slashes automatically. A url like +`https://example.com/foo/` is automatically redirected to +`https://example.com/foo`. + +This is important for SEO reasons, as website crawlers (like Google) treat these +as separate URLs and will consider them to be distinct pages with duplicate +content. + +```ts +app.use((req, res, next) => { + if (req.path.endsWith('/') && req.path.length > 1) { + const query = req.url.slice(req.path.length) + const safepath = req.path.slice(0, -1).replace(/\/+/g, '/') + res.redirect(301, safepath + query) + } else { + next() + } +}) +``` + +## www subdomains + +You can redirect root domain traffic to your www subdomain or vice versa. + +[DNS level redirects do not work with Fly](https://community.fly.io/t/how-to-redirect-from-non-www-to-www/5795/2). +The recommended way to implement this redirect is in your application code. + +First, you will need to register SSL certificates for both the www and the root +domain. You can +[register an SSL certificate for your domains in the terminal](https://fly.io/docs/getting-started/working-with-fly-apps/#fly-io-and-custom-domains) +or in your [Fly dashboard](https://fly.io/dashboard/) by navigating to your app +and selecting "Certificates" on the sidebar. + +After registering the two SSL certificates, Fly will allow traffic from either +source to hit your application, and then you can redirect to your preferred +domain in code. + +**To redirect non-www traffic to www** + +```ts +app.use((req, res, next) => { + const host = getHost(req) + if (!host.startsWith('www.')) { + return res.redirect(301, `https://www.${host}${req.url}`) + } else { + next() + } +}) +``` + +**To redirect www traffic to non-www:** + +```ts +app.use((req, res, next) => { + const host = getHost(req) + if (host.startsWith('www.')) { + return res.redirect(301, `https://${host.slice(4)}${req.url}`) + } else { + next() + } +}) +``` diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 0000000..b8a043d --- /dev/null +++ b/docs/routing.md @@ -0,0 +1,145 @@ +# Routing + +The Epic Stack uses file-based routing with Remix. However, it's not using the +built-in routing convention of Remix and instead is using +[remix-flat-routes](https://github.com/kiliman/remix-flat-routes) which is a +special implementation of the Remix convention that adds a few features. You'll +find it configured for the application in the `remix.config.js` file at the root +of the app. Specifically "hybrid routes." + +We'll defer to the `remix-flat-routes` documentation for specifics, but an +important thing for you to know as you get used to this convention is you can +always run `npx remix routes` from the root of the app and it will output the +routes of your application in a JSX-like output that will reveal the routes that +will be generated based on your current file structure. Here's an example of the +Epic Stack routes at the time of this writing: + +``` +app/routes +├── _auth+ +│   ├── forgot-password.tsx +│   ├── login.tsx +│   ├── logout.tsx +│   ├── onboarding.tsx +│   ├── reset-password.tsx +│   └── signup.tsx +├── _marketing+ +│   ├── about.tsx +│   ├── index.tsx +│   ├── logos +│   │   ├── logos.ts +│   │   └── ... +│   ├── privacy.tsx +│   ├── support.tsx +│   └── tos.tsx +├── admin+ +│   ├── cache.tsx +│   ├── cache_.lru.$cacheKey.ts +│   ├── cache_.sqlite.$cacheKey.ts +│   └── cache_.sqlite.tsx +├── me.tsx +├── resources+ +│   ├── delete-image.test.tsx +│   ├── delete-image.tsx +│   ├── delete-note.tsx +│   ├── file.$fileId.tsx +│   ├── healthcheck.tsx +│   ├── image-upload.tsx +│   ├── login.tsx +│   ├── note-editor.tsx +│   └── theme.tsx +├── settings+ +│   ├── profile.photo.tsx +│   └── profile.tsx +└── users+ + ├── $username.tsx + └── $username_+ + ├── notes.$noteId.tsx + ├── notes.$noteId_.edit.tsx + ├── notes.index.tsx + ├── notes.new.tsx + └── notes.tsx + +9 directories, 54 files +``` + +```tsx + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Basically, remix-flat-routes hybrid routing allows us to get the best of both +worlds: + +- Colocation of routes to the code they use +- Organized folder structure to keep routes together as needed + +If you're familiar with the Remix routing convention, just think of it this way, +remix-flat-routes converts `+/` to `.`. diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000..9aca013 --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,37 @@ +# Secrets + +Managing secrets in the Epic Stack is done using environment variables and the +`fly secrets` command. + +> **Warning**: It is very important that you do NOT hard code any secrets in the +> source code. Even if your app source is not public, there are a lot of reasons +> this is dangerous and in the epic stack we default to creating source maps +> which will reveal your hard coded secrets to the public. Read more about this +> in [the source map decision document](./decisions/016-source-maps.md). + +## Local development + +When you need to create a new secret, it's best to add a line to your +`.env.example` file so folks know that secret is necessary. The value you put in +here should be not real because this file is committed to the repository. + +To keep everything in line with the [guiding principle](./guiding-principles.md) +of "Offline Development," you should also strive make it so whatever service +you're interacting with can be mocked out using MSW in the `test/mocks` +directory. + +You can also put the real value of the secret in `.env` which is `.gitignore`d +so you can interact with the real service if you need to during development. + +## Production secrets + +To publish a secret to your production and staging applications, you can use the +`fly secrets set` command. For example, if you were integrating with the `tito` +API, to set the `TITO_API_SECRET` secret, you would run the following command: + +```sh +fly secrets set TITO_API_SECRET=some_secret_value +fly secrets set TITO_API_SECRET=some_secret_value --app [YOUR_STAGING_APP_NAME] +``` + +This will redeploy your app with that environment variable set. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..15a4759 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,85 @@ +# Security + +The Epic Stack has several security measures in place to protect your users and +yourself. This (incomplete) document, explains some of the security measures +that are in place and how to use them. + +## Content Security Policy + +The Epic Stack uses a strict +[Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP). +This means that only resources from trusted sources are allowed to be loaded. +However, by default, the CSP is set to `report-only` which means that the +browser will report violations of the CSP without actually blocking the +resource. + +This is to prevent new users of the Epic Stack from being blocked or surprised +by the CSP by default. However, it is recommended to enable the CSP in +`server/index.ts` by removing the `reportOnly: true` option. + +## Fly's Internal Network + +The Epic Stack uses [Fly](https://fly.io) for hosting. Fly has an internal +network that allows you to connect services to each other without exposing them +to the public internet. Only services within your organization have access to +this network, and only accounts in your organization have access as well. + +When running multiple instances of the Epic Stack, your instances communicate +with each other over this internal network. Most of this happens behind the +scenes with the consul service that Fly manages for us. + +We also have an endpoint that allows instances to connect to each other to +update the cache in the primary region. This uses internal URLs for that +communication (via [`litefs-js`](https://github.com/fly-apps/litefs-js)), but as +an added layer of security it uses a shared secret to validate the requests. + +> This could be changed if there's a way to determine if a request is coming +> from the internal network. But I haven't found a way to do that yet. PRs +> welcome! + +Outside of this, the Epic Stack does not access other first-party services or +databases. + +## Secrets + +The currently recommended policy for managing secrets is to place them in a +`.env` file in the root of the application (which is `.gitignore`d). There is a +`.env.example` which can be used as a template for this file (and if you do not +need to actually connect to real services, this can be used as +`cp .env.example .env`). + +These secrets need to also be set on Fly using the `fly secrets` command. + +There are significant limitations to this approach and will probably be improved +in the future. + +## [Cross-Site Scripting (XSS)](https://developer.mozilla.org/en-US/docs/Glossary/Cross-site_scripting) + +React has built-in support for XSS protection. It does this by escaping all +values by default. This means that if you want to render HTML, you need to use +the `dangerouslySetInnerHTML` prop. This is a good thing, but it does mean that +you need to be careful when rendering HTML. Never pass anything that is +user-generated to this prop. + +## [Cross-Site Request Forgery (CSRF)](https://forms.epicweb.dev/07) + +The Epic Stack has built-in support to prevent CSRF attacks. We use the +[`remix-utils`](https://github.com/sergiodxa/remix-utils) +[CSRF-related utilities](https://github.com/sergiodxa/remix-utils#csrf) to do +this. + +## [Honeypot](https://forms.epicweb.dev/06) + +The Epic Stack has built-in support for honeypot fields. We use the +[`remix-utils`](https://github.com/sergiodxa/remix-utils) +[honeypot-related utilities](https://github.com/sergiodxa/remix-utils#form-honeypot) +to do this. + +## Rate Limiting + +The Epic Stack uses a rate limiter to prevent abuse of the API. This is +configured in the `server/index.ts` file and can be changed as needed. By +default it uses [`express-rate-limit`](https://npm.im/express-rate-limit) with +the in-memory store. There are trade-offs with this simpler approach, but it +should be relatively simple to externalize the store into Redis as that's a +built-in feature to express-rate-limit. diff --git a/docs/seo.md b/docs/seo.md new file mode 100644 index 0000000..3da3b2f --- /dev/null +++ b/docs/seo.md @@ -0,0 +1,41 @@ +# SEO + +Remix has built-in support for setting up `meta` tags on a per-route basis which +you can read about +[in the Remix Metadata docs](https://remix.run/docs/en/main/route/meta). + +The Epic Stack also has built-in support for `/robots.txt` and `/sitemap.xml` +via [resource routes](https://remix.run/docs/en/main/guides/resource-routes) +using [`@nasa-gcn/remix-seo`](https://github.com/nasa-gcn/remix-seo). By +default, all routes are included in the `sitemap.xml` file, but you can +configure which routes are included using the `handle` export in the route. Only +public-facing pages should be included in the `sitemap.xml` file. + +Here are two quick examples of how to customize the sitemap on a per-route basis +from the `@nasa-gcn/remix-seo` docs: + +```tsx +// routes/blog/$blogslug.tsx + +export const handle: SEOHandle = { + getSitemapEntries: async request => { + const blogs = await db.blog.findMany() + return blogs.map(blog => { + return { route: `/blog/${blog.slug}`, priority: 0.7 } + }) + }, +} +``` + +```tsx +// in your routes/url-that-doesnt-need-sitemap +import { SEOHandle } from '@nasa-gcn/remix-seo' + +export let loader = ({ request }: LoaderFunctionArgs) => { + /**/ +} + +export const handle: SEOHandle = { + getSitemapEntries: () => null, +} +``` diff --git a/docs/server-timing.md b/docs/server-timing.md new file mode 100644 index 0000000..248f769 --- /dev/null +++ b/docs/server-timing.md @@ -0,0 +1,86 @@ +# Server Timing + +![Network tab of Chrome DevTools showing the Timing tab of a specific network call and an arrow pointing to the Server Timing section with the words "This is what server timings do"](https://github.com/epicweb-dev/epic-stack/assets/1500684/e5a28253-8204-43b1-8222-3f287d024ca5) + +The Epic Stack comes with a built-in server timing utility that allows you to +measure the performance of your application. You can find it in the +`app/utils/timing.server.ts` file. The idea is you can wrap a function in a +`time` call and then use the timings object to generate a `Server-Timing` header +which you can then use to have fine grained timing metrics for requests made in +your app. + +You can +[learn more about the Server Timing header on MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server-Timing). +The metrics passed in this header will be visually displayed in +[the DevTools "Timing" tab](https://developer.chrome.com/docs/devtools/network/reference/#timing). + +## Usage + +Timings requires four parts: + +1. Setup Timings +2. Time functions +3. Create headers +4. Send headers + +Here are all those parts in action in the `/user/:username/notes` route at the +time of this writing: + +```tsx +import { + combineServerTimings, + makeTimings, + time, +} from '#app/utils/timing.server.ts' + +export async function loader({ params }: LoaderFunctionArgs) { + const timings = makeTimings('notes loader') // <-- 1. Setup Timings + // 2. Time functions + const owner = await time( + () => + prisma.user.findUnique({ + where: { + username: params.username, + }, + select: { + id: true, + username: true, + name: true, + imageId: true, + }, + }), + { timings, type: 'find user' }, + ) + if (!owner) { + throw new Response('Not found', { status: 404 }) + } + // 2. Time functions + const notes = await time( + () => + prisma.note.findMany({ + where: { + ownerId: owner.id, + }, + select: { + id: true, + title: true, + }, + }), + { timings, type: 'find notes' }, + ) + return json( + { owner, notes }, + { headers: { 'Server-Timing': timings.toString() } }, // <-- 3. Create headers + ) +} + +export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => { + return { + 'Server-Timing': combineServerTimings(parentHeaders, loaderHeaders), // <-- 4. Send headers + } +} +``` + +You can +[learn more about `headers` in the Remix docs](https://remix.run/docs/en/main/route/headers) +(note, the Epic Stack has the v2 behavior enabled). diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..8a0b630 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,48 @@ +# Testing + +## Playwright + +We use Playwright for our End-to-End tests in this project. You'll find those in +the `tests` directory. As you make changes, add to an existing file or create a +new file in the `tests` directory to test your changes. + +To run these tests in development, run `npm run test:e2e:dev` which will start +the dev server for the app and run Playwright on it. + +We have a fixture for testing authenticated features without having to go +through the login flow: + +```ts +test('my test', async ({ page, login }) => { + const user = await login() + // you are now logged in +}) +``` + +We also auto-delete the user at the end of your test. That way, we can keep your +local db clean and keep your tests isolated from one another. + +## Vitest + +For lower level tests of utilities and individual components, we use `vitest`. +We have DOM-specific assertion helpers via +[`@testing-library/jest-dom`](https://testing-library.com/jest-dom). + +## Type Checking + +This project uses TypeScript. It's recommended to get TypeScript set up for your +editor to get a really great in-editor experience with type checking and +auto-complete. To run type checking across the whole project, run +`npm run typecheck`. + +## Linting + +This project uses ESLint for linting. That is configured in `.eslintrc.js`. + +## Formatting + +We use [Prettier](https://prettier.io/) for auto-formatting in this project. +It's recommended to install an editor plugin (like the +[VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) +to get auto-formatting on save. There's also a `npm run format` script you can +run to format all files in the project. diff --git a/docs/timezone.md b/docs/timezone.md new file mode 100644 index 0000000..4234892 --- /dev/null +++ b/docs/timezone.md @@ -0,0 +1,37 @@ +# Timezones + +Server rendering timezones has always been a pain. This is because the server +doesn't know the user's timezone. It only knows the timezone of the server. So +lots of people will take the easy way out and do one of the following +workarounds: + +- Just render in UTC: Not great because it's not the user's timezone +- Render in the server's timezone: Not great because it's not the user's + timezone +- Render in the server's timezone, and hydrate in the client's timezone: Not + great because it causes a flash of incorrect content (and a hydration error + unless you add `suppressHydrationWarning={true}` to the element) +- Don't render the time on the server at all: Not great because it's a flash of + incomplete content (and no, fading it in does not count). +- Only render the time from user interaction: Sometimes this is fine, but often + you're just compromising on UX and you know it. + +Thanks to the Epic Stack's built-in support for +[client hints](./client-hints.md), we can do better! We have a client hint set +up for the user's timezone. This means you can render the time on the server in +the user's timezone, and hydrate it in the user's timezone, without any flash of +incorrect content or hydration errors. + +You can use this in a few ways. In server-side only code, +`getHints(request).timeZone` will be what you're looking for. In UI code, you +can use `useHints().timeZone` to get the user's timezone. + +For the server-side code, we have a `getDateTimeFormat` utility uses to give you +a +[`DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) +object that is in the user's timezone (it also uses the standard +`accept-language` header to determine the user's preferred locale). + +If you'd prefer to use a library for formatting dates and times, feel free to +simply access the timezone from the hints and use it with your library of +choice. diff --git a/docs/toasts.md b/docs/toasts.md new file mode 100644 index 0000000..ccba4da --- /dev/null +++ b/docs/toasts.md @@ -0,0 +1,62 @@ +# Toasts + +Toast messages are great ways to temporarily call someone's attention to +something. They are often used to notify users of a successful or failed action. + +![toasts](https://github.com/epicweb-dev/epic-stack/assets/1500684/715d754a-9e9f-4b61-814f-881121f2fa48) + +There are utilities in the Epic Stack for toast notifications. + +This is managed by a special session using a concept called "flash data" which +is a temporary session value that is only available for the next request. This +is a great way to pass data to the next request without having to worry about +the data persisting in the session. And you don't have to worry about managing +state either. It all just lives in the cookie. + +The primary utility you'll use for redirecting with toast notifications is +`redirectWithToast` from `app/utils/toast.server.ts`. Here's a simple example of +using this: + +```tsx +return redirectWithToast(`/users/${note.owner.username}/notes/${note.id}`, { + description: id ? 'Note updated' : 'Note created', +}) +``` + +This accepts an additional argument for other `ResponseInit` options so you can +set other headers, etc. + +If you don't wish to redirect, you could use the underlying `createToastHeaders` +directly: + +```tsx +return json( + { success: true }, + { + headers: await createToastHeaders({ + description: 'Note updated', + type: 'success', + }), + }, +) +``` + +And if you need to set multiple headers, you can use the `combineHeaders` +utility from `app/utils/misc.tsx`: + +```tsx +return json( + { success: true }, + { + headers: combineHeaders( + await createToastHeaders({ + toast: { + description: 'Note updated', + type: 'success', + }, + }), + { 'x-foo': 'bar' }, + ), + }, +) +``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..e316165 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,47 @@ +# Troubleshooting + +This is the page where we document common errors and how to fix them: + +## Content Security Policy violations + +If you've received an error like this: + +> Refused to load the image 'https://example.com/thing.png' because it violates +> the following Content Security Policy directive: "img-src 'self'". + +This means you're trying to add a link to a resource that is not allowed. Learn +more about the decision to add this content security policy (CSP) in +[the decision document](./decisions/008-content-security-policy.md). NOTE: This +is disabled by default as of +[the report-only CSP decision](./decisions/022-report-only-csp.md). It is, +however, recommended to be enabled for security reasons. + +To fix this, adjust the CSP to allow the resource you're trying to add. This can +be done in the `server/index.ts` file. + +```diff + contentSecurityPolicy: { + directives: { + 'connect-src': [ + MODE === 'development' ? 'ws:' : null, + process.env.SENTRY_DSN ? '*.ingest.sentry.io' : null, + "'self'", + ].filter(Boolean), + 'font-src': ["'self'"], + 'frame-src': ["'self'"], +- 'img-src': ["'self'", 'data:'], ++ 'img-src': ["'self'", 'data:', 'https://*.example.com'] +``` + +## Missing Icons + +Epic Stack uses SVG sprite icons for performance reasons. If you've received an +error like this during local development: + +> X [ERROR] Could not resolve "#app/components/ui/icon.tsx" + +You need to be manually regenerate the icon with `npm run build:icons`. + +See +[the icons decision document](https://github.com/epicweb-dev/epic-stack/blob/main/docs/decisions/020-icons.md) +for more information about icons. diff --git a/fly.toml b/fly.toml index 232ccc5..76170c4 100644 --- a/fly.toml +++ b/fly.toml @@ -1,4 +1,4 @@ -app = "bookbreeze-5848" +app = "epic-stack-template" primary_region = "sjc" kill_signal = "SIGINT" kill_timeout = 5 diff --git a/remix.init/gitignore b/remix.init/gitignore new file mode 100644 index 0000000..f9d5ad8 --- /dev/null +++ b/remix.init/gitignore @@ -0,0 +1,22 @@ +node_modules +.DS_store + +/build +/public/build +/server-build +.env + +/prisma/data.db +/prisma/data.db-journal +/tests/prisma + +/test-results/ +/playwright-report/ +/playwright/.cache/ +/tests/fixtures/email/ +/coverage + +/other/cache.db + +# Easy way to create temporary files/folders that won't accidentally be added to git +*.local.* diff --git a/remix.init/index.js b/remix.init/index.js new file mode 100644 index 0000000..e5e36c0 --- /dev/null +++ b/remix.init/index.js @@ -0,0 +1,4 @@ +module.exports = async (...args) => { + const { default: main } = await import('./index.mjs') + await main(...args) +} diff --git a/remix.init/index.mjs b/remix.init/index.mjs new file mode 100644 index 0000000..e59457d --- /dev/null +++ b/remix.init/index.mjs @@ -0,0 +1,334 @@ +import { execSync } from 'child_process' +import crypto from 'crypto' +import fs from 'fs/promises' +import path from 'path' +import toml from '@iarna/toml' +import { $ } from 'execa' +import inquirer from 'inquirer' +import open from 'open' +import parseGitHubURL from 'parse-github-url' + +const escapeRegExp = string => + // $& means the whole matched string + string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const getRandomString = length => crypto.randomBytes(length).toString('hex') +const getRandomString32 = () => getRandomString(32) + +export default async function main({ rootDirectory }) { + const FLY_TOML_PATH = path.join(rootDirectory, 'fly.toml') + const EXAMPLE_ENV_PATH = path.join(rootDirectory, '.env.example') + const ENV_PATH = path.join(rootDirectory, '.env') + const PKG_PATH = path.join(rootDirectory, 'package.json') + + const appNameRegex = escapeRegExp('epic-stack-template') + + const DIR_NAME = path.basename(rootDirectory) + const SUFFIX = getRandomString(2) + + const APP_NAME = (DIR_NAME + '-' + SUFFIX) + // get rid of anything that's not allowed in an app name + .replace(/[^a-zA-Z0-9-_]/g, '-') + .toLowerCase() + + const [flyTomlContent, env, packageJsonString] = await Promise.all([ + fs.readFile(FLY_TOML_PATH, 'utf-8'), + fs.readFile(EXAMPLE_ENV_PATH, 'utf-8'), + fs.readFile(PKG_PATH, 'utf-8'), + ]) + + const newEnv = env + .replace(/^SESSION_SECRET=.*$/m, `SESSION_SECRET="${getRandomString(16)}"`) + .replace( + /^INTERNAL_COMMAND_TOKEN=.*$/m, + `INTERNAL_COMMAND_TOKEN="${getRandomString(16)}"`, + ) + + const newFlyTomlContent = flyTomlContent.replace( + new RegExp(appNameRegex, 'g'), + APP_NAME, + ) + + const packageJson = JSON.parse(packageJsonString) + + packageJson.name = APP_NAME + delete packageJson.author + delete packageJson.license + + const fileOperationPromises = [ + fs.writeFile(FLY_TOML_PATH, newFlyTomlContent), + fs.writeFile(ENV_PATH, newEnv), + fs.writeFile(PKG_PATH, JSON.stringify(packageJson, null, 2)), + fs.copyFile( + path.join(rootDirectory, 'remix.init', 'gitignore'), + path.join(rootDirectory, '.gitignore'), + ), + fs.rm(path.join(rootDirectory, 'LICENSE.md')), + fs.rm(path.join(rootDirectory, 'CONTRIBUTING.md')), + fs.rm(path.join(rootDirectory, 'docs'), { recursive: true }), + fs.rm(path.join(rootDirectory, 'tests/e2e/notes.test.ts')), + fs.rm(path.join(rootDirectory, 'tests/e2e/search.test.ts')), + ] + + await Promise.all(fileOperationPromises) + + if (!process.env.SKIP_SETUP) { + execSync('npm run setup', { cwd: rootDirectory, stdio: 'inherit' }) + } + + if (!process.env.SKIP_FORMAT) { + execSync('npm run format -- --log-level warn', { + cwd: rootDirectory, + stdio: 'inherit', + }) + } + + if (!process.env.SKIP_DEPLOYMENT) { + await setupDeployment({ rootDirectory }).catch(error => { + console.error(error) + + console.error( + `Looks like something went wrong setting up deployment. Sorry about that. Check the docs for instructions on how to get deployment setup yourself (https://github.com/epicweb-dev/epic-stack/blob/main/docs/deployment.md).`, + ) + }) + } + + console.log( + ` +Setup is complete. You're now ready to rock and roll 🐨 + +What's next? + +- Start development with \`npm run dev\` +- Run tests with \`npm run test\` and \`npm run test:e2e\` + `.trim(), + ) +} + +async function setupDeployment({ rootDirectory }) { + const $I = $({ stdio: 'inherit', cwd: rootDirectory }) + + const { shouldSetupDeployment } = await inquirer.prompt([ + { + name: 'shouldSetupDeployment', + type: 'confirm', + default: true, + message: 'Would you like to setup deployment right now?', + }, + ]) + + if (!shouldSetupDeployment) { + console.log( + `Ok, check the docs (https://github.com/epicweb-dev/epic-stack/blob/main/docs/deployment.md) when you're ready to set that up.`, + ) + return + } + + const hasFly = await $`fly version`.then( + () => true, + () => false, + ) + if (!hasFly) { + console.log( + `You need to install Fly first. Follow the instructions here: https://fly.io/docs/hands-on/install-flyctl/`, + ) + return + } + const loggedInUser = await ensureLoggedIn() + if (!loggedInUser) { + console.log( + `Ok, check the docs when you're ready to get this deployed: https://github.com/epicweb-dev/epic-stack/blob/main/docs/deployment.md`, + ) + } + + console.log('🔎 Determining the best region for you...') + const primaryRegion = await getPreferredRegion() + + const flyConfig = toml.parse( + await fs.readFile(path.join(rootDirectory, 'fly.toml')), + ) + flyConfig.primary_region = primaryRegion + await fs.writeFile( + path.join(rootDirectory, 'fly.toml'), + toml.stringify(flyConfig), + ) + + const { app: APP_NAME } = flyConfig + + console.log(`🥪 Creating app ${APP_NAME} and ${APP_NAME}-staging...`) + await $I`fly apps create ${APP_NAME}-staging` + await $I`fly apps create ${APP_NAME}` + + console.log(`🤫 Setting secrets in apps`) + await $I`fly secrets set SESSION_SECRET=${getRandomString32()} INTERNAL_COMMAND_TOKEN=${getRandomString32()} HONEYPOT_SECRET=${getRandomString32()} --app ${APP_NAME}-staging` + await $I`fly secrets set SESSION_SECRET=${getRandomString32()} INTERNAL_COMMAND_TOKEN=${getRandomString32()} HONEYPOT_SECRET=${getRandomString32()} --app ${APP_NAME}` + + console.log( + `🔊 Creating volumes. Answer "yes" when it warns you about downtime. You can add more volumes later (when you actually start getting paying customers �).`, + ) + await $I`fly volumes create data --region ${primaryRegion} --size 1 --app ${APP_NAME}-staging` + await $I`fly volumes create data --region ${primaryRegion} --size 1 --app ${APP_NAME}` + + // attach consul + console.log(`🔗 Attaching consul`) + await $I`fly consul attach --app ${APP_NAME}-staging` + await $I`fly consul attach --app ${APP_NAME}` + + const { shouldDeploy } = await inquirer.prompt([ + { + name: 'shouldDeploy', + type: 'confirm', + default: true, + message: + 'Would you like to deploy right now? (This will take a while, and you can always wait until you push to GitHub instead).', + }, + ]) + if (shouldDeploy) { + console.log(`🚀 Deploying apps...`) + console.log(' Moving Dockerfile and .dockerignore to root (temporarily)') + await fs.rename( + path.join(rootDirectory, 'other', 'Dockerfile'), + path.join(rootDirectory, 'Dockerfile'), + ) + await fs.rename( + path.join(rootDirectory, 'other', '.dockerignore'), + path.join(rootDirectory, '.dockerignore'), + ) + console.log(` Starting with staging`) + await $I`fly deploy --app ${APP_NAME}-staging` + await open(`https://${APP_NAME}-staging.fly.dev/`) + + console.log(` Staging deployed... Deploying production...`) + await $I`fly deploy --app ${APP_NAME}` + await open(`https://${APP_NAME}.fly.dev/`) + console.log(` Production deployed...`) + console.log(' Moving Dockerfile and .dockerignore back to other/') + await fs.rename( + path.join(rootDirectory, 'Dockerfile'), + path.join(rootDirectory, 'other', 'Dockerfile'), + ) + await fs.rename( + path.join(rootDirectory, '.dockerignore'), + path.join(rootDirectory, 'other', '.dockerignore'), + ) + } + + const { shouldSetupGitHub } = await inquirer.prompt([ + { + name: 'shouldSetupGitHub', + type: 'confirm', + default: true, + message: 'Would you like to setup GitHub Action deployment right now?', + }, + ]) + if (shouldSetupGitHub) { + console.log(`⛓ Initializing git repo...`) + // it's possible there's already a git repo initialized so we'll just ignore + // any errors and hope things work out. + await $I`git init`.catch(() => {}) + + console.log( + `Opening repo.new. Please create a new repo and paste the URL below.`, + ) + await open(`https://repo.new`) + + const { repoURL } = await inquirer.prompt([ + { + name: 'repoURL', + type: 'input', + message: 'What is the URL of your repo?', + }, + ]) + + const githubParts = parseGitHubURL(repoURL) + + if (!githubParts) { + throw new Error(`Invalid GitHub URL: ${repoURL}`) + } + + console.log( + `Opening Fly Tokens Dashboard and GitHub Action Secrets pages. Please create a new token on Fly and set it as the value for a new secret called FLY_API_TOKEN on GitHub.`, + ) + await open(`https://web.fly.io/user/personal_access_tokens/new`) + await open(`${repoURL}/settings/secrets/actions/new`) + + console.log( + `Once you're finished with setting the token, you should be good to add the remote, commit, and push!`, + ) + } + console.log('All done 🎉 Happy building') +} + +async function ensureLoggedIn() { + const loggedInUser = await $`fly auth whoami`.then( + ({ stdout }) => stdout, + () => null, + ) + if (loggedInUser) { + const answers = await inquirer.prompt([ + { + name: 'proceed', + type: 'list', + default: 'Yes', + message: `You're logged in as ${loggedInUser}. Proceed?`, + choices: ['Yes', 'Login as another user', 'Exit'], + }, + ]) + switch (answers.proceed) { + case 'Yes': { + return loggedInUser + } + case 'Login as another user': { + await $`fly auth logout` + return ensureLoggedIn() + } + default: { + return null + } + } + } else { + console.log(`You need to login to Fly first. Running \`fly auth login\`...`) + await $({ stdio: 'inherit' })`fly auth login` + return ensureLoggedIn() + } +} + +async function getPreferredRegion() { + const { + platform: { requestRegion: defaultRegion }, + } = await makeFlyRequest({ query: 'query {platform {requestRegion}}' }) + + const availableRegions = await makeFlyRequest({ + query: `{platform {regions {name code}}}`, + }) + const { preferredRegion } = await inquirer.prompt([ + { + name: 'preferredRegion', + type: 'list', + default: defaultRegion, + message: `Which region would you like to deploy to? The closest to you is ${defaultRegion}.`, + choices: availableRegions.platform.regions.map(region => ({ + name: `${region.name} (${region.code})`, + value: region.code, + })), + }, + ]) + return preferredRegion +} + +let flyToken = null +async function makeFlyRequest({ query, variables }) { + if (!flyToken) { + flyToken = (await $`fly auth token`).stdout.trim() + } + + const json = await fetch('https://api.fly.io/graphql', { + method: 'POST', + body: JSON.stringify({ query, variables }), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${flyToken}`, + }, + }).then(response => response.json()) + return json.data +} diff --git a/remix.init/package-lock.json b/remix.init/package-lock.json new file mode 100644 index 0000000..d3755cc --- /dev/null +++ b/remix.init/package-lock.json @@ -0,0 +1,1731 @@ +{ + "name": "remix.init", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "remix.init", + "dependencies": { + "@iarna/toml": "^2.2.5", + "execa": "^7.1.1", + "inquirer": "^9.2.6", + "open": "^9.1.0", + "parse-github-url": "^1.0.2" + } + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/big-integer": { + "version": "1.6.51", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", + "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bplist-parser": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", + "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", + "dependencies": { + "big-integer": "^1.6.44" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bundle-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", + "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", + "dependencies": { + "run-applescript": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz", + "integrity": "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.0.tgz", + "integrity": "sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.0.0.tgz", + "integrity": "sha512-ZksGS2xpa/bYkNzN3BAw1wEjsLV/ZKOf/CCrJ/QOBsxx6fOARIkwTutxp1XIOIohi6HKmOFjMoK/XaqDVUpEEw==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/default-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", + "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", + "dependencies": { + "bundle-name": "^3.0.0", + "default-browser-id": "^3.0.0", + "execa": "^7.1.1", + "titleize": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", + "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", + "dependencies": { + "bplist-parser": "^0.2.0", + "untildify": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz", + "integrity": "sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/figures": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz", + "integrity": "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==", + "dependencies": { + "escape-string-regexp": "^5.0.0", + "is-unicode-supported": "^1.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/inquirer": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.6.tgz", + "integrity": "sha512-y71l237eJJKS4rl7sQcEUiMhrR0pB/ZnRMMTxLpjJhWL4hdWCT03a6jJnC1w6qIPSRZWEozuieGt3v7XaEJYFw==", + "dependencies": { + "ansi-escapes": "^4.3.2", + "chalk": "^5.2.0", + "cli-cursor": "^3.1.0", + "cli-width": "^4.0.0", + "external-editor": "^3.0.3", + "figures": "^5.0.0", + "lodash": "^4.17.21", + "mute-stream": "1.0.0", + "ora": "^5.4.1", + "run-async": "^3.0.0", + "rxjs": "^7.8.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", + "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", + "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", + "dependencies": { + "default-browser": "^4.0.0", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-github-url": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-github-url/-/parse-github-url-1.0.2.tgz", + "integrity": "sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw==", + "bin": { + "parse-github-url": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/run-applescript": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", + "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/run-applescript/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/run-applescript/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-applescript/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/run-applescript/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/run-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", + "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "node_modules/titleize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", + "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/tslib": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz", + "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==" + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + } + }, + "dependencies": { + "@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==" + }, + "ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "requires": { + "type-fest": "^0.21.3" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "big-integer": { + "version": "1.6.51", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", + "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==" + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "bplist-parser": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.2.0.tgz", + "integrity": "sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==", + "requires": { + "big-integer": "^1.6.44" + } + }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "bundle-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-3.0.0.tgz", + "integrity": "sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==", + "requires": { + "run-applescript": "^5.0.0" + } + }, + "chalk": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz", + "integrity": "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==" + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-spinners": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.0.tgz", + "integrity": "sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g==" + }, + "cli-width": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.0.0.tgz", + "integrity": "sha512-ZksGS2xpa/bYkNzN3BAw1wEjsLV/ZKOf/CCrJ/QOBsxx6fOARIkwTutxp1XIOIohi6HKmOFjMoK/XaqDVUpEEw==" + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "default-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-4.0.0.tgz", + "integrity": "sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==", + "requires": { + "bundle-name": "^3.0.0", + "default-browser-id": "^3.0.0", + "execa": "^7.1.1", + "titleize": "^3.0.0" + } + }, + "default-browser-id": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-3.0.0.tgz", + "integrity": "sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==", + "requires": { + "bplist-parser": "^0.2.0", + "untildify": "^4.0.0" + } + }, + "defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "requires": { + "clone": "^1.0.2" + } + }, + "define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" + }, + "execa": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.1.1.tgz", + "integrity": "sha512-wH0eMf/UXckdUYnO21+HDztteVv05rq2GXksxT4fCGeHkBhw1DROXh40wcjMcRqDOWE7iPJ4n3M7e2+YFP+76Q==", + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "dependencies": { + "mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==" + }, + "onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "requires": { + "mimic-fn": "^4.0.0" + } + } + } + }, + "external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + } + }, + "figures": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-5.0.0.tgz", + "integrity": "sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==", + "requires": { + "escape-string-regexp": "^5.0.0", + "is-unicode-supported": "^1.2.0" + } + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==" + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "inquirer": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.6.tgz", + "integrity": "sha512-y71l237eJJKS4rl7sQcEUiMhrR0pB/ZnRMMTxLpjJhWL4hdWCT03a6jJnC1w6qIPSRZWEozuieGt3v7XaEJYFw==", + "requires": { + "ansi-escapes": "^4.3.2", + "chalk": "^5.2.0", + "cli-cursor": "^3.1.0", + "cli-width": "^4.0.0", + "external-editor": "^3.0.3", + "figures": "^5.0.0", + "lodash": "^4.17.21", + "mute-stream": "1.0.0", + "ora": "^5.4.1", + "run-async": "^3.0.0", + "rxjs": "^7.8.1", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + } + }, + "is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==" + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "requires": { + "is-docker": "^3.0.0" + } + }, + "is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==" + }, + "is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==" + }, + "is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==" + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "requires": { + "is-docker": "^2.0.0" + }, + "dependencies": { + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==" + } + } + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==" + } + } + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, + "mute-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", + "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==" + }, + "npm-run-path": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", + "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "requires": { + "path-key": "^4.0.0" + }, + "dependencies": { + "path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==" + } + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "open": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/open/-/open-9.1.0.tgz", + "integrity": "sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==", + "requires": { + "default-browser": "^4.0.0", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^2.2.0" + } + }, + "ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "requires": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==" + } + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==" + }, + "parse-github-url": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-github-url/-/parse-github-url-1.0.2.tgz", + "integrity": "sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw==" + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "run-applescript": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-5.0.0.tgz", + "integrity": "sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==", + "requires": { + "execa": "^5.0.0" + }, + "dependencies": { + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "requires": { + "path-key": "^3.0.0" + } + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==" + } + } + }, + "run-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", + "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==" + }, + "rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "requires": { + "tslib": "^2.1.0" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "titleize": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/titleize/-/titleize-3.0.0.tgz", + "integrity": "sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==" + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "tslib": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.2.tgz", + "integrity": "sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==" + }, + "type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==" + }, + "untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "requires": { + "defaults": "^1.0.3" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "requires": { + "isexe": "^2.0.0" + } + }, + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } +} diff --git a/remix.init/package.json b/remix.init/package.json new file mode 100644 index 0000000..3a85b81 --- /dev/null +++ b/remix.init/package.json @@ -0,0 +1,12 @@ +{ + "name": "remix.init", + "private": true, + "main": "index.js", + "dependencies": { + "@iarna/toml": "^2.2.5", + "execa": "^7.1.1", + "inquirer": "^9.2.6", + "open": "^9.1.0", + "parse-github-url": "^1.0.2" + } +} diff --git a/server/index.ts b/server/index.ts index c89520b..6a5b135 100644 --- a/server/index.ts +++ b/server/index.ts @@ -224,8 +224,8 @@ const server = app.listen(portToUse, () => { desiredPort === portToUse ? desiredPort : addy && typeof addy === 'object' - ? addy.port - : 0 + ? addy.port + : 0 if (portUsed !== desiredPort) { console.warn( diff --git a/tests/e2e/notes.test.ts b/tests/e2e/notes.test.ts new file mode 100644 index 0000000..449a224 --- /dev/null +++ b/tests/e2e/notes.test.ts @@ -0,0 +1,74 @@ +import { faker } from '@faker-js/faker' +import { prisma } from '#app/utils/db.server.ts' +import { expect, test } from '#tests/playwright-utils.ts' + +test('Users can create notes', async ({ page, login }) => { + const user = await login() + await page.goto(`/users/${user.username}/notes`) + + const newNote = createNote() + await page.getByRole('link', { name: /New Note/i }).click() + + // fill in form and submit + await page.getByRole('textbox', { name: /title/i }).fill(newNote.title) + await page.getByRole('textbox', { name: /content/i }).fill(newNote.content) + + await page.getByRole('button', { name: /submit/i }).click() + await expect(page).toHaveURL(new RegExp(`/users/${user.username}/notes/.*`)) +}) + +test('Users can edit notes', async ({ page, login }) => { + const user = await login() + + const note = await prisma.note.create({ + select: { id: true }, + data: { ...createNote(), ownerId: user.id }, + }) + await page.goto(`/users/${user.username}/notes/${note.id}`) + + // edit the note + await page.getByRole('link', { name: 'Edit', exact: true }).click() + const updatedNote = createNote() + await page.getByRole('textbox', { name: /title/i }).fill(updatedNote.title) + await page + .getByRole('textbox', { name: /content/i }) + .fill(updatedNote.content) + await page.getByRole('button', { name: /submit/i }).click() + + await expect(page).toHaveURL(new RegExp(`/users/${user.username}/notes/.*`)) + await expect( + page.getByRole('heading', { name: updatedNote.title }), + ).toBeVisible() +}) + +test('Users can delete notes', async ({ page, login }) => { + const user = await login() + + const note = await prisma.note.create({ + select: { id: true }, + data: { ...createNote(), ownerId: user.id }, + }) + await page.goto(`/users/${user.username}/notes/${note.id}`) + + // find links with href prefix + const noteLinks = page + .getByRole('main') + .getByRole('list') + .getByRole('listitem') + .getByRole('link') + const countBefore = await noteLinks.count() + await page.getByRole('button', { name: /delete/i }).click() + await expect( + page.getByText('Your note has been deleted.', { exact: true }), + ).toBeVisible() + await expect(page).toHaveURL(`/users/${user.username}/notes`) + const countAfter = await noteLinks.count() + expect(countAfter).toEqual(countBefore - 1) +}) + +function createNote() { + return { + title: faker.lorem.words(3), + content: faker.lorem.paragraphs(3), + } +} diff --git a/tests/e2e/search.test.ts b/tests/e2e/search.test.ts new file mode 100644 index 0000000..270eb4e --- /dev/null +++ b/tests/e2e/search.test.ts @@ -0,0 +1,26 @@ +import { invariant } from '@epic-web/invariant' +import { expect, test } from '#tests/playwright-utils.ts' + +test('Search from home page', async ({ page, insertNewUser }) => { + const newUser = await insertNewUser() + await page.goto('/') + + await page.getByRole('searchbox', { name: /search/i }).fill(newUser.username) + await page.getByRole('button', { name: /search/i }).click() + + await page.waitForURL( + `/users?${new URLSearchParams({ search: newUser.username })}`, + ) + await expect(page.getByText('Epic Notes Users')).toBeVisible() + const userList = page.getByRole('main').getByRole('list') + await expect(userList.getByRole('listitem')).toHaveCount(1) + invariant(newUser.name, 'User name not found') + await expect(page.getByAltText(newUser.name)).toBeVisible() + + await page.getByRole('searchbox', { name: /search/i }).fill('__nonexistent__') + await page.getByRole('button', { name: /search/i }).click() + await page.waitForURL(`/users?search=__nonexistent__`) + + await expect(userList.getByRole('listitem')).not.toBeVisible() + await expect(page.getByText(/no users found/i)).toBeVisible() +}) diff --git a/tsconfig.json b/tsconfig.json index 059910a..940246e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,11 +17,11 @@ "#*": ["./*"], "@/icon-name": [ "./app/components/ui/icons/name.d.ts", - "./types/icon-name.d.ts", - ], + "./types/icon-name.d.ts" + ] }, "skipLibCheck": true, "allowImportingTsExtensions": true, - "noEmit": true, - }, + "noEmit": true + } }