├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── deploy.yml │ └── ci.yml ├── degit.json ├── bunfig.toml ├── .gitignore ├── app ├── entities │ ├── team.ts │ ├── credential.ts │ ├── gravatar-profile.ts │ ├── membership.ts │ ├── user.ts │ ├── session.ts │ ├── gravatar-profile.test.ts │ └── user.test.ts ├── resources │ └── file.$key.ts ├── entry.client.tsx ├── redirects.ts ├── components │ ├── button.tsx │ ├── anchor-button.tsx │ └── spinner.tsx ├── helpers │ ├── cn.ts │ ├── rate-limit.ts │ ├── fingerprint.ts │ ├── body-parser.ts │ ├── cn.test.ts │ ├── fingerprint.test.ts │ ├── response.ts │ ├── cookies.ts │ ├── session.ts │ └── auth.ts ├── tasks │ └── cleanup-sessions.ts ├── jobs │ ├── email-account-recovery-code.ts │ └── sync-user-with-gravatar.ts ├── repositories.server │ ├── audit-logs.ts │ ├── memberships.ts │ ├── teams.ts │ ├── credentials.ts │ ├── users.ts │ ├── auth.ts │ └── sessions.ts ├── views │ ├── landings │ │ └── home.tsx │ ├── layouts │ │ ├── landings.tsx │ │ ├── auth.tsx │ │ └── admin.tsx │ ├── auth │ │ ├── logout.tsx │ │ ├── register.tsx │ │ ├── login.tsx │ │ └── recover.tsx │ ├── catch-all.tsx │ ├── admin │ │ ├── dashboard.tsx │ │ └── purge.tsx │ └── profile.tsx ├── entry.server.tsx ├── core │ └── strategies │ │ ├── login.ts │ │ └── register.ts ├── routes.ts ├── services.server │ ├── sync-user-with-gravatar.ts │ ├── sync-user-with-gravatar.test.ts │ └── auth │ │ ├── verify-email.ts │ │ ├── login.ts │ │ ├── register.ts │ │ ├── recover.ts │ │ ├── register.test.ts │ │ └── login.test.ts ├── mocks │ └── server.ts ├── clients │ ├── gravatar.ts │ └── gravatar.test.ts ├── entry.worker.ts ├── root.tsx └── assets │ └── tailwind.css ├── db ├── migrations │ ├── 20241101081131_right_princess_powerful.sql │ ├── 20241121083023_sour_squirrel_girl.sql │ ├── 20241024083556_natural_madame_masque.sql │ ├── meta │ │ ├── _journal.json │ │ ├── 20241020085523_snapshot.json │ │ └── 20241121083023_snapshot.json │ ├── 20241029073759_closed_earthquake.sql │ ├── 20241020085523_gray_the_call.sql │ └── 20241027053957_huge_echo.sql ├── helpers │ ├── id.ts │ └── timestamp.ts ├── seed.sql └── schema.ts ├── tsconfig.json ├── tsconfig.bun.json ├── .vscode ├── extensions.json └── settings.json ├── scripts ├── setup │ ├── gh │ │ ├── user.ts │ │ ├── action-secret.ts │ │ └── repository.ts │ ├── cf │ │ ├── account.ts │ │ ├── secret.ts │ │ ├── d1-database.ts │ │ ├── queue.ts │ │ ├── r2-bucket.ts │ │ ├── kv-namespace.ts │ │ └── worker.ts │ ├── helpers.ts │ └── package.ts ├── dev.ts └── setup.ts ├── docs ├── redirects.md ├── principles.md ├── file-structure.md ├── database.md ├── setup.md └── architecture.md ├── react-router.config.ts ├── tsconfig.cloudflare.json ├── drizzle.config.ts ├── vite.config.ts ├── LICENSE ├── biome.json ├── wrangler.json ├── README.md └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: sergiodxa 2 | -------------------------------------------------------------------------------- /degit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "action": "remove", 4 | "files": ["LICENSE", "README.md", ".github/FUNDING.yml"] 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | coverage = true # Always enable coverage 3 | # Uncomment to set a minimum coverage threshold 4 | # coverageThreshold = 0.8 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Folders 2 | /node_modules 3 | /.cache 4 | /.react-router 5 | /.wrangler 6 | /coverage 7 | /build 8 | 9 | # Files 10 | .DS_Store 11 | .dev.vars 12 | -------------------------------------------------------------------------------- /app/entities/team.ts: -------------------------------------------------------------------------------- 1 | import { TableEntity } from "edgekitjs"; 2 | 3 | export class Team extends TableEntity { 4 | get name() { 5 | return this.parser.string("name"); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /db/migrations/20241101081131_right_princess_powerful.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `permissions`;--> statement-breakpoint 2 | DROP TABLE `role_permissions`;--> statement-breakpoint 3 | DROP TABLE `roles`; -------------------------------------------------------------------------------- /app/resources/file.$key.ts: -------------------------------------------------------------------------------- 1 | import { fs } from "edgekitjs"; 2 | import type { Route } from "./+types/file.$key"; 3 | 4 | export function loader({ params }: Route.LoaderArgs) { 5 | return fs().serve(params.key); 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@total-typescript/tsconfig/bundler/dom/app", 3 | "files": [], 4 | "references": [ 5 | { "path": "./tsconfig.bun.json" }, 6 | { "path": "./tsconfig.cloudflare.json" } 7 | ], 8 | "compilerOptions": {} 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.bun.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["./scripts/**/*.ts"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "jsx": "react-jsx", 7 | "types": ["@total-typescript/ts-reset", "@types/bun"], 8 | "rootDirs": ["./scripts"] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode, startTransition } from "react"; 2 | import { hydrateRoot } from "react-dom/client"; 3 | import { HydratedRouter } from "react-router/dom"; 4 | 5 | startTransition(() => { 6 | hydrateRoot( 7 | document, 8 | 9 | 10 | , 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /db/helpers/id.ts: -------------------------------------------------------------------------------- 1 | import { createId } from "@paralleldrive/cuid2"; 2 | import { text } from "drizzle-orm/sqlite-core"; 3 | 4 | export const ID_LENGTH = 24; 5 | 6 | export function cuid(name: Name, indexName?: string) { 7 | return text(name, { mode: "text", length: ID_LENGTH }) 8 | .unique(indexName) 9 | .notNull() 10 | .$defaultFn(() => createId()); 11 | } 12 | -------------------------------------------------------------------------------- /db/migrations/20241121083023_sour_squirrel_girl.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS `users_credentials_reset_token_unique`;--> statement-breakpoint 2 | DROP INDEX IF EXISTS `users_credentials_reset_token_idx`;--> statement-breakpoint 3 | CREATE UNIQUE INDEX `users_credentials_user_id_unique` ON `users_credentials` (`user_id`);--> statement-breakpoint 4 | ALTER TABLE `users_credentials` DROP COLUMN `reset_token`; -------------------------------------------------------------------------------- /app/redirects.ts: -------------------------------------------------------------------------------- 1 | import { URLPattern } from "urlpattern-polyfill"; 2 | 3 | export default function redirects(url: URL): Array { 4 | return [ 5 | { 6 | source: new URLPattern("/home", url.toString()), 7 | destination: "/", 8 | permanent: false, 9 | }, 10 | ]; 11 | } 12 | 13 | interface Redirect { 14 | source: URLPattern; 15 | destination: string; 16 | permanent: boolean; 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bierner.jsdoc-markdown-highlighting", 4 | "biomejs.biome", 5 | "bradlc.vscode-tailwindcss", 6 | "csstools.postcss", 7 | "formulahendry.auto-close-tag", 8 | "formulahendry.auto-rename-tag", 9 | "github.vscode-github-actions", 10 | "vitest.explorer", 11 | "vscode-icons-team.vscode-icons", 12 | "ms-playwright.playwright" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /app/components/button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "app:helpers/cn"; 2 | import type { ComponentProps } from "react"; 3 | 4 | export function Button(props: ComponentProps<"button">) { 5 | return ( 6 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/entities/session.ts: -------------------------------------------------------------------------------- 1 | import { StringParser, TableEntity } from "edgekitjs"; 2 | 3 | export class Session extends TableEntity { 4 | get userId() { 5 | return new StringParser(this.parser.string("userId")).cuid(); 6 | } 7 | 8 | get ip() { 9 | if (this.parser.isNull("ipAddress")) return null; 10 | return new StringParser(this.parser.string("ipAddress")).ip(); 11 | } 12 | 13 | get ua() { 14 | if (this.parser.isNull("userAgent")) return null; 15 | return new StringParser(this.parser.string("userAgent")).userAgent(); 16 | } 17 | 18 | get expiresAt() { 19 | return this.parser.date("expiresAt"); 20 | } 21 | 22 | get hasExpired() { 23 | return this.expiresAt < new Date(); 24 | } 25 | 26 | get lastActivityAt() { 27 | return this.parser.date("lastActivityAt"); 28 | } 29 | 30 | get payload() { 31 | return this.parser.object("payload"); 32 | } 33 | 34 | get teamId() { 35 | return new StringParser(this.payload.string("teamId")).cuid(); 36 | } 37 | 38 | get teams() { 39 | return this.payload 40 | .array("teams") 41 | .map((team: string) => new StringParser(team).cuid()); 42 | } 43 | 44 | get geo() { 45 | if (!this.payload.has("geo")) return null; 46 | let geo = this.payload.object("geo"); 47 | return { city: geo.string("city"), country: geo.string("country") }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /wrangler.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/wrangler@latest/config-schema.json", 3 | "name": "edgefirst-dev-starter", 4 | "main": "./app/entry.worker.ts", 5 | "compatibility_date": "2025-02-20", 6 | "compatibility_flags": ["nodejs_compat"], 7 | "workers_dev": true, 8 | "dev": { "port": 3000 }, 9 | "placement": { "mode": "off" }, 10 | "observability": { "enabled": true }, 11 | "assets": { "directory": "./build/client" }, 12 | "browser": { "binding": "BROWSER" }, 13 | "d1_databases": [ 14 | { 15 | "binding": "DB", 16 | "database_name": "starter-db", 17 | "database_id": "548e1d13-bc2b-4113-b179-717e7808e4d4", 18 | "migrations_dir": "./db/migrations" 19 | } 20 | ], 21 | "kv_namespaces": [ 22 | { "binding": "KV", "id": "186c61a8603a44a3bd0dded4aa290204" } 23 | ], 24 | "r2_buckets": [{ "binding": "FS", "bucket_name": "starter-bucket" }], 25 | "queues": { 26 | "consumers": [ 27 | { 28 | "queue": "starter-queue", 29 | "max_batch_size": 10, 30 | "max_batch_timeout": 30, 31 | "max_retries": 10 32 | } 33 | ], 34 | "producers": [{ "binding": "QUEUE", "queue": "starter-queue" }] 35 | }, 36 | "ai": { "binding": "AI" }, 37 | "triggers": { "crons": ["* * * * *"] }, 38 | "vars": { "APP_ENV": "development" }, 39 | "env": { "production": { "vars": { "APP_ENV": "production" } } } 40 | } 41 | -------------------------------------------------------------------------------- /scripts/dev.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This script let us run the app in development mode using `wrangler dev` but 3 | * and also watch changes in our app and re-build the React Router app. 4 | * 5 | * This way we will lose the HMR and HDR features, but instead we will be able 6 | * to test our app inside Wrangler which will let use simulate a Cloudflare 7 | * Worker environment and get access to all bindings without depending on the 8 | * Cloudflare Proxy API. 9 | */ 10 | import * as FS from "node:fs/promises"; 11 | import Path from "node:path"; 12 | import { $ } from "bun"; 13 | 14 | const appPath = Path.join(import.meta.dir, "../app"); 15 | const buildPath = Path.join(import.meta.dir, "../build"); 16 | 17 | await Promise.all([retry(run), watch()]); 18 | 19 | /** 20 | * This function will build the app and start the server. 21 | */ 22 | async function run() { 23 | await $`bun run build`.nothrow(); 24 | await $`bun start`; 25 | } 26 | 27 | /** 28 | * This function will recursively watch the app directory and delete the build 29 | * directory when a change is detected. 30 | */ 31 | async function watch() { 32 | for await (let _ of FS.watch(appPath, { recursive: true })) { 33 | await FS.rm(buildPath, { recursive: true }); 34 | } 35 | } 36 | 37 | async function retry(cb: () => Promise) { 38 | await cb().catch(() => retry(cb)); 39 | } 40 | -------------------------------------------------------------------------------- /scripts/setup/gh/action-secret.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "@edgefirst-dev/data"; 2 | import type { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type { Octokit } from "@octokit/core"; 4 | import sodium from "libsodium-wrappers"; 5 | 6 | export class ActionSecret extends Data { 7 | static async create( 8 | gh: Octokit, 9 | owner: string, 10 | repo: string, 11 | name: string, 12 | value: string, 13 | ) { 14 | let data = await ActionSecret.encrypt(gh, owner, repo, value); 15 | 16 | await gh.request("PUT /repos/{owner}/{repo}/actions/secrets/{name}", { 17 | owner, 18 | repo, 19 | name, 20 | data, 21 | }); 22 | } 23 | 24 | private static async encrypt( 25 | gh: Octokit, 26 | owner: string, 27 | repo: string, 28 | value: string, 29 | ) { 30 | let { 31 | data: { key, key_id }, 32 | } = await gh.request( 33 | "GET /repos/{owner}/{repo}/actions/secrets/public-key", 34 | { owner, repo }, 35 | ); 36 | 37 | await sodium.ready; 38 | let binPublicKey = sodium.from_base64(key, sodium.base64_variants.ORIGINAL); 39 | let binSecret = sodium.from_string(value); 40 | let encrypted = sodium.crypto_box_seal(binSecret, binPublicKey); 41 | let encrypted_value = sodium.to_base64( 42 | encrypted, 43 | sodium.base64_variants.ORIGINAL, 44 | ); 45 | 46 | return { key_id, encrypted_value }; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/views/catch-all.tsx: -------------------------------------------------------------------------------- 1 | import { AnchorButton } from "app:components/anchor-button"; 2 | import { notFound } from "app:helpers/response"; 3 | import redirects from "app:redirects"; 4 | import { generatePath, redirectDocument } from "react-router"; 5 | import type { Route } from "./+types/catch-all"; 6 | 7 | export async function loader({ request }: Route.LoaderArgs) { 8 | let url = new URL(request.url); 9 | 10 | for (let redirect of redirects(url)) { 11 | let match = redirect.source.exec(url); 12 | if (!match) continue; 13 | let location = generatePath(redirect.destination, match.pathname.groups); 14 | throw redirectDocument(location, redirect.permanent ? 301 : 302); 15 | } 16 | 17 | return notFound(null); 18 | } 19 | 20 | export default function Component() { 21 | return ( 22 |
23 |
24 |

25 | Page Not Found 26 |

27 | 28 |

29 | We're sorry, but an unexpected error has occurred. Please try again 30 | later or contact support if the issue persists. 31 |

32 | 33 | Go to Homepage 34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/helpers/cn.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "bun:test"; 2 | 3 | import { cn } from "./cn"; 4 | 5 | test("removes conflicting classes using twMerge", () => { 6 | let classes = cn("text-red-500", "text-blue-500"); 7 | 8 | expect(classes).toBe("text-blue-500"); 9 | }); 10 | 11 | test("merges multiple classes into a single string", () => { 12 | let classes = cn("text-red-500", "font-bold"); 13 | 14 | expect(classes).toBe("text-red-500 font-bold"); 15 | }); 16 | 17 | test("removes empty classes", () => { 18 | let classes = cn("text-red-500", "", "font-bold"); 19 | 20 | expect(classes).toBe("text-red-500 font-bold"); 21 | }); 22 | 23 | test("removes undefined classes", () => { 24 | let classes = cn("text-red-500", undefined, "font-bold"); 25 | 26 | expect(classes).toBe("text-red-500 font-bold"); 27 | }); 28 | 29 | test("removes null classes", () => { 30 | let classes = cn("text-red-500", null, "font-bold"); 31 | 32 | expect(classes).toBe("text-red-500 font-bold"); 33 | }); 34 | 35 | test("resolves nested arrays", () => { 36 | let classes = cn("text-red-500", ["font-bold", "bg-blue-500"]); 37 | 38 | expect(classes).toBe("text-red-500 font-bold bg-blue-500"); 39 | }); 40 | 41 | test("resolves nested objects", () => { 42 | let classes = cn("text-red-500", { "font-bold": true, "bg-blue-500": true }); 43 | 44 | expect(classes).toBe("text-red-500 font-bold bg-blue-500"); 45 | }); 46 | -------------------------------------------------------------------------------- /scripts/setup/package.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "node:path"; 2 | import { Data } from "@edgefirst-dev/data"; 3 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 4 | import { file, write } from "bun"; 5 | import type { Repository } from "./gh/repository"; 6 | 7 | export class Package extends Data { 8 | set name(name: string) { 9 | this.parser = new ObjectParser({ ...this.parser.valueOf(), name }); 10 | } 11 | 12 | set description(description: string) { 13 | this.parser = new ObjectParser({ ...this.parser.valueOf(), description }); 14 | } 15 | 16 | set repository(repo: Repository) { 17 | this.parser = new ObjectParser({ 18 | ...this.parser.valueOf(), 19 | repository: { type: "git", url: repo.url }, 20 | }); 21 | } 22 | 23 | get repositoryURL() { 24 | return new URL(this.parser.object("repository").string("url")); 25 | } 26 | 27 | static async read() { 28 | let path = Path.resolve("./package.json"); 29 | let pkg = file(path); 30 | 31 | if (await pkg.exists()) { 32 | return new Package(new ObjectParser(await pkg.json())); 33 | } 34 | 35 | throw new Error( 36 | "Failed to find the package.json file. Ensure you're running the setup script from the root of your project.", 37 | ); 38 | } 39 | 40 | async write() { 41 | let path = Path.resolve("./package.json"); 42 | await write(file(path), JSON.stringify(this.parser.valueOf(), null, "\t")); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # We run CI on every push 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | types: [opened, synchronize] 10 | 11 | # Automatically cancel running workflows when there's a new one 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 14 | cancel-in-progress: true 15 | 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | permissions: 20 | contents: read 21 | packages: read 22 | 23 | jobs: 24 | typecheck: 25 | name: Typechecker 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: oven-sh/setup-bun@v2 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: 20 33 | - run: bun install --frozen-lockfile 34 | - run: bun run build 35 | - run: bun run rr:typegen 36 | - run: bun run typecheck 37 | 38 | tests: 39 | name: Tests 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: oven-sh/setup-bun@v2 44 | - run: bun install --frozen-lockfile 45 | - run: bun test 46 | 47 | quality: 48 | name: Code Quality 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: oven-sh/setup-bun@v2 53 | - run: bun install --frozen-lockfile 54 | - run: bun run quality 55 | -------------------------------------------------------------------------------- /app/entities/gravatar-profile.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | 3 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 4 | import { GravatarProfile } from "./gravatar-profile"; 5 | 6 | describe(GravatarProfile.name, () => { 7 | let data = { 8 | display_name: "John Doe", 9 | pronouns: "he/him", 10 | job_title: "Software Engineer", 11 | company: "Acme Inc", 12 | location: "San Francisco, CA", 13 | }; 14 | 15 | test("#constructor", () => { 16 | let profile = new GravatarProfile(new ObjectParser(data)); 17 | expect(profile).toBeInstanceOf(GravatarProfile); 18 | }); 19 | 20 | test("get displayName", () => { 21 | let profile = new GravatarProfile(new ObjectParser(data)); 22 | expect(profile.displayName).toBe("John Doe"); 23 | }); 24 | 25 | test("get pronouns", () => { 26 | let profile = new GravatarProfile(new ObjectParser(data)); 27 | expect(profile.pronouns).toBe("he/him"); 28 | }); 29 | 30 | test("get jobTitle", () => { 31 | let profile = new GravatarProfile(new ObjectParser(data)); 32 | expect(profile.jobTitle).toBe("Software Engineer"); 33 | }); 34 | 35 | test("get company", () => { 36 | let profile = new GravatarProfile(new ObjectParser(data)); 37 | expect(profile.company).toBe("Acme Inc"); 38 | }); 39 | 40 | test("get location", () => { 41 | let profile = new GravatarProfile(new ObjectParser(data)); 42 | expect(profile.location).toBe("San Francisco, CA"); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /app/helpers/fingerprint.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, mock, test } from "bun:test"; 2 | 3 | import { User } from "app:entities/user"; 4 | import type { users } from "db:schema"; 5 | import { fingerprint } from "./fingerprint"; 6 | 7 | mock.module("edgekitjs", () => { 8 | return { 9 | request: () => 10 | new Request("https://example.com", { 11 | headers: { 12 | "User-Agent": 13 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", 14 | "CF-Connecting-IP": "127.0.0.1", 15 | }, 16 | }), 17 | }; 18 | }); 19 | 20 | describe(fingerprint.name, () => { 21 | test("returns a fingerprint", () => { 22 | expect(fingerprint()).toBe( 23 | "15c340949579c701e4e1d00ebf0a89c095442d74d6572ccbd93bd2d4770feb6f", 24 | ); 25 | }); 26 | 27 | test("returns a fingerprint with user", () => { 28 | let userRow: typeof users.$inferSelect = { 29 | // biome-ignore lint/suspicious/noExplicitAny: It's a test 30 | id: "a3j3p00nmf5fnhggm9zqc6l8" as any, 31 | createdAt: new Date(), 32 | updatedAt: new Date(), 33 | email: "john.doe@example.com", 34 | emailVerifiedAt: new Date(), 35 | emailVerificationToken: "xijjlqpjwls8h18k1fn49r6y", 36 | displayName: null, 37 | avatarKey: null, 38 | role: "user", 39 | }; 40 | 41 | let user = User.from(userRow); 42 | 43 | expect(fingerprint(user)).toBe( 44 | "604d03bc1e9867e2cabea8eead7ad26c3a2105763ed6eae0196d17973e2b1cf6", 45 | ); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /scripts/setup/cf/secret.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "@edgefirst-dev/data"; 2 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type Cloudflare from "cloudflare"; 4 | import consola from "consola"; 5 | import { generatePath } from "react-router"; 6 | import type { Account } from "./account"; 7 | import { Worker } from "./worker"; 8 | 9 | export class Secret extends Data { 10 | get name() { 11 | return this.parser.string("name"); 12 | } 13 | 14 | get type() { 15 | return this.parser.string("type"); 16 | } 17 | 18 | static async create( 19 | cf: Cloudflare, 20 | account: Account, 21 | worker: Worker, 22 | name: string, 23 | text: string, 24 | ) { 25 | consola.info(`Creating secret ${name}.`); 26 | 27 | let path = generatePath( 28 | "client/v4/accounts/:accountId/workers/scripts/:workerName/secrets", 29 | { accountId: account.id, workerName: worker.name }, 30 | ); 31 | 32 | let response = await fetch(new URL(path, "https://api.cloudflare.com"), { 33 | method: "PUT", 34 | body: JSON.stringify({ name, text, type: "secret_text" }), 35 | headers: { 36 | Authorization: `Bearer ${cf.apiToken}`, 37 | "Content-Type": "application/json", 38 | }, 39 | }); 40 | 41 | if (!response.ok) throw new Error(`Failed to create secret ${name}.`); 42 | 43 | let result = await response.json(); 44 | let parser = new ObjectParser(result); 45 | 46 | consola.success(`Created secret ${name}.`); 47 | 48 | return new Secret(parser.object("result")); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from "msw"; 2 | 3 | export const gravatar = { 4 | success: http.get("https://api.gravatar.com/v3/profiles/:hash", () => { 5 | return HttpResponse.json({ 6 | hash: "14330318de450e39207d3063ca9dc23698bba910562fdb497d50cc52e1bae0ea", 7 | display_name: "Sergio Xalambrí", 8 | location: "Perú", 9 | job_title: "Web Developer", 10 | company: "Daffy.org", 11 | pronouns: "He/Him", 12 | }); 13 | }), 14 | 15 | notFoundError: http.get("https://api.gravatar.com/v3/profiles/:hash", () => { 16 | return new HttpResponse(null, { status: 404 }); 17 | }), 18 | 19 | rateLimitError: http.get("https://api.gravatar.com/v3/profiles/:hash", () => { 20 | return new HttpResponse(null, { status: 429 }); 21 | }), 22 | 23 | serverError: http.get("https://api.gravatar.com/v3/profiles/:hash", () => { 24 | return new HttpResponse(null, { status: 500 }); 25 | }), 26 | }; 27 | 28 | export const pwnedPasswords = { 29 | weak: http.get("https://api.pwnedpasswords.com/range/:hash", () => { 30 | return new Response("d2f5c131c7ab9fbc431622225e430a49ccd"); 31 | }), 32 | 33 | strong: http.get("https://api.pwnedpasswords.com/range/:hash", () => { 34 | return new Response("1da2f5c1331c7ab39fbc431622225e4f30a49ccd"); 35 | }), 36 | }; 37 | 38 | export const emailVerifier = { 39 | valid: http.get("https://verifyright.co/verify/:value", () => { 40 | return HttpResponse.json({ status: true }); 41 | }), 42 | 43 | invalid: http.get("https://verifyright.co/verify/:value", () => { 44 | return HttpResponse.json({ 45 | status: false, 46 | error: { code: 2, message: "Disposable email address" }, 47 | }); 48 | }), 49 | }; 50 | -------------------------------------------------------------------------------- /db/migrations/20241029073759_closed_earthquake.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `permissions` ( 2 | `id` text(24) NOT NULL, 3 | `created_at` integer NOT NULL, 4 | `updated_at` integer NOT NULL, 5 | `action` text NOT NULL, 6 | `entity` text NOT NULL, 7 | `access` text NOT NULL, 8 | `description` text 9 | ); 10 | --> statement-breakpoint 11 | CREATE UNIQUE INDEX `permissions_id_unique` ON `permissions` (`id`);--> statement-breakpoint 12 | CREATE TABLE `role_permissions` ( 13 | `id` text(24) NOT NULL, 14 | `created_at` integer NOT NULL, 15 | `updated_at` integer NOT NULL, 16 | `role_id` text NOT NULL, 17 | `permission_id` text NOT NULL, 18 | FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON UPDATE no action ON DELETE cascade, 19 | FOREIGN KEY (`permission_id`) REFERENCES `permissions`(`id`) ON UPDATE no action ON DELETE cascade 20 | ); 21 | --> statement-breakpoint 22 | CREATE UNIQUE INDEX `role_permissions_id_unique` ON `role_permissions` (`id`);--> statement-breakpoint 23 | CREATE INDEX `role_permissions_role_id_idx` ON `role_permissions` (`role_id`);--> statement-breakpoint 24 | CREATE INDEX `role_permissions_permission_id_idx` ON `role_permissions` (`permission_id`);--> statement-breakpoint 25 | CREATE INDEX `role_permissions_role_permission_idx` ON `role_permissions` (`role_id`,`permission_id`);--> statement-breakpoint 26 | CREATE TABLE `roles` ( 27 | `id` text(24) NOT NULL, 28 | `created_at` integer NOT NULL, 29 | `updated_at` integer NOT NULL, 30 | `name` text NOT NULL, 31 | `description` text 32 | ); 33 | --> statement-breakpoint 34 | CREATE UNIQUE INDEX `roles_id_unique` ON `roles` (`id`);--> statement-breakpoint 35 | ALTER TABLE `sessions` ADD `expires_at` integer NOT NULL; -------------------------------------------------------------------------------- /app/helpers/response.ts: -------------------------------------------------------------------------------- 1 | import { data as json } from "react-router"; 2 | 3 | export function ok(data: T, init?: ResponseInit) { 4 | return json( 5 | { ...data, ok: true as const }, 6 | { ...init, status: 200, statusText: "OK" }, 7 | ); 8 | } 9 | 10 | export function badRequest(data: T, init?: ResponseInit) { 11 | return json( 12 | { ...data, ok: false as const }, 13 | { ...init, status: 400, statusText: "Bad Request" }, 14 | ); 15 | } 16 | 17 | export function unauthorized(data: T, init?: ResponseInit) { 18 | return json( 19 | { ...data, ok: false as const }, 20 | { ...init, status: 401, statusText: "Unauthorized" }, 21 | ); 22 | } 23 | 24 | export function forbidden(data: T, init?: ResponseInit) { 25 | return json( 26 | { ...data, ok: false as const }, 27 | { ...init, status: 403, statusText: "Forbidden" }, 28 | ); 29 | } 30 | 31 | export function notFound(data: T, init?: ResponseInit) { 32 | return json( 33 | { ...data, ok: false as const }, 34 | { ...init, status: 404, statusText: "Not Found" }, 35 | ); 36 | } 37 | 38 | export function unprocessableEntity(data: T, init?: ResponseInit) { 39 | return json( 40 | { ...data, ok: false as const }, 41 | { ...init, status: 422, statusText: "Unprocessable Entity" }, 42 | ); 43 | } 44 | 45 | export function tooManyRequests(data: T, init?: ResponseInit) { 46 | return json( 47 | { ...data, ok: false as const }, 48 | { ...init, status: 429, statusText: "Too Many Requests" }, 49 | ); 50 | } 51 | 52 | export function internalServerError(data: T, init?: ResponseInit) { 53 | return json( 54 | { ...data, ok: false as const }, 55 | { ...init, status: 500, statusText: "Internal Server Error" }, 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/repositories.server/users.ts: -------------------------------------------------------------------------------- 1 | import { User } from "app:entities/user"; 2 | import schema from "db:schema"; 3 | import { count, eq } from "drizzle-orm"; 4 | import { orm } from "edgekitjs"; 5 | 6 | export class UsersRepository { 7 | async findAll() { 8 | return User.fromMany(await orm().select().from(schema.users).execute()); 9 | } 10 | 11 | async findById(id: User["id"]) { 12 | return User.fromMany( 13 | await orm() 14 | .select() 15 | .from(schema.users) 16 | .where(eq(schema.users.id, id)) 17 | .limit(1) 18 | .execute(), 19 | ); 20 | } 21 | 22 | async findByEmail(email: User["email"]) { 23 | return User.fromMany( 24 | await orm() 25 | .select() 26 | .from(schema.users) 27 | .where(eq(schema.users.email, email.toString())) 28 | .limit(1) 29 | .execute(), 30 | ); 31 | } 32 | 33 | async create(input: Omit) { 34 | let [user] = await orm().insert(schema.users).values(input).returning(); 35 | if (user) return User.from(user); 36 | throw new Error("Failed to create user"); 37 | } 38 | 39 | async update( 40 | id: User["id"], 41 | input: Partial, 42 | ) { 43 | await orm() 44 | .update(schema.users) 45 | .set(input) 46 | .where(eq(schema.users.id, id)) 47 | .execute(); 48 | } 49 | 50 | async verifyEmail(user: User) { 51 | await orm() 52 | .update(schema.users) 53 | .set({ emailVerifiedAt: new Date() }) 54 | .where(eq(schema.users.id, user.id)) 55 | .execute(); 56 | } 57 | 58 | async count() { 59 | let [result] = await orm() 60 | .select({ count: count() }) 61 | .from(schema.users) 62 | .execute(); 63 | return result?.count ?? 0; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /db/seed.sql: -------------------------------------------------------------------------------- 1 | -- This file is used to seed the database with initial data. 2 | -- You can run this file against your local database using: 3 | -- ```sh 4 | -- bun run db:seed 5 | -- ``` 6 | -- Where `` is the name of your database. 7 | -- Edit the SQL below to seed your database with the initial data you need. 8 | 9 | -- The SQL below seeds the database with a root user with the email address 10 | -- `test@edgefirst.dev` and the display name `John Doe`. 11 | INSERT INTO users 12 | (id, email, display_name, role, email_verification_token, created_at, updated_at) 13 | VALUES 14 | ('pix455o8e9fml7i37qw2ffic', 'test@edgefirst.dev', 'John Doe', 'root', 'otc30x0tgeghn4llocvw8258', 1730188960512, 1730188960512); 15 | 16 | -- Here we're creating the user credentials for the root user. 17 | -- The password_hash used below is the hash of the string 'password' 18 | INSERT INTO users_credentials 19 | (id, user_id, password_hash, created_at, updated_at) 20 | VALUES 21 | ( 22 | 'os4x4zxhdbapbkcyt6x5380p', 'pix455o8e9fml7i37qw2ffic', 23 | '$2a$10$BZTY.7KwIQO9AE/Rd.n3TuzkcIwaE0V0Wd9LppZPRhHRMKq9yHZCK', 24 | 1730188960512, 1730188960512 25 | ); 26 | 27 | -- Here we're creating a team named Root Team. 28 | INSERT INTO teams 29 | (id, name, created_at, updated_at) 30 | VALUES 31 | ('gf259lidx7f4e64s4kzkisok', 'Root Team', 1730188960512, 1730188960512); 32 | 33 | -- Here we're creating an accepted membership for the root user in the 34 | -- Root Team. 35 | INSERT INTO memberships 36 | (id, team_id, user_id, role, accepted_at, created_at, updated_at) 37 | VALUES 38 | ('qwjpvri7hhworstxvmpusobb', 'gf259lidx7f4e64s4kzkisok', 'pix455o8e9fml7i37qw2ffic', 'owner', 1730188960512, 1730188960512, 1730188960512); 39 | -------------------------------------------------------------------------------- /app/services.server/auth/verify-email.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "app:entities/user"; 2 | import { AuditLogsRepository } from "app:repositories.server/audit-logs"; 3 | import { UsersRepository } from "app:repositories.server/users"; 4 | import type { Email } from "edgekitjs"; 5 | import { type Entity, waitUntil } from "edgekitjs"; 6 | 7 | /** 8 | * Verifies a user's email address by setting the `emailVerifiedAt` field. 9 | * 10 | * @param input - The registration input data containing the email and password. 11 | * @param deps - The dependency injection object containing repositories. 12 | * @throws {Error} If the user is not found. 13 | * @returns A promise that resolves when the email is verified, or immediately if the user is already verified. 14 | */ 15 | export async function verifyEmail( 16 | input: verifyEmail.Input, 17 | deps: verifyEmail.Dependencies = { 18 | audits: new AuditLogsRepository(), 19 | user: new UsersRepository(), 20 | }, 21 | ) { 22 | let [user] = await deps.user.findByEmail(input.email); 23 | if (!user) throw new Error("User not found"); 24 | 25 | // If the user is already verified, we don't need to do anything 26 | if (user.hasEmailVerified) return; 27 | 28 | // Here we should validate that data.code is valid 29 | 30 | await deps.user.verifyEmail(user); 31 | 32 | waitUntil(deps.audits.create(user, "verified_email")); 33 | } 34 | 35 | export namespace verifyEmail { 36 | export interface Input { 37 | readonly email: Email; 38 | readonly code: string; 39 | } 40 | 41 | export interface Dependencies { 42 | audits: { 43 | create(user: User, action: string, entity?: Entity): Promise; 44 | }; 45 | user: { 46 | findByEmail(email: Email): Promise; 47 | verifyEmail(user: User): Promise; 48 | }; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /docs/file-structure.md: -------------------------------------------------------------------------------- 1 | # File Structure of Routes 2 | 3 | The routes of the app are split into two main folders: `app/resources` and `app/views`. 4 | 5 | The `app/resources` folder contains any resource route that is not a view, like an API endpoint, a dynamically generated file (think PDFs, social images, etc.), a redirect route, a webhook, etc. 6 | 7 | The `app/views` folder contains all the routes that render a view, like the home page, the login page, the admin dashboard, etc. 8 | 9 | ## View Routes 10 | 11 | Inside `app/views/layouts` there's also the layouts for the views, like the `app/views/layouts/auth.tsx` which is used for the `/login` and `/register` routes, or the `app/views/layouts/landing.tsx` which is used for the landing pages. 12 | 13 | You can then organize other routes however you prefer, one way could be to follow Rails like conventions and create a folder for each resource, like `app/views/users`, `app/views/posts`, etc. And inside each folder, you can create the routes for that resource, like `app/views/users/index.tsx`, `app/views/users/show.tsx`, `app/views/users/edit.tsx`, etc. 14 | 15 | ``` 16 | app/views 17 | ├── layouts 18 | │ ├── auth.tsx 19 | │ ├── landing.tsx 20 | ├── users 21 | │ ├── edit.tsx 22 | │ ├── index.tsx 23 | │ ├── new.tsx 24 | │ ├── show.tsx 25 | ├── posts 26 | │ ├── edit.tsx 27 | │ ├── index.tsx 28 | │ ├── new.tsx 29 | │ ├── show.tsx 30 | ``` 31 | 32 | ## Resource Routes 33 | 34 | I recommend you to also create `app/resources/api` and `app/resources/webhooks` folders to split the API and webhook routes, respectively, other resources can be created as needed. 35 | 36 | ``` 37 | app/resources 38 | ├── api 39 | │ ├── users.ts 40 | │ ├── posts.ts 41 | ├── webhooks 42 | │ ├── stripe.ts 43 | │ ├── mailgun.ts 44 | ├── file.ts 45 | ``` 46 | -------------------------------------------------------------------------------- /app/views/admin/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { rootOnly } from "app:helpers/auth"; 2 | import { ok } from "app:helpers/response"; 3 | import { TeamsRepository } from "app:repositories.server/teams"; 4 | import { UsersRepository } from "app:repositories.server/users"; 5 | import { NumberParser } from "edgekitjs"; 6 | import type { Route } from "./+types/dashboard"; 7 | 8 | export async function loader({ request }: Route.LoaderArgs) { 9 | await rootOnly(request); 10 | 11 | let [users, teams] = await Promise.all([ 12 | new UsersRepository().count(), 13 | new TeamsRepository().count(), 14 | ]); 15 | 16 | return ok({ 17 | users: new NumberParser(users).format(), 18 | teams: new NumberParser(teams).format(), 19 | }); 20 | } 21 | 22 | export default function Component({ loaderData }: Route.ComponentProps) { 23 | return ( 24 |
25 |
26 |

Dashboard

27 |
28 | 29 |
30 |
31 |

32 | Total Users 33 |

34 |

35 | {loaderData.users} 36 |

37 |
38 | 39 |
40 |

41 | Total Teams 42 |

43 |

44 | {loaderData.teams} 45 |

46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/helpers/cookies.ts: -------------------------------------------------------------------------------- 1 | import { env } from "edgekitjs"; 2 | import { createCookie } from "react-router"; 3 | 4 | export namespace Cookies { 5 | /** 6 | * The `session` cookie is used to store the user's session information. 7 | * 8 | * @example 9 | * let sessionValue = await Cookies.session.parse(cookieHeader); 10 | */ 11 | export const session = createCookie("session", { 12 | path: "/", 13 | sameSite: "lax", 14 | secure: process.env.NODE_ENV === "production", 15 | httpOnly: true, 16 | maxAge: 1000 * 60 * 60 * 24 * 365, // 1 year 17 | secrets: [env().fetch("SESSION_SECRET", "s3cr3t")], 18 | }); 19 | 20 | /** 21 | * This cookie is used to store the user's expired session information. 22 | */ 23 | export const expiredSession = createCookie("expired_session", { 24 | path: "/", 25 | sameSite: "lax", 26 | secure: process.env.NODE_ENV === "production", 27 | httpOnly: true, 28 | secrets: [env().fetch("SESSION_SECRET", "s3cr3t")], 29 | }); 30 | 31 | /** 32 | * The `lng` cookie is used to store the user's preferred language (locale). 33 | * 34 | * @example 35 | * let language = await Cookies.lng.parse(cookieHeader); 36 | */ 37 | export const lng = createCookie("lng", { 38 | path: "/", 39 | sameSite: "lax", 40 | secure: process.env.NODE_ENV === "production", 41 | httpOnly: true, 42 | secrets: [env().fetch("SESSION_SECRET", "s3cr3t")], 43 | }); 44 | 45 | /** 46 | * The `returnTo` cookie is used to store the URL the user should be 47 | * redirected to after logging in. 48 | * 49 | * @example 50 | * let returnTo = await Cookies.returnTo.parse(cookieHeader); 51 | */ 52 | export const returnTo = createCookie("returnTo", { 53 | path: "/", 54 | sameSite: "lax", 55 | secure: process.env.NODE_ENV === "production", 56 | httpOnly: true, 57 | secrets: [env().fetch("SESSION_SECRET", "s3cr3t")], 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /app/entities/user.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "bun:test"; 2 | import type { users } from "db:schema"; 3 | 4 | import { Email } from "edgekitjs"; 5 | import { User } from "./user"; 6 | 7 | describe(User.name, () => { 8 | let userRow: typeof users.$inferSelect = { 9 | // biome-ignore lint/suspicious/noExplicitAny: It's a test 10 | id: "a3j3p00nmf5fnhggm9zqc6l8" as any, 11 | createdAt: new Date(), 12 | updatedAt: new Date(), 13 | email: "john.doe@example.com", 14 | emailVerifiedAt: new Date(), 15 | emailVerificationToken: "xijjlqpjwls8h18k1fn49r6y", 16 | displayName: null, 17 | avatarKey: null, 18 | role: "user", 19 | }; 20 | 21 | test("#constructor", () => { 22 | let user = User.from(userRow); 23 | expect(user).toBeInstanceOf(User); 24 | expect(user.toString()).toBe("user:a3j3p00nmf5fnhggm9zqc6l8"); 25 | }); 26 | 27 | test("get email", () => { 28 | let user = User.from(userRow); 29 | expect(user.email).toBeInstanceOf(Email); 30 | }); 31 | 32 | test("get emailVerifiedAt", () => { 33 | let user = User.from(userRow); 34 | expect(user.emailVerifiedAt).toBeInstanceOf(Date); 35 | }); 36 | 37 | test("get hasEmailVerified", () => { 38 | let user = User.from(userRow); 39 | expect(user.hasEmailVerified).toBe(true); 40 | }); 41 | 42 | test("#avatar fallbacks to Gravatar if no avatarKey", () => { 43 | let user = User.from(userRow); 44 | expect(user.avatar).toBe( 45 | "https://gravatar.com/avatar/836f82db99121b3481011f16b49dfa5fbc714a0d1b1b9f784a1ebbbf5b39577f", 46 | ); 47 | }); 48 | 49 | test("#diplayName fallbacks to the email's username", () => { 50 | let user = User.from(userRow); 51 | expect(user.displayName).toBe("john.doe"); 52 | }); 53 | 54 | test("#role", () => { 55 | let user = User.from(userRow); 56 | expect(user.role).toBe("user"); 57 | }); 58 | 59 | test("#isRoot", () => { 60 | let user = User.from(userRow); 61 | expect(user.isRoot).toBe(false); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /app/helpers/session.ts: -------------------------------------------------------------------------------- 1 | import { Cookies } from "app:helpers/cookies"; 2 | import { SessionsRepository } from "app:repositories.server/sessions"; 3 | import { waitUntil } from "edgekitjs"; 4 | import { redirect } from "react-router"; 5 | 6 | export async function querySession(request: Request) { 7 | let repo = new SessionsRepository(); 8 | 9 | let sessionId = await Cookies.session.parse(request.headers.get("cookie")); 10 | if (!sessionId) return null; 11 | 12 | let [session] = await repo.findById(sessionId); 13 | if (!session) return null; 14 | 15 | if (session.hasExpired) { 16 | let headers = await deleteSession(request); 17 | headers.append( 18 | "Set-Cookie", 19 | await Cookies.expiredSession.serialize(session.userId), 20 | ); 21 | throw redirect("/login", { headers }); 22 | } 23 | 24 | waitUntil(repo.recordActivity(session.id)); 25 | return session; 26 | } 27 | 28 | export async function getSession(request: Request, returnTo = "/register") { 29 | let session = await querySession(request); 30 | if (!session) throw redirect(returnTo); 31 | return session; 32 | } 33 | 34 | export async function createSession( 35 | input: SessionsRepository.CreateInput, 36 | responseHeaders = new Headers(), 37 | ) { 38 | let session = await new SessionsRepository().create(input); 39 | responseHeaders.append( 40 | "Set-Cookie", 41 | await Cookies.session.serialize(session.id), 42 | ); 43 | return responseHeaders; 44 | } 45 | 46 | export async function deleteSession( 47 | request: Request, 48 | responseHeaders = new Headers(), 49 | ) { 50 | let id = await Cookies.session.parse(request.headers.get("cookie")); 51 | await new SessionsRepository().destroy(id); 52 | responseHeaders.append( 53 | "Set-Cookie", 54 | await Cookies.session.serialize(null, { maxAge: 0 }), 55 | ); 56 | responseHeaders.append( 57 | "Set-Cookie", 58 | await Cookies.expiredSession.serialize(null, { maxAge: 0 }), 59 | ); 60 | return responseHeaders; 61 | } 62 | -------------------------------------------------------------------------------- /app/views/admin/purge.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "app:components/button"; 2 | import { Spinner } from "app:components/spinner"; 3 | import { rootOnly } from "app:helpers/auth"; 4 | import { cn } from "app:helpers/cn"; 5 | import { Cookies } from "app:helpers/cookies"; 6 | import { ok } from "app:helpers/response"; 7 | import schema from "db:schema"; 8 | import { orm } from "edgekitjs"; 9 | import { Form, redirect, useNavigation } from "react-router"; 10 | import type { Route } from "./+types/purge"; 11 | 12 | export async function loader({ request }: Route.LoaderArgs) { 13 | await rootOnly(request); 14 | return ok(null); 15 | } 16 | 17 | export async function action({ request }: Route.ActionArgs) { 18 | await rootOnly(request); 19 | 20 | await orm().delete(schema.users).execute(); 21 | await orm().delete(schema.teams).execute(); 22 | 23 | let headers = new Headers(); 24 | 25 | headers.set( 26 | "Set-Cookie", 27 | await Cookies.session.serialize(null, { expires: new Date(0) }), 28 | ); 29 | 30 | throw redirect("/", { headers }); 31 | } 32 | 33 | export default function Component() { 34 | let navigation = useNavigation(); 35 | let isPending = navigation.state !== "idle"; 36 | 37 | return ( 38 |
39 |
43 |

Purge Database

44 | 45 |

Are you sure you want to delete all the data from the database?

46 | 47 | 55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /docs/database.md: -------------------------------------------------------------------------------- 1 | # Database 2 | 3 | We use Cloudflare D1, locally this uses SQLite persisted to a file inside `.wrangler/state/v3/d1`. 4 | 5 | ## Migrations 6 | 7 | To run any pending migration against your local database you have to use `bun run db:migrate:locale ` with your database name. 8 | 9 | To generate any new migration file you need to do changes to the `db/schema.ts` file, then run the same command as above and this will generate the migration file in the `db/migrations` folder, and run them. 10 | 11 | As a general recommendation, since the migrations will run before the app is deployed, there will be a moment where your old app will live with the new schema, so you should always make your changes backwards compatible. 12 | 13 | If you need to rename or remove a column or table, first deploy the app change that stop using that column or table, and then deploy the change that adds the migration that removes the column or table. 14 | 15 | This way you can avoid any downtime or data loss caused by users potentially using a version of the application that is not compatible with the new schema. 16 | 17 | ## Seed data 18 | 19 | To define the seed data we use the file `db/seed.sql`, we use plain SQL as this is simpler, faster and more reliable than using an ORM. 20 | 21 | You can run this seed against your local DB using `bun run db:seed `, this will run the seed against the database with the name you provide. 22 | 23 | The default seed data comes with a user with the email `test@edgefirst.dev` and password `password`, with role `root` and being the owner of its own team. 24 | 25 | Feel free to change the email and password hash to your own values in the `db/seed.sql` file. 26 | 27 | If you already ran them and want to reset the seed data you have to drop the database. 28 | 29 | ## Drop database 30 | 31 | You can drop the local database using `bun run db:drop`, this will delete any local state associated to Cloudflare, which includes the local D1, KV, R2 and cache. 32 | -------------------------------------------------------------------------------- /app/clients/gravatar.ts: -------------------------------------------------------------------------------- 1 | import { GravatarProfile } from "app:entities/gravatar-profile"; 2 | import { APIClient } from "@edgefirst-dev/api-client"; 3 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 4 | import type { Email } from "edgekitjs"; 5 | import { env } from "edgekitjs"; 6 | 7 | export class Gravatar extends APIClient { 8 | constructor() { 9 | super(new URL("https://api.gravatar.com")); 10 | } 11 | 12 | /** 13 | * Fetches the Gravatar Profile API for a given email address and returns a 14 | * Gravatar profile object with the data returned by the API. 15 | * @param email The email address to fetch the profile for. 16 | * @throws {Gravatar.NotFoundError} If the profile is not found. 17 | * @throws {Gravatar.RateLimitError} If the rate limit is exceeded. 18 | * @throws {Gravatar.ServerError} If the server returns an error. 19 | * @returns A Gravatar profile object. 20 | */ 21 | async profile(email: Email) { 22 | let response = await this.get(`/v3/profiles/${email.hash}`); 23 | 24 | if (response.status === 404) throw new Gravatar.NotFoundError(email); 25 | if (!response.ok) throw new Gravatar.ServerError(); 26 | 27 | return new GravatarProfile(new ObjectParser(await response.json())); 28 | } 29 | 30 | protected override async before(request: Request) { 31 | let token = env().fetch("GRAVATAR_API_TOKEN"); 32 | request.headers.set("Authorization", `Bearer ${token}`); 33 | return request; 34 | } 35 | 36 | protected override async after(_: Request, response: Response) { 37 | if (response.status === 429) throw new Gravatar.RateLimitError(); 38 | return response; 39 | } 40 | } 41 | 42 | export namespace Gravatar { 43 | export class NotFoundError extends Error { 44 | override name = "GravatarNotFoundError"; 45 | 46 | constructor(email: Email) { 47 | super(`Gravatar profile not found for ${email.toString()}`); 48 | } 49 | } 50 | 51 | export class RateLimitError extends Error { 52 | override name = "GravatarRateLimitError"; 53 | override message = "Rate limit exceeded"; 54 | } 55 | 56 | export class ServerError extends Error { 57 | override name = "GravatarServerError"; 58 | override message = "Server error"; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/repositories.server/auth.ts: -------------------------------------------------------------------------------- 1 | import { Membership } from "app:entities/membership"; 2 | import { Team } from "app:entities/team"; 3 | import { User } from "app:entities/user"; 4 | import schema from "db:schema"; 5 | import { createId } from "@paralleldrive/cuid2"; 6 | import { type Email, type Password, orm } from "edgekitjs"; 7 | 8 | export class AuthRepository { 9 | async register(input: AuthRepository.RegisterInput) { 10 | let db = orm(); 11 | 12 | let userId = createId(); 13 | let teamId = createId(); 14 | 15 | let passwordHash = await input.password.hash(); 16 | 17 | let [users, , teams, memberships] = await db.batch([ 18 | db 19 | .insert(schema.users) 20 | .values({ 21 | id: userId, 22 | email: input.email.toString(), 23 | displayName: input.displayName, 24 | }) 25 | .returning(), 26 | db.insert(schema.credentials).values({ userId, passwordHash }), 27 | db 28 | .insert(schema.teams) 29 | .values({ id: teamId, name: "Personal Team" }) 30 | .returning(), 31 | db 32 | .insert(schema.memberships) 33 | .values({ 34 | userId, 35 | teamId, 36 | role: "owner", // A user is the owner of their personal team 37 | acceptedAt: new Date(), // Automatically accept the membership 38 | }) 39 | .returning(), 40 | ]); 41 | 42 | let [user] = User.fromMany(users); 43 | let [team] = Team.fromMany(teams); 44 | let [membership] = Membership.fromMany(memberships); 45 | 46 | if (!user || !team || !membership) { 47 | throw new Error("Failed to register the user"); 48 | } 49 | 50 | return { user, team, membership }; 51 | } 52 | } 53 | 54 | export namespace AuthRepository { 55 | /** 56 | * Input data for the `register` method. 57 | * Contains the email and password required for user registration. 58 | */ 59 | export interface RegisterInput { 60 | /** The users's display name. */ 61 | readonly displayName: string | null; 62 | /** The user's email address. */ 63 | readonly email: Email; 64 | /** The user's password. */ 65 | readonly password: Password; 66 | } 67 | 68 | export interface RegisterOutput { 69 | readonly user: User; 70 | readonly team: Team; 71 | readonly membership: Membership; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /scripts/setup/gh/repository.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "@edgefirst-dev/data"; 2 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type { Octokit } from "@octokit/core"; 4 | import consola from "consola"; 5 | 6 | export class Repository extends Data { 7 | get owner() { 8 | return this.parser.string("owner.login"); 9 | } 10 | 11 | get name() { 12 | return this.parser.string("name"); 13 | } 14 | 15 | get fullName() { 16 | return this.parser.string("full_name"); 17 | } 18 | 19 | get url() { 20 | return new URL(this.parser.string("html_url")); 21 | } 22 | 23 | static async create( 24 | gh: Octokit, 25 | owner: string, 26 | repo: string, 27 | isOrg: boolean, 28 | ) { 29 | consola.info(`Creating repository ${owner}/${repo}`); 30 | 31 | if (isOrg) { 32 | consola.debug("Creating repository for organization"); 33 | let result = await gh.request("POST /orgs/{org}/repos", { 34 | name: repo, 35 | org: owner, 36 | }); 37 | consola.success(`Created repository ${owner}/${repo}`); 38 | return new Repository(new ObjectParser(result.data)); 39 | } 40 | 41 | consola.debug("Creating repository for user"); 42 | let result = await gh.request("POST /user/repos", { 43 | name: repo, 44 | }); 45 | 46 | consola.success(`Created repository ${owner}/${repo}`); 47 | 48 | return new Repository(new ObjectParser(result.data)); 49 | } 50 | 51 | static async find(gh: Octokit, owner: string, repo: string) { 52 | consola.info(`Looking up for repository ${owner}/${repo}...`); 53 | 54 | try { 55 | let { data } = await gh.request("GET /repos/{owner}/{repo}", { 56 | owner, 57 | repo, 58 | }); 59 | 60 | return new Repository(new ObjectParser(data)); 61 | } catch { 62 | consola.info(`No repository found with the name ${owner}/${repo}.`); 63 | return null; 64 | } 65 | } 66 | 67 | static async upsert( 68 | gh: Octokit, 69 | owner: string, 70 | repo: string, 71 | isOrg: boolean, 72 | ) { 73 | let repository = await Repository.find(gh, owner, repo); 74 | 75 | if (repository) { 76 | consola.success(`Using found repository ${owner}/${repo}.`); 77 | return repository; 78 | } 79 | 80 | return Repository.create(gh, owner, repo, isOrg); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /scripts/setup/cf/d1-database.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "@edgefirst-dev/data"; 2 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type { Cloudflare } from "cloudflare"; 4 | import consola from "consola"; 5 | import type { Account } from "./account"; 6 | 7 | export class Database extends Data { 8 | get id() { 9 | return this.parser.string("uuid"); 10 | } 11 | 12 | get name() { 13 | return this.parser.string("name"); 14 | } 15 | 16 | static async create(cf: Cloudflare, account: Account, name: string) { 17 | consola.info(`Creating D1 database ${name}.`); 18 | 19 | let result = await cf.d1.database.create({ 20 | account_id: account.id, 21 | name, 22 | }); 23 | let db = new Database(new ObjectParser(result)); 24 | 25 | consola.success(`Created D1 database ${db.name}.`); 26 | 27 | return db; 28 | } 29 | 30 | static async find(cf: Cloudflare, account: Account, name: string) { 31 | consola.info(`Looking up for D1 database named ${name}...`); 32 | 33 | let { result } = await cf.d1.database.list({ 34 | account_id: account.id, 35 | name: name, 36 | }); 37 | 38 | if (result.length === 0) { 39 | consola.info(`No D1 database found named ${name}.`); 40 | return null; 41 | } 42 | 43 | if (result.length > 1) { 44 | throw new Error(`Found more than one database named ${name}.`); 45 | } 46 | 47 | return new Database(new ObjectParser(result[0])); 48 | } 49 | 50 | static async upsert(cf: Cloudflare, account: Account, projectName: string) { 51 | let name = Database.getName(projectName); 52 | 53 | let db = await Database.find(cf, account, name); 54 | if (db) { 55 | consola.success(`Using found D1 database ${name}.`); 56 | return db; 57 | } 58 | 59 | return await Database.create(cf, account, name); 60 | } 61 | 62 | static async delete(cf: Cloudflare, account: Account, projectName: string) { 63 | let name = Database.getName(projectName); 64 | 65 | consola.info(`Deleting D1 database ${name} if exists.`); 66 | 67 | let db = await Database.find(cf, account, name); 68 | if (!db) return null; 69 | 70 | await cf.d1.database.delete(db.id, { account_id: account.id }); 71 | 72 | consola.success(`Deleted D1 database ${name}.`); 73 | } 74 | 75 | private static getName(projectName: string) { 76 | return `${projectName}-db`; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /app/repositories.server/sessions.ts: -------------------------------------------------------------------------------- 1 | import { Session } from "app:entities/session"; 2 | import type { User } from "app:entities/user"; 3 | import schema from "db:schema"; 4 | import { desc, eq, gte } from "drizzle-orm"; 5 | import { type IPAddress, type UserAgent, orm } from "edgekitjs"; 6 | 7 | export class SessionsRepository { 8 | async findById(id: Session["id"]) { 9 | return Session.fromMany( 10 | await orm() 11 | .select() 12 | .from(schema.sessions) 13 | .where(eq(schema.sessions.id, id)) 14 | .limit(1) 15 | .execute(), 16 | ); 17 | } 18 | 19 | async findByUser(user: User) { 20 | return Session.fromMany( 21 | await orm() 22 | .select() 23 | .from(schema.sessions) 24 | .where(eq(schema.sessions.userId, user.id)) 25 | .orderBy(desc(schema.sessions.lastActivityAt)) 26 | .execute(), 27 | ); 28 | } 29 | 30 | async create({ user, ip, ua, payload }: SessionsRepository.CreateInput) { 31 | let [session] = await orm() 32 | .insert(schema.sessions) 33 | .values({ 34 | userId: user.id, 35 | ipAddress: ip?.toString(), 36 | userAgent: ua?.toString(), 37 | payload, 38 | lastActivityAt: new Date(), 39 | expiresAt: this.getDateInFuture(30), 40 | }) 41 | .returning(); 42 | 43 | if (session) return Session.from(session); 44 | throw new Error("Failed to create session"); 45 | } 46 | 47 | async destroy(id: Session["id"]) { 48 | await orm() 49 | .delete(schema.sessions) 50 | .where(eq(schema.sessions.id, id)) 51 | .execute(); 52 | } 53 | 54 | async recordActivity(id: Session["id"]) { 55 | await orm() 56 | .update(schema.sessions) 57 | .set({ 58 | lastActivityAt: new Date(), 59 | expiresAt: this.getDateInFuture(30), 60 | }) 61 | .where(eq(schema.sessions.id, id)) 62 | .execute(); 63 | } 64 | 65 | async findExpired() { 66 | return Session.fromMany( 67 | await orm() 68 | .select() 69 | .from(schema.sessions) 70 | .where(gte(schema.sessions.expiresAt, new Date())) 71 | .execute(), 72 | ); 73 | } 74 | 75 | private getDateInFuture(days: number) { 76 | return new Date(Date.now() + 1000 * 60 * 60 * 24 * days); 77 | } 78 | } 79 | 80 | export namespace SessionsRepository { 81 | export interface CreateInput { 82 | user: User; 83 | ip?: IPAddress | null; 84 | ua?: UserAgent | null; 85 | payload: Record; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /scripts/setup/cf/queue.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "@edgefirst-dev/data"; 2 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type { Cloudflare } from "cloudflare"; 4 | import consola from "consola"; 5 | import type { Account } from "./account"; 6 | 7 | export class Queue extends Data { 8 | get id() { 9 | return this.parser.string("queue_id"); 10 | } 11 | 12 | get name() { 13 | return this.parser.string("queue_name"); 14 | } 15 | 16 | static async create(cf: Cloudflare, account: Account, name: string) { 17 | consola.info(`Creating queue ${name}.`); 18 | 19 | let result = await cf.queues.create({ 20 | account_id: account.id, 21 | queue_name: name, 22 | }); 23 | 24 | let queue = new Queue(new ObjectParser(result)); 25 | 26 | consola.success(`Created queue ${queue.name}.`); 27 | 28 | return queue; 29 | } 30 | 31 | static async find(cf: Cloudflare, account: Account, name: string) { 32 | consola.info(`Looking up for queue named ${name}...`); 33 | 34 | let { result } = await cf.queues.list({ account_id: account.id }); 35 | 36 | let queues = result 37 | .map((r) => new Queue(new ObjectParser(r))) 38 | .filter((queue) => queue.name === name); 39 | 40 | if (queues.length === 0) { 41 | consola.info(`No queue found named ${name}.`); 42 | return null; 43 | } 44 | 45 | if (queues.length > 1) { 46 | throw new Error(`Found more than one queue named ${name}.`); 47 | } 48 | 49 | return queues.at(0) ?? null; 50 | } 51 | 52 | static async upsert(cf: Cloudflare, account: Account, projectName: string) { 53 | let name = Queue.getName(projectName); 54 | 55 | let r2 = await Queue.find(cf, account, name); 56 | 57 | if (r2) { 58 | consola.success(`Using found queue ${name}.`); 59 | return r2; 60 | } 61 | 62 | return await Queue.create(cf, account, name); 63 | } 64 | 65 | static async delete(cf: Cloudflare, account: Account, projectName: string) { 66 | let name = Queue.getName(projectName); 67 | 68 | consola.info(`Deleting queue ${name} if exists.`); 69 | 70 | let queue = await Queue.find(cf, account, name); 71 | 72 | if (!queue) return null; 73 | 74 | await cf.queues.delete(queue.id, { account_id: account.id }); 75 | 76 | consola.success(`Deleted queue ${name}.`); 77 | } 78 | 79 | private static getName(projectName: string) { 80 | return `${projectName}-queue`; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/clients/gravatar.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; 2 | import { GravatarProfile } from "app:entities/gravatar-profile"; 3 | import { gravatar } from "app:mocks/server"; 4 | import { Email } from "edgekitjs"; 5 | import { setupServer } from "msw/native"; 6 | import { Gravatar } from "./gravatar"; 7 | 8 | mock.module("edgekitjs", () => { 9 | return { 10 | orm: mock(), 11 | env() { 12 | return { 13 | fetch(key: string) { 14 | return key; 15 | }, 16 | }; 17 | }, 18 | }; 19 | }); 20 | 21 | describe(Gravatar.name, () => { 22 | let email = Email.from("john.doe@company.com"); 23 | let server = setupServer(); 24 | 25 | beforeAll(() => server.listen()); 26 | afterAll(() => server.close()); 27 | 28 | test("#constructor()", () => { 29 | const client = new Gravatar(); 30 | expect(client).toBeInstanceOf(Gravatar); 31 | }); 32 | 33 | test("#profile()", async () => { 34 | let client = new Gravatar(); 35 | 36 | server.resetHandlers(gravatar.success); 37 | 38 | expect(client.profile(email)).resolves.toBeInstanceOf(GravatarProfile); 39 | }); 40 | 41 | test("#profile() with error", async () => { 42 | let client = new Gravatar(); 43 | 44 | server.resetHandlers(gravatar.notFoundError); 45 | 46 | expect(client.profile(email)).rejects.toThrowError(Gravatar.NotFoundError); 47 | }); 48 | 49 | test("#profile() with rate limit error", async () => { 50 | let client = new Gravatar(); 51 | 52 | server.resetHandlers(gravatar.rateLimitError); 53 | 54 | expect(client.profile(email)).rejects.toThrowError(Gravatar.RateLimitError); 55 | }); 56 | 57 | test("#profile() with server error", async () => { 58 | let client = new Gravatar(); 59 | 60 | server.resetHandlers(gravatar.serverError); 61 | 62 | expect(client.profile(email)).rejects.toThrowError(Gravatar.ServerError); 63 | }); 64 | 65 | test("#displayName", async () => { 66 | let client = new Gravatar(); 67 | 68 | server.resetHandlers(gravatar.success); 69 | 70 | let profile = await client.profile(email); 71 | 72 | expect(profile.displayName).toBe("Sergio Xalambrí"); 73 | }); 74 | }); 75 | 76 | const mockResponse = { 77 | hash: "14330318de450e39207d3063ca9dc23698bba910562fdb497d50cc52e1bae0ea", 78 | display_name: "Sergio Xalambrí", 79 | location: "Perú", 80 | job_title: "Web Developer", 81 | company: "Daffy.org", 82 | pronouns: "He/Him", 83 | }; 84 | -------------------------------------------------------------------------------- /app/entry.worker.ts: -------------------------------------------------------------------------------- 1 | import schema from "db:schema"; 2 | 3 | import { EmailAccountRecoveryCodeJob } from "app:jobs/email-account-recovery-code.js"; 4 | import { SyncUserWithGravatarJob } from "app:jobs/sync-user-with-gravatar.js"; 5 | import { CleanupSessionsTask } from "app:tasks/cleanup-sessions.js"; 6 | import type { Request, Response } from "@cloudflare/workers-types"; 7 | import { IPAddress, UserAgent } from "edgekitjs"; 8 | import { bootstrap } from "edgekitjs/worker"; 9 | import { createRequestHandler } from "react-router"; 10 | 11 | // @ts-expect-error - no types 12 | import * as build from "virtual:react-router/server-build"; 13 | 14 | const handler = createRequestHandler(build); 15 | 16 | export interface Env { 17 | // 👇 Env variables 18 | GRAVATAR_API_TOKEN: string; 19 | SESSION_SECRET: string; 20 | APP_ENV: "development" | "production"; 21 | 22 | GH_CLIENT_ID: string; 23 | GH_CLIENT_SECRET: string; 24 | GH_REDIRECT_PATHNAME: string; 25 | } 26 | 27 | export default bootstrap({ 28 | orm: { schema }, 29 | 30 | rateLimit: { limit: 1000, period: 60 }, 31 | 32 | jobs() { 33 | return [new SyncUserWithGravatarJob(), new EmailAccountRecoveryCodeJob()]; 34 | }, 35 | 36 | tasks() { 37 | return [new CleanupSessionsTask().hourly()]; 38 | }, 39 | 40 | async onRequest(request) { 41 | let context = await getLoadContext(request); 42 | // @ts-expect-error The RR handler expects a Request with a different type 43 | return (await handler(request, context)) as Response; 44 | }, 45 | }); 46 | 47 | async function getLoadContext(request: Request) { 48 | let ua = UserAgent.fromRequest(request); 49 | let ip = IPAddress.fromRequest(request); 50 | return { ua, ip }; 51 | } 52 | 53 | declare module "edgekitjs" { 54 | export interface Environment extends Cloudflare.Env {} 55 | type Schema = typeof schema; 56 | export interface DatabaseSchema extends Schema {} 57 | } 58 | 59 | declare module "react-router" { 60 | export interface AppLoadContext 61 | extends Awaited> {} 62 | } 63 | 64 | declare module "cloudflare:workers" { 65 | export namespace Cloudflare { 66 | export interface Env { 67 | // 👇 Env variables 68 | GRAVATAR_API_TOKEN: string; 69 | SESSION_SECRET: string; 70 | APP_ENV: "development" | "production"; 71 | 72 | GH_CLIENT_ID: string; 73 | GH_CLIENT_SECRET: string; 74 | GH_REDIRECT_PATHNAME: string; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Edge-first Starter Kit 2 | 3 | A full-stack starter kit for Edge-first applications built with React on top of Cloudflare Developer Platform. 4 | 5 | > [!IMPORTANT] 6 | > This is still in development, the authentication code included works but is not ready or complete, however app works just fine ignoring that. 7 | 8 | ## Features 9 | 10 | - [x] Deploy to **[Cloudflare Workers](https://workers.cloudflare.com/)** 11 | - [x] Test and manage packages with **[Bun](https://bun.sh/docs/cli/test)** 12 | - [x] Styles with **[Tailwind](https://tailwindcss.com/)** 13 | - [x] Code Quality (lint and format) checker with **[Biome](https://biomejs.dev)** 14 | - [x] CI with **[GH Actions](https://github.com/features/actions)** 15 | - [x] Router with **[React Router](https://reactrouter.com/dev)** 16 | - [x] Database with **[Cloudflare D1](https://developers.cloudflare.com/d1/)** 17 | - [x] Query builder and DB migrations with **[Drizzle](https://orm.drizzle.team)** 18 | - [x] Queues with **[Cloudflare Queues](https://developers.cloudflare.com/queues/)** 19 | - [x] Scheduled tasks with **[Cloudflare Workers Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/)** 20 | - [x] KV storage and caching with **[Cloudflare Workers KV](https://developers.cloudflare.com/kv/)** 21 | - [x] File storage with **[Cloudflare R2](https://developers.cloudflare.com/r2/)** 22 | - [x] Run AI models with **[Cloudflare Workers AI](https://developers.cloudflare.com/workers-ai/)** 23 | - [x] Use Puppeteer with **[Cloudflare Browser Rendering](https://developers.cloudflare.com/browser-rendering/)** 24 | - [x] User profile backfilling with **[Gravatar API](https://docs.gravatar.com/)** 25 | - [x] Prevent email spam with **[Verifier](https://verifier.meetchopra.com)** 26 | - [x] Secure your users with **[HaveIBeenPwned API](https://haveibeenpwned.com/API/v3)** 27 | - [ ] Authentication and authorization WIP 28 | - More to come... 29 | 30 | ## Getting Started 31 | 32 | Create a new React application using the Edge-first Starter Kit: 33 | 34 | ```sh 35 | bun create edgefirst-dev/starter 36 | cd my-app 37 | bun run setup 38 | ``` 39 | 40 | The `setup` script will ask for your project name, and other information to configure the project locally and on Cloudflare. 41 | 42 | Check the [setup docs](./docs/setup.md) for more information. 43 | 44 | ## Author 45 | 46 | - [Sergio Xalambrí](https://sergiodxa.com) 47 | -------------------------------------------------------------------------------- /scripts/setup/cf/r2-bucket.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "@edgefirst-dev/data"; 2 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type { Cloudflare } from "cloudflare"; 4 | import consola from "consola"; 5 | import type { Account } from "./account"; 6 | 7 | export class R2Bucket extends Data { 8 | get name() { 9 | return this.parser.string("name"); 10 | } 11 | 12 | static async create(cf: Cloudflare, account: Account, name: string) { 13 | consola.info(`Creating R2 bucket ${name}.`); 14 | 15 | let result = await cf.r2.buckets.create({ account_id: account.id, name }); 16 | 17 | let r2 = new R2Bucket(new ObjectParser(result)); 18 | 19 | consola.success(`Created R2 bucket ${r2.name}.`); 20 | 21 | return r2; 22 | } 23 | 24 | static async find(cf: Cloudflare, account: Account, name: string) { 25 | consola.info(`Looking up for R2 bucket named ${name}...`); 26 | 27 | let { buckets } = await cf.r2.buckets.list({ account_id: account.id }); 28 | 29 | if (!buckets) { 30 | consola.info(`No R2 bucket found named ${name}.`); 31 | return null; 32 | } 33 | 34 | let r2Buckets = buckets 35 | .map((r: object) => new R2Bucket(new ObjectParser(r))) 36 | .filter((r2: R2Bucket) => r2.name === name); 37 | 38 | if (r2Buckets.length === 0) { 39 | consola.info(`No R2 bucket found named ${name}.`); 40 | return null; 41 | } 42 | 43 | if (r2Buckets.length > 1) { 44 | throw new Error(`Found more than one R2 bucket named ${name}.`); 45 | } 46 | 47 | return r2Buckets.at(0) ?? null; 48 | } 49 | 50 | static async upsert(cf: Cloudflare, account: Account, projectName: string) { 51 | let name = R2Bucket.getName(projectName); 52 | 53 | let r2 = await R2Bucket.find(cf, account, name); 54 | 55 | if (r2) { 56 | consola.success(`Using found R2 bucket ${name}.`); 57 | return r2; 58 | } 59 | 60 | return await R2Bucket.create(cf, account, name); 61 | } 62 | 63 | static async delete(cf: Cloudflare, account: Account, projectName: string) { 64 | let name = R2Bucket.getName(projectName); 65 | 66 | consola.info(`Deleting R2 bucket ${name} if exists.`); 67 | 68 | let r2 = await R2Bucket.find(cf, account, name); 69 | 70 | if (!r2) return null; 71 | 72 | await cf.r2.buckets.delete(r2.name, { account_id: account.id }); 73 | 74 | consola.success(`Deleted R2 bucket ${name}.`); 75 | } 76 | 77 | private static getName(projectName: string) { 78 | return `${projectName}-bucket`; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /scripts/setup/cf/kv-namespace.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "@edgefirst-dev/data"; 2 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type { Cloudflare } from "cloudflare"; 4 | import consola from "consola"; 5 | import type { Account } from "./account"; 6 | 7 | export class KVNamespace extends Data { 8 | get id() { 9 | return this.parser.string("id"); 10 | } 11 | 12 | get name() { 13 | return this.parser.string("title"); 14 | } 15 | 16 | static async create(cf: Cloudflare, account: Account, title: string) { 17 | consola.info(`Creating KV namespace ${title}.`); 18 | 19 | let result = await cf.kv.namespaces.create({ 20 | account_id: account.id, 21 | title, 22 | }); 23 | 24 | let kv = new KVNamespace(new ObjectParser(result)); 25 | 26 | consola.success(`Created KV namespace ${kv.name}.`); 27 | 28 | return kv; 29 | } 30 | 31 | static async find(cf: Cloudflare, account: Account, name: string) { 32 | consola.info(`Looking up for KV namespace named ${name}...`); 33 | 34 | let { result } = await cf.kv.namespaces.list({ account_id: account.id }); 35 | 36 | let kvNamespaces = result 37 | .map((r) => new KVNamespace(new ObjectParser(r))) 38 | .filter((kv) => kv.name === name); 39 | 40 | if (kvNamespaces.length === 0) { 41 | consola.info(`No KV namespace found with the name ${name}.`); 42 | return null; 43 | } 44 | 45 | if (kvNamespaces.length > 1) { 46 | throw new Error(`Found more than one KV namespace named ${name}.`); 47 | } 48 | 49 | return kvNamespaces.at(0) ?? null; 50 | } 51 | 52 | static async upsert(cf: Cloudflare, account: Account, projectName: string) { 53 | let name = KVNamespace.getName(projectName); 54 | 55 | let kv = await KVNamespace.find(cf, account, name); 56 | 57 | if (kv) { 58 | consola.success(`Using found KV namespace ${name}.`); 59 | return kv; 60 | } 61 | 62 | return await KVNamespace.create(cf, account, name); 63 | } 64 | 65 | static async delete(cf: Cloudflare, account: Account, projectName: string) { 66 | let name = KVNamespace.getName(projectName); 67 | 68 | consola.info(`Deleting KV namespace ${name} if exists.`); 69 | 70 | let kv = await KVNamespace.find(cf, account, name); 71 | 72 | if (!kv) return null; 73 | 74 | await cf.kv.namespaces.delete(kv.id, { account_id: account.id }); 75 | 76 | consola.success(`Deleted KV namespace ${name}.`); 77 | } 78 | 79 | private static getName(projectName: string) { 80 | return `${projectName}-kv`; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /scripts/setup/cf/worker.ts: -------------------------------------------------------------------------------- 1 | import { Data } from "@edgefirst-dev/data"; 2 | import { ObjectParser } from "@edgefirst-dev/data/parser"; 3 | import type { Cloudflare } from "cloudflare"; 4 | import consola from "consola"; 5 | import { generatePath } from "react-router"; 6 | import type { Account } from "./account"; 7 | 8 | export class Worker extends Data { 9 | get name() { 10 | return this.parser.string("id"); 11 | } 12 | 13 | static async create(cf: Cloudflare, account: Account, name: string) { 14 | consola.info(`Creating worker ${name}.`); 15 | 16 | let formData = new FormData(); 17 | formData.set("account_id", account.id); 18 | formData.set("metadata", JSON.stringify({ main_module: "worker.ts" })); 19 | formData.set( 20 | "worker.ts", 21 | new File(["export default { fetch() {} }"], "worker.ts", { 22 | type: "application/javascript+module", 23 | }), 24 | ); 25 | 26 | let path = generatePath( 27 | "/client/v4/accounts/:accountId/workers/services/:name/environments/staging", 28 | { accountId: account.id, name }, 29 | ); 30 | 31 | let response = await fetch(new URL(path, "https://api.cloudflare.com"), { 32 | method: "PUT", 33 | body: formData, 34 | headers: { Authorization: `Bearer ${cf.apiToken}` }, 35 | }); 36 | 37 | if (!response.ok) throw new Error("Failed to create worker."); 38 | 39 | let result = await response.json(); 40 | 41 | let parser = new ObjectParser(result); 42 | 43 | let worker = new Worker(parser.object("result")); 44 | 45 | consola.success(`Created worker ${worker.name}.`); 46 | 47 | return worker; 48 | } 49 | 50 | static async find(cf: Cloudflare, account: Account, name: string) { 51 | consola.info(`Looking up for Worker named ${name}...`); 52 | 53 | let { result } = await cf.workers.scripts.list({ 54 | account_id: account.id, 55 | }); 56 | 57 | let workers = result 58 | .map((item) => new Worker(new ObjectParser(item))) 59 | .filter((worker) => worker.name === name); 60 | 61 | if (workers.length === 0) { 62 | consola.info(`No workers found named ${name}.`); 63 | return null; 64 | } 65 | 66 | if (workers.length > 1) { 67 | throw new Error(`Found more than one worker named ${name}.`); 68 | } 69 | 70 | return workers.at(0) ?? null; 71 | } 72 | 73 | static async upsert(cf: Cloudflare, account: Account, projectName: string) { 74 | let worker = await Worker.find(cf, account, projectName); 75 | 76 | if (worker) { 77 | consola.success(`Using found worker ${projectName}.`); 78 | return worker; 79 | } 80 | 81 | return await Worker.create(cf, account, projectName); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { AnchorButton } from "app:components/anchor-button"; 2 | import type { ReactNode } from "react"; 3 | import { 4 | Links, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | data, 10 | useRouteLoaderData, 11 | } from "react-router"; 12 | import "./assets/tailwind.css"; 13 | import { isAuthenticated } from "app:helpers/auth"; 14 | import { ok } from "app:helpers/response"; 15 | import type { Route } from "./+types/root"; 16 | 17 | export async function loader({ request }: Route.LoaderArgs) { 18 | return ok({ isAuthenticated: await isAuthenticated(request) }); 19 | } 20 | 21 | export default function App() { 22 | return ( 23 | <> 24 | Edge-first Starter Kit for React 25 | 26 | 27 | ); 28 | } 29 | 30 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 31 | let title = 32 | error instanceof Error ? error.message : "Oops, something went wrong!"; 33 | 34 | return ( 35 |
36 |
37 |

38 | {title} 39 |

40 | 41 |

42 | We're sorry, but an unexpected error has occurred. Please try again 43 | later or contact support if the issue persists. 44 |

45 | 46 | Go to Homepage 47 | 48 | {error instanceof Error && error.stack ? ( 49 |
50 | 						{JSON.stringify(error.stack, null, "\t")}
51 | 					
52 | ) : null} 53 |
54 |
55 | ); 56 | } 57 | 58 | export function Layout({ children }: { children: ReactNode }) { 59 | return ( 60 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | {children} 72 | 73 | 74 | 75 | 76 | ); 77 | } 78 | 79 | export function useRootLoaderData() { 80 | let rootLoaderData = useRouteLoaderData("root"); 81 | if (!rootLoaderData) throw new Error("Failed to load root loader data"); 82 | return rootLoaderData; 83 | } 84 | 85 | export function useIsAuthenticated() { 86 | return useRootLoaderData().isAuthenticated; 87 | } 88 | -------------------------------------------------------------------------------- /app/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @variant dark (@media (prefers-color-scheme: dark)); 4 | @variant light (@media (prefers-color-scheme: light)); 5 | 6 | @theme { 7 | --color-*: initial; 8 | --color-transparent: transparent; 9 | --color-inherit: inherit; 10 | --color-current: currentColor; 11 | --color-inline: var(--color-inline); 12 | --color-black: #000; 13 | --color-white: #fff; 14 | 15 | --color-accent-50: #f0fdf4; 16 | --color-accent-100: #dcfce7; 17 | --color-accent-200: #bbf7d0; 18 | --color-accent-300: #86efac; 19 | --color-accent-400: #4ade80; 20 | --color-accent-500: #22c55e; 21 | --color-accent-600: #16a34a; 22 | --color-accent-700: #15803d; 23 | --color-accent-800: #166534; 24 | --color-accent-900: #14532d; 25 | --color-accent-950: #052e16; 26 | 27 | --color-neutral-50: #fafafa; 28 | --color-neutral-100: #f5f5f5; 29 | --color-neutral-200: #e5e5e5; 30 | --color-neutral-300: #d4d4d4; 31 | --color-neutral-400: #a3a3a3; 32 | --color-neutral-500: #737373; 33 | --color-neutral-600: #525252; 34 | --color-neutral-700: #404040; 35 | --color-neutral-800: #262626; 36 | --color-neutral-900: #171717; 37 | --color-neutral-950: #0a0a0a; 38 | 39 | --color-success-50: #f0fdf4; 40 | --color-success-100: #dcfce7; 41 | --color-success-200: #bbf7d0; 42 | --color-success-300: #86efac; 43 | --color-success-400: #4ade80; 44 | --color-success-500: #22c55e; 45 | --color-success-600: #16a34a; 46 | --color-success-700: #15803d; 47 | --color-success-800: #166534; 48 | --color-success-900: #14532d; 49 | --color-success-950: #052e16; 50 | 51 | --color-warning-50: #fffbeb; 52 | --color-warning-100: #fef3c7; 53 | --color-warning-200: #fde68a; 54 | --color-warning-300: #fcd34d; 55 | --color-warning-400: #fbbf24; 56 | --color-warning-500: #f59e0b; 57 | --color-warning-600: #d97706; 58 | --color-warning-700: #b45309; 59 | --color-warning-800: #92400e; 60 | --color-warning-900: #78350f; 61 | --color-warning-950: #451a03; 62 | 63 | --color-danger-50: #fef2f2; 64 | --color-danger-100: #fee2e2; 65 | --color-danger-200: #fecaca; 66 | --color-danger-300: #fca5a5; 67 | --color-danger-400: #f87171; 68 | --color-danger-500: #ef4444; 69 | --color-danger-600: #dc2626; 70 | --color-danger-700: #b91c1c; 71 | --color-danger-800: #991b1b; 72 | --color-danger-900: #7f1d1d; 73 | --color-danger-950: #450a0a; 74 | 75 | --color-info-50: #ecfeff; 76 | --color-info-100: #cffafe; 77 | --color-info-200: #a5f3fc; 78 | --color-info-300: #67e8f9; 79 | --color-info-400: #22d3ee; 80 | --color-info-500: #06b6d4; 81 | --color-info-600: #0891b2; 82 | --color-info-700: #0e7490; 83 | --color-info-800: #155e75; 84 | --color-info-900: #164e63; 85 | --color-info-950: #083344; 86 | } 87 | 88 | @layer base { 89 | :root { 90 | color-scheme: light dark; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@edgefirst-dev/starter-worker", 3 | "description": "A starter template for Edge-first applications", 4 | "license": "MIT", 5 | "private": true, 6 | "engines": { 7 | "node": ">=20" 8 | }, 9 | "type": "module", 10 | "scripts": { 11 | "dev": "bun run ./scripts/dev.ts", 12 | "build": "react-router build", 13 | "deploy": "wrangler deploy", 14 | "start": "wrangler dev --test-scheduled", 15 | "typecheck": "tsc", 16 | "rr:typegen": "react-router typegen", 17 | "rr:routes": "react-router routes", 18 | "quality": "biome check .", 19 | "quality:fix": "biome check . --write --unsafe", 20 | "predb:migrate:local": "bun run orm:generate", 21 | "db:drop": "rm -rf ./.wrangler", 22 | "db:seed": "wrangler d1 execute --local --file=./db/seed.sql", 23 | "db:migrate:local": "bun run db:migrate --local", 24 | "postdb:migrate:local": "bun run quality:fix", 25 | "db:migrate": "wrangler d1 migrations apply", 26 | "orm:generate": "drizzle-kit generate --config drizzle.config.ts", 27 | "setup": "bun run ./scripts/setup.ts" 28 | }, 29 | "dependencies": { 30 | "@edgefirst-dev/api-client": "^0.1.0", 31 | "@edgefirst-dev/data": "^0.0.4", 32 | "@oslojs/crypto": "^1.0.1", 33 | "@oslojs/encoding": "^1.1.0", 34 | "@paralleldrive/cuid2": "^2.2.2", 35 | "@react-router/cloudflare": "^7.4.0", 36 | "@react-router/fs-routes": "^7.4.0", 37 | "bcryptjs": "^3.0.2", 38 | "bowser": "^2.11.0", 39 | "clsx": "^2.1.1", 40 | "drizzle-orm": "^0.40.1", 41 | "edgekitjs": "^0.0.49", 42 | "inflected": "^2.1.0", 43 | "isbot": "^5.1.25", 44 | "react": "^19.0.0", 45 | "react-dom": "^19.0.0", 46 | "react-router": "^7.4.0", 47 | "remix-auth": "^4.1.0", 48 | "tailwind-merge": "^3.0.2", 49 | "urlpattern-polyfill": "^10.0.0" 50 | }, 51 | "devDependencies": { 52 | "@biomejs/biome": "^1.9.4", 53 | "@cloudflare/workers-types": "^4.20250319.0", 54 | "@octokit/core": "^6.1.4", 55 | "@react-router/dev": "^7.4.0", 56 | "@tailwindcss/vite": "^4.0.15", 57 | "@total-typescript/ts-reset": "^0.6.1", 58 | "@total-typescript/tsconfig": "^1.0.4", 59 | "@types/bcryptjs": "^3.0.0", 60 | "@types/bun": "^1.2.5", 61 | "@types/inflected": "^2.1.3", 62 | "@types/libsodium-wrappers": "^0.7.14", 63 | "@types/react": "^19.0.12", 64 | "@types/react-dom": "^19.0.4", 65 | "cloudflare": "^4.2.0", 66 | "consola": "^3.4.2", 67 | "dotenv": "^16.4.7", 68 | "drizzle-kit": "^0.30.5", 69 | "libsodium-wrappers": "^0.7.15", 70 | "msw": "^2.7.3", 71 | "postcss": "^8.5.3", 72 | "tailwindcss": "^4.0.15", 73 | "typescript": "^5.8.2", 74 | "vite": "^6.2.2", 75 | "vite-env-only": "^3.0.3", 76 | "vite-plugin-cjs-interop": "^2.2.0", 77 | "vite-tsconfig-paths": "^5.1.4", 78 | "wrangler": "3.114.2" 79 | }, 80 | "trustedDependencies": ["@biomejs/biome"], 81 | "bun-create": { 82 | "postinstall": ["bun run ./scripts/setup.ts"], 83 | "start": "bun run dev" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/views/auth/register.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "app:components/button"; 2 | import { Spinner } from "app:components/spinner"; 3 | import { anonymous, register } from "app:helpers/auth"; 4 | import { cn } from "app:helpers/cn"; 5 | import { rateLimit } from "app:helpers/rate-limit"; 6 | import { badRequest, ok, unprocessableEntity } from "app:helpers/response"; 7 | import { Parser } from "@edgefirst-dev/data/parser"; 8 | import { Form, Link, useNavigation } from "react-router"; 9 | import type { Route } from "./+types/register"; 10 | 11 | export async function loader({ request }: Route.LoaderArgs) { 12 | await anonymous(request, "/profile"); 13 | return ok(null); 14 | } 15 | 16 | export async function action({ request, context }: Route.ActionArgs) { 17 | await rateLimit(request.headers); 18 | 19 | try { 20 | await register(request, context); 21 | } catch (error) { 22 | if (error instanceof Parser.Error) { 23 | return unprocessableEntity({ error: error.message }); 24 | } 25 | 26 | if (error instanceof Error) { 27 | console.error(error); 28 | return badRequest({ error: error.message }); 29 | } 30 | 31 | throw error; 32 | } 33 | } 34 | 35 | export default function Component({ actionData }: Route.ComponentProps) { 36 | let navigation = useNavigation(); 37 | let isPending = navigation.state !== "idle"; 38 | 39 | return ( 40 |
41 |

Create an account

42 |

43 | Enter your email below to create your account 44 |

45 | 46 | {actionData?.ok === false && ( 47 |

{actionData.error}

48 | )} 49 | 50 | 60 | 61 | 70 | 71 |
72 | 73 | Already have an account? Login 74 | 75 | 76 | 84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /app/services.server/auth/login.ts: -------------------------------------------------------------------------------- 1 | import type { Membership } from "app:entities/membership"; 2 | import type { Team } from "app:entities/team"; 3 | import type { User } from "app:entities/user"; 4 | 5 | import type { Credential } from "app:entities/credential"; 6 | import { Email } from "edgekitjs"; 7 | import { type Entity, type Password, waitUntil } from "edgekitjs"; 8 | 9 | /** 10 | * Logs in a user by verifying the provided email and password. 11 | * 12 | * @param input - The login input data containing the email and password. 13 | * @param deps - The dependency injection object containing repositories. 14 | * @returns A promise that resolves to the logged-in user. 15 | * @throws {Error} If the user is not found or the credentials are invalid. 16 | */ 17 | export async function login( 18 | input: login.Input, 19 | deps: login.Dependencies, 20 | ): Promise { 21 | let [user] = await deps.users.findByEmail(input.email); 22 | if (!user) throw new Error("User not found"); 23 | 24 | let [credential] = await deps.credentials.findByUser(user); 25 | if (!credential) throw new Error("User has no associated credentials"); 26 | 27 | // Compare the provided password with the stored password hash 28 | if (await credential.match(input.password)) { 29 | let memberships = await deps.memberships.findByUser(user); 30 | if (memberships.length === 0) throw new Error("User has no memberships"); 31 | // biome-ignore lint/style/noNonNullAssertion: We know there's one element 32 | let [team] = await deps.teams.findByMembership(memberships.at(0)!); 33 | if (!team) throw new Error("User has no team"); 34 | waitUntil(deps.audits.create(user, "user_login")); 35 | return { user, team, memberships }; 36 | } 37 | 38 | waitUntil( 39 | deps.audits.create(user, "invalid_credentials_attempt", credential), 40 | ); 41 | 42 | throw new Error("Invalid credentials"); 43 | } 44 | 45 | export namespace login { 46 | /** 47 | * Input data for the `login` method. 48 | * Contains the email and password required for user login. 49 | */ 50 | export interface Input { 51 | /** The user's email address. */ 52 | readonly email: Email; 53 | /** The user's password. */ 54 | readonly password: Password; 55 | } 56 | 57 | /** 58 | * Output data returned by the `login` method. 59 | * Contains the logged-in user's information. 60 | */ 61 | export interface Output { 62 | /** The user object created during registration. */ 63 | user: User; 64 | /** The team object created during registration. */ 65 | team: Team; 66 | /** The membership object linking the user to the team. */ 67 | memberships: Membership[]; 68 | } 69 | 70 | export interface Dependencies { 71 | audits: { 72 | create(user: User, action: string, entity?: Entity): Promise; 73 | }; 74 | users: { 75 | findByEmail(email: Email): Promise; 76 | }; 77 | teams: { 78 | findByMembership(membership: Membership): Promise; 79 | }; 80 | credentials: { 81 | findByUser(user: User): Promise; 82 | }; 83 | memberships: { 84 | findByUser(user: User): Promise; 85 | }; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /app/services.server/auth/register.ts: -------------------------------------------------------------------------------- 1 | import type { GravatarProfile } from "app:entities/gravatar-profile"; 2 | import { Membership } from "app:entities/membership"; 3 | import { Team } from "app:entities/team"; 4 | import { User } from "app:entities/user"; 5 | import { SyncUserWithGravatarJob } from "app:jobs/sync-user-with-gravatar"; 6 | import type { AuthRepository } from "app:repositories.server/auth"; 7 | import { Email } from "edgekitjs"; 8 | import { Entity, Password, waitUntil } from "edgekitjs"; 9 | 10 | /** 11 | * Registers a new user by creating an account, generating a password hash, 12 | * and setting up a personal team. 13 | * 14 | * @param input - The registration input data containing the email and password. 15 | * @param deps - The dependency injection object containing repositories. 16 | * @returns A promise that resolves to the newly created user and team. 17 | * @throws {Error} If the user already exists or if the registration process fails. 18 | */ 19 | export async function register( 20 | input: register.Input, 21 | deps: register.Dependencies, 22 | ): Promise { 23 | await input.email.verify(); 24 | await input.password.isStrong(); 25 | 26 | await deps.users.findByEmail(input.email).then(([user]) => { 27 | if (user) throw new Error("User already exists"); 28 | }); 29 | 30 | let displayName = input.displayName; 31 | 32 | // If the display name is not provided, try to fetch it from Gravatar 33 | if (!displayName) { 34 | SyncUserWithGravatarJob.enqueue({ email: input.email.toString() }); 35 | } 36 | 37 | let { user, team, membership } = await deps.auth.register({ 38 | email: input.email, 39 | password: input.password, 40 | displayName, 41 | }); 42 | 43 | waitUntil(deps.audits.create(user, "user_register")); 44 | waitUntil(deps.audits.create(user, "accepts_membership", membership)); 45 | 46 | return { user, team, membership }; 47 | } 48 | 49 | export namespace register { 50 | /** 51 | * Input data for the `register` method. 52 | * Contains the email and password required for user registration. 53 | */ 54 | export interface Input { 55 | /** The users's display name. */ 56 | readonly displayName: string | null; 57 | /** The user's email address. */ 58 | readonly email: Email; 59 | /** The user's password. */ 60 | readonly password: Password; 61 | } 62 | 63 | /** 64 | * Output data returned by the `register` method. 65 | * Contains the created user and associated team information. 66 | */ 67 | export interface Output { 68 | /** The user object created during registration. */ 69 | user: User; 70 | /** The team object created during registration. */ 71 | team: Team; 72 | /** The membership object linking the user to the team. */ 73 | membership: Membership; 74 | } 75 | 76 | export interface Dependencies { 77 | auth: { 78 | register( 79 | input: AuthRepository.RegisterInput, 80 | ): Promise; 81 | }; 82 | audits: { 83 | create(user: User, action: string, entity?: Entity): Promise; 84 | }; 85 | users: { 86 | findByEmail(email: Email): Promise; 87 | }; 88 | gravatar: { 89 | profile(email: Email): Promise; 90 | }; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /db/migrations/20241020085523_gray_the_call.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `audit_logs` ( 2 | `id` text(24) NOT NULL, 3 | `created_at` integer NOT NULL, 4 | `action` text, 5 | `auditable` text, 6 | `payload` text DEFAULT '{}', 7 | `user_id` text NOT NULL, 8 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action 9 | ); 10 | --> statement-breakpoint 11 | CREATE UNIQUE INDEX `audit_logs_id_unique` ON `audit_logs` (`id`);--> statement-breakpoint 12 | CREATE INDEX `audit_logs_user_id_idx` ON `audit_logs` (`user_id`);--> statement-breakpoint 13 | CREATE TABLE `users_credentials` ( 14 | `id` text(24) NOT NULL, 15 | `created_at` integer NOT NULL, 16 | `updated_at` integer NOT NULL, 17 | `password_hash` text NOT NULL, 18 | `reset_token` text, 19 | `user_id` text NOT NULL, 20 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action 21 | ); 22 | --> statement-breakpoint 23 | CREATE UNIQUE INDEX `users_credentials_id_unique` ON `users_credentials` (`id`);--> statement-breakpoint 24 | CREATE UNIQUE INDEX `users_credentials_reset_token_unique` ON `users_credentials` (`reset_token`);--> statement-breakpoint 25 | CREATE INDEX `users_credentials_user_id_idx` ON `users_credentials` (`user_id`);--> statement-breakpoint 26 | CREATE INDEX `users_credentials_reset_token_idx` ON `users_credentials` (`reset_token`);--> statement-breakpoint 27 | CREATE TABLE `memberships` ( 28 | `id` text(24) NOT NULL, 29 | `created_at` integer NOT NULL, 30 | `updated_at` integer NOT NULL, 31 | `accepted_at` integer, 32 | `role` text DEFAULT 'member' NOT NULL, 33 | `user_id` text NOT NULL, 34 | `team_id` text NOT NULL, 35 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, 36 | FOREIGN KEY (`team_id`) REFERENCES `teams`(`id`) ON UPDATE no action ON DELETE no action 37 | ); 38 | --> statement-breakpoint 39 | CREATE UNIQUE INDEX `memberships_id_unique` ON `memberships` (`id`);--> statement-breakpoint 40 | CREATE INDEX `memberships_user_id_idx` ON `memberships` (`user_id`);--> statement-breakpoint 41 | CREATE INDEX `memberships_user_team_idx` ON `memberships` (`user_id`,`team_id`);--> statement-breakpoint 42 | CREATE INDEX `memberships_team_id_idx` ON `memberships` (`team_id`);--> statement-breakpoint 43 | CREATE INDEX `memberships_team_user_idx` ON `memberships` (`team_id`,`user_id`);--> statement-breakpoint 44 | CREATE TABLE `teams` ( 45 | `id` text(24) NOT NULL, 46 | `created_at` integer NOT NULL, 47 | `updated_at` integer NOT NULL, 48 | `name` text 49 | ); 50 | --> statement-breakpoint 51 | CREATE UNIQUE INDEX `teams_id_unique` ON `teams` (`id`);--> statement-breakpoint 52 | CREATE TABLE `users` ( 53 | `id` text(24) NOT NULL, 54 | `created_at` integer NOT NULL, 55 | `updated_at` integer NOT NULL, 56 | `email_verified_at` integer, 57 | `display_name` text, 58 | `email` text NOT NULL, 59 | `avatar_key` text(24), 60 | `role` text DEFAULT 'user', 61 | `email_verification_token` text(24) NOT NULL 62 | ); 63 | --> statement-breakpoint 64 | CREATE UNIQUE INDEX `users_id_unique` ON `users` (`id`);--> statement-breakpoint 65 | CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint 66 | CREATE UNIQUE INDEX `users_email_verification_token_unique` ON `users` (`email_verification_token`);--> statement-breakpoint 67 | CREATE INDEX `users_email_idx` ON `users` (`email`); -------------------------------------------------------------------------------- /app/views/auth/login.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "app:components/button"; 2 | import { Spinner } from "app:components/spinner"; 3 | import { anonymous, login } from "app:helpers/auth"; 4 | import { cn } from "app:helpers/cn"; 5 | import { Cookies } from "app:helpers/cookies"; 6 | import { rateLimit } from "app:helpers/rate-limit"; 7 | import { badRequest, ok, unprocessableEntity } from "app:helpers/response"; 8 | import { UsersRepository } from "app:repositories.server/users"; 9 | import { Parser } from "@edgefirst-dev/data/parser"; 10 | import { Form, Link, useNavigation } from "react-router"; 11 | import type { Route } from "./+types/login"; 12 | 13 | export async function loader({ request }: Route.LoaderArgs) { 14 | await anonymous(request, "/profile"); 15 | let userId = await Cookies.expiredSession.parse( 16 | request.headers.get("cookie"), 17 | ); 18 | let users = userId ? await new UsersRepository().findById(userId) : null; 19 | return ok({ defaultEmail: users?.at(0)?.email.toString() ?? null }); 20 | } 21 | 22 | export async function action({ request, context }: Route.ActionArgs) { 23 | await rateLimit(request.headers); 24 | 25 | try { 26 | await login(request, context); 27 | } catch (error) { 28 | if (error instanceof Parser.Error) { 29 | return unprocessableEntity({ error: error.message }); 30 | } 31 | 32 | if (error instanceof Error) { 33 | return badRequest({ error: error.message }); 34 | } 35 | throw error; 36 | } 37 | } 38 | 39 | export default function Component({ 40 | loaderData, 41 | actionData, 42 | }: Route.ComponentProps) { 43 | let navigation = useNavigation(); 44 | let isPending = navigation.state !== "idle"; 45 | 46 | return ( 47 |
48 |

Access

49 | 50 | {actionData?.ok === false && ( 51 |

{actionData.error}

52 | )} 53 | 54 | 65 | 66 | 75 | 76 |
77 | 78 | First day? Register 79 | 80 | 81 | 89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /app/views/profile.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "app:helpers/auth"; 2 | import { ok } from "app:helpers/response"; 3 | import { getSession } from "app:helpers/session"; 4 | import { SessionsRepository } from "app:repositories.server/sessions"; 5 | import { Link } from "react-router"; 6 | import type { Route } from "./+types/profile"; 7 | 8 | export async function loader({ request }: Route.LoaderArgs) { 9 | let user = await currentUser(request); 10 | 11 | let [currentSession, sessions] = await Promise.all([ 12 | getSession(request), 13 | new SessionsRepository().findByUser(user), 14 | ]); 15 | 16 | return ok({ 17 | user: { 18 | avatar: user.avatar, 19 | displayName: user.displayName, 20 | hasEmailVerified: user.hasEmailVerified, 21 | }, 22 | 23 | sessions: sessions 24 | .filter((s) => !s.hasExpired) 25 | .map((session) => { 26 | return { 27 | id: session.id, 28 | ip: session.ip?.valueOf() ?? null, 29 | ua: session.ua 30 | ? { browser: session.ua?.browser.name, os: session.ua?.os.name } 31 | : null, 32 | geo: { city: session.geo?.city, country: session.geo?.country }, 33 | isCurrent: currentSession.id === session.id, 34 | }; 35 | }), 36 | }); 37 | } 38 | 39 | export default function Component({ loaderData }: Route.ComponentProps) { 40 | return ( 41 |
42 | 47 | 48 |
49 |
50 | 57 | 58 |

59 | {loaderData.user.displayName} 60 |

61 |
62 | 63 | {loaderData.user.hasEmailVerified ? null : ( 64 |

65 | Your email address has not been verified. Please check your email 66 | for a verification link. 67 |

68 | )} 69 |
70 | 71 |
72 |
73 |

Session

74 |
75 |

76 | This is a list of devices that have logged into your account. Revoke 77 | any sessions that you do not recognize. 78 |

79 |
80 | 81 |
    82 | {loaderData.sessions.map((session) => { 83 | return ( 84 |
  1. 85 |
    86 |

    87 | {session.geo.city}, {session.geo.country} - {session.ip} -{" "} 88 | {session.ua?.browser}{" "} 89 | {session.isCurrent ? " (current)" : ""} 90 |

    91 |
    92 |
  2. 93 | ); 94 | })} 95 |
96 |
97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /app/services.server/auth/recover.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "app:entities/user"; 2 | import { EmailAccountRecoveryCodeJob } from "app:jobs/email-account-recovery-code"; 3 | import { 4 | type AuditAction, 5 | AuditLogsRepository, 6 | } from "app:repositories.server/audit-logs"; 7 | import { CredentialsRepository } from "app:repositories.server/credentials"; 8 | import { UsersRepository } from "app:repositories.server/users"; 9 | import { encodeBase32 } from "@oslojs/encoding"; 10 | import type { Email, Password } from "edgekitjs"; 11 | import { type Entity, kv, waitUntil } from "edgekitjs"; 12 | 13 | /** 14 | * Initiates a password recovery process by generating a one-time password (OTP). 15 | * 16 | * @param input - The recovery input data containing the email. 17 | * @param deps - The dependency injection object containing repositories. 18 | * @returns A promise that resolves to an OTP. 19 | * @throws {Error} If the user is not found. 20 | */ 21 | export async function recover( 22 | input: recover.Input, 23 | baseURL: URL, 24 | deps: recover.Dependencies = { 25 | audits: new AuditLogsRepository(), 26 | users: new UsersRepository(), 27 | credentials: new CredentialsRepository(), 28 | }, 29 | ) { 30 | let [user] = await deps.users.findByEmail(input.email); 31 | if (!user) throw new Error("User not found"); 32 | 33 | if (input.intent === "start") { 34 | let token = generateRandomOTP(); 35 | 36 | await kv().set(`recoveryCode:${token}`, input.email.toString(), { 37 | ttl: 60 * 15, // 15 minutes 38 | }); 39 | 40 | let url = new URL("/recover", baseURL); 41 | url.searchParams.set("token", token); 42 | 43 | EmailAccountRecoveryCodeJob.enqueue({ 44 | email: input.email.toString(), 45 | url: url.toString(), 46 | }); 47 | 48 | waitUntil(deps.audits.create(user, "generate_account_recovery_code")); 49 | } 50 | 51 | if (input.intent === "finish") { 52 | if (!(await kv().has(`recoveryCode:${input.token}`))) { 53 | throw new Error("Invalid recovery token"); 54 | } 55 | 56 | waitUntil(kv().remove(`recoveryCode:${input.token}`)); 57 | await deps.credentials.upsertByUser(user, input.password); 58 | waitUntil(deps.audits.create(user, "use_account_recovery_code")); 59 | } 60 | } 61 | 62 | /** 63 | * Generates a random one-time password (OTP) using Base32 encoding. 64 | * 65 | * @returns A string representing the OTP. 66 | */ 67 | function generateRandomOTP(): string { 68 | let recoveryCodeBytes = new Uint8Array(10); 69 | crypto.getRandomValues(recoveryCodeBytes); 70 | return encodeBase32(recoveryCodeBytes); 71 | } 72 | 73 | export namespace recover { 74 | export namespace Input { 75 | export interface Start { 76 | readonly intent: "start"; 77 | readonly email: Email; 78 | } 79 | 80 | export interface Finish { 81 | readonly intent: "finish"; 82 | readonly email: Email; 83 | readonly password: Password; 84 | readonly token: string; 85 | } 86 | } 87 | 88 | /** 89 | * Input data for the `recover` method. 90 | * Contains the email required to initiate password recovery. 91 | */ 92 | export type Input = Input.Start | Input.Finish; 93 | 94 | export interface Dependencies { 95 | audits: { 96 | create(user: User, action: AuditAction, entity?: Entity): Promise; 97 | }; 98 | users: { 99 | findByEmail: (email: Email) => Promise; 100 | }; 101 | credentials: { 102 | upsertByUser(user: User, password: Password): Promise; 103 | }; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/services.server/auth/register.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; 2 | 3 | import { emailVerifier, pwnedPasswords } from "app:mocks/server"; 4 | import { Email } from "edgekitjs"; 5 | import { Password } from "edgekitjs"; 6 | import { setupServer } from "msw/native"; 7 | import { register } from "./register"; 8 | 9 | mock.module("edgekitjs", () => { 10 | return { 11 | waitUntil: mock().mockImplementation((promise: Promise) => void 0), 12 | queue: mock().mockImplementation(() => { 13 | return { 14 | enqueue: mock().mockImplementation(() => void 0), 15 | }; 16 | }), 17 | env: mock().mockImplementation(() => { 18 | return { 19 | fetch: mock().mockImplementation((key) => key), 20 | }; 21 | }), 22 | }; 23 | }); 24 | 25 | describe(register.name, () => { 26 | let email = Email.from("john.doe@company.com"); 27 | let password = Password.from("abcDEF123!@#"); 28 | let server = setupServer(); 29 | 30 | beforeAll(() => server.listen()); 31 | afterAll(() => server.close()); 32 | 33 | test("it works", async () => { 34 | server.resetHandlers(pwnedPasswords.strong, emailVerifier.valid); 35 | 36 | let users = { findByEmail: mock() }; 37 | let auth = { register: mock() }; 38 | let audits = { create: mock() }; 39 | let gravatar = { profile: mock() }; 40 | 41 | users.findByEmail.mockResolvedValue([]); 42 | auth.register.mockResolvedValue({ 43 | user: { id: "1" }, 44 | team: { id: "1" }, 45 | membership: { id: "1" }, 46 | }); 47 | audits.create.mockResolvedValue({ id: "1" }); 48 | gravatar.profile.mockResolvedValue(null); 49 | 50 | await register( 51 | { email, password, displayName: null }, 52 | { users, auth, audits, gravatar }, 53 | ); 54 | 55 | expect(users.findByEmail).toHaveBeenCalledWith(email); 56 | expect(auth.register).toHaveBeenCalledWith({ 57 | email, 58 | password, 59 | displayName: null, 60 | }); 61 | expect(audits.create).toHaveBeenCalledWith( 62 | expect.any(Object), 63 | "user_register", 64 | ); 65 | }); 66 | 67 | test("it throws an error if the user already exists", async () => { 68 | server.resetHandlers(pwnedPasswords.strong, emailVerifier.valid); 69 | 70 | let users = { findByEmail: mock() }; 71 | let auth = { register: mock() }; 72 | let audits = { create: mock() }; 73 | let gravatar = { profile: mock() }; 74 | 75 | users.findByEmail.mockResolvedValue([{ id: "1" }]); 76 | 77 | expect( 78 | register( 79 | { email, password, displayName: null }, 80 | { users, auth, audits, gravatar }, 81 | ), 82 | ).rejects.toThrow("User already exists"); 83 | }); 84 | 85 | test("it throws an error if the password is weak", async () => { 86 | server.resetHandlers(pwnedPasswords.weak, emailVerifier.valid); 87 | 88 | let users = { findByEmail: mock(), create: mock() }; 89 | let auth = { register: mock() }; 90 | let audits = { create: mock() }; 91 | let gravatar = { profile: mock() }; 92 | 93 | users.findByEmail.mockResolvedValue([]); 94 | users.create.mockResolvedValue({ id: "1" }); 95 | 96 | expect( 97 | register( 98 | { email, password, displayName: null }, 99 | { users, auth, audits, gravatar }, 100 | ), 101 | ).rejects.toThrow("Password is included in a data breach"); 102 | }); 103 | 104 | test("it throws an error if the email is invalid", async () => { 105 | server.resetHandlers(pwnedPasswords.strong, emailVerifier.invalid); 106 | 107 | let users = { findByEmail: mock(), create: mock() }; 108 | let auth = { register: mock() }; 109 | let audits = { create: mock() }; 110 | let gravatar = { profile: mock() }; 111 | 112 | users.findByEmail.mockResolvedValue([]); 113 | 114 | expect( 115 | register( 116 | { email, password, displayName: null }, 117 | { users, auth, audits, gravatar }, 118 | ), 119 | ).rejects.toThrow("Disposable email address"); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | You can setup the project using the provided `bun run setup` command or manually. 4 | 5 | ## Automated Setup 6 | 7 | To setup the project automatically you can run the following command: 8 | 9 | ```bash 10 | bun run setup 11 | ``` 12 | 13 | This will ask for a project name, and for a Cloudflare API token with the required permissions. 14 | 15 | It will also ask if you have a `GRAVATAR_API_TOKEN` and `VERIFIER_API_KEY` to use in `.dev.vars`. 16 | 17 | You can get a new Verifier API key at [verifier.meetchopra.com](https://verifier.meetchopra.com) completely free. 18 | 19 | You can get a new Gravatar API token at [gravatar.com/developers](https://gravatar.com/developers/applications) completely free. 20 | 21 | > [!TIP] 22 | > If already have `CLOUDFLARE_API_TOKEN`, `GRAVATAR_API_TOKEN` and `VERIFIER_API_KEY` as an environment variable then the setup script won't ask for them and use the already set value. 23 | > The setup script also loads your `.dev.vars` file if it exists as environment variables. 24 | 25 | After that it will create the required bindings (D1, KV, R2 and queue), run the migrations and seed against the local database. 26 | 27 | Finally it will create a `.dev.vars` file and update the `wrangler.toml` file with the correct values from the created variables. 28 | 29 | ## Manual Setup 30 | 31 | ### Environment Variables 32 | 33 | Create a `.dev.vars` file with the following content: 34 | 35 | ```txt 36 | APP_ENV="development" 37 | 38 | CLOUDFLARE_ACCOUNT_ID="" 39 | CLOUDFLARE_DATABASE_ID="" 40 | CLOUDFLARE_API_TOKEN="" 41 | 42 | GRAVATAR_API_TOKEN="" 43 | 44 | VERIFIER_API_KEY="" 45 | ``` 46 | 47 | Replace the empty strings with the values of your Cloudflare account, database and API token, Gravatar API token and Verifier API key. 48 | 49 | You can get a new Verifier API key at [verifier.meetchopra.com](https://verifier.meetchopra.com) completely free. 50 | 51 | You can get a new Gravatar API token at [gravatar.com/developers](https://gravatar.com/developers/applications) completely free. 52 | 53 | > [!TIP] 54 | > On your Cloudflare Workers environment you will only need `GRAVATAR_API_TOKEN` and `VERIFIER_API_KEY` environment variables. 55 | 56 | ### The Cloudflare API token should have the following permissions: 57 | 58 | - Workers AI:Edit 59 | - D1:Edit 60 | - Workers R2 Storage:Edit 61 | - Workers KV Storage:Edit 62 | - Workers Scripts:Edit 63 | 64 | ### Create required bindings 65 | 66 | You will need to create the following bindings: 67 | 68 | ```sh 69 | bunx wrangler d1 create 70 | bunx wrangler kv namespace create 71 | bunx wrangler r2 bucket create 72 | bunx wrangler queues create 73 | ``` 74 | 75 | Replace the values between `<>` with your desired names, then update the IDs in your wrangler.toml file for the D1 and KV, and the name for the D1 and R2 and queue. 76 | 77 | > [!IMPORTANT] 78 | > Don't change the binding names, they must be DB, KV and FS as that's what EdgeKit.js expects. 79 | 80 | ### Run migrations 81 | 82 | You will need to run the database migrations, you can do it with 83 | 84 | ```bash 85 | bun run db:migrate:local db-name 86 | ``` 87 | 88 | Replace `db-name` with your database name configured in `wrangler.toml`. 89 | 90 | ### Setup GitHub Action Secrets 91 | 92 | To deploy using the GitHub Action workflow you will need to setup the following secrets: 93 | 94 | - `CLOUDFLARE_ACCOUNT_ID` 95 | - `CLOUDFLARE_DATABASE_NAME` 96 | - `CLOUDFLARE_API_TOKEN` 97 | 98 | These can be the sames that you used in your `.dev.vars` file. 99 | 100 | ### Create Cloudflare Secrets 101 | 102 | Inside your Cloudflare Workers environment you will need to create the following secrets 103 | 104 | - `APP_ENV` 105 | - `GRAVATAR_API_TOKEN` 106 | - `VERIFIER_API_KEY` 107 | 108 | You can do it manually on the dashboard or using the `wrangler secret` command. 109 | 110 | ```bash 111 | bunx wrangler secret put APP_ENV 112 | bunx wrangler secret put GRAVATAR_API_TOKEN 113 | bunx wrangler secret put VERIFIER_API_KEY 114 | ``` 115 | 116 | Set APP_ENV to `production`, and the other two to the values you used in your `.dev.vars` file. 117 | -------------------------------------------------------------------------------- /db/migrations/20241027053957_huge_echo.sql: -------------------------------------------------------------------------------- 1 | PRAGMA foreign_keys=OFF;--> statement-breakpoint 2 | CREATE TABLE `__new_audit_logs` ( 3 | `id` text(24) NOT NULL, 4 | `created_at` integer NOT NULL, 5 | `action` text, 6 | `auditable` text, 7 | `payload` text DEFAULT '{}', 8 | `user_id` text NOT NULL, 9 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade 10 | ); 11 | --> statement-breakpoint 12 | INSERT INTO `__new_audit_logs`("id", "created_at", "action", "auditable", "payload", "user_id") SELECT "id", "created_at", "action", "auditable", "payload", "user_id" FROM `audit_logs`;--> statement-breakpoint 13 | DROP TABLE `audit_logs`;--> statement-breakpoint 14 | ALTER TABLE `__new_audit_logs` RENAME TO `audit_logs`;--> statement-breakpoint 15 | PRAGMA foreign_keys=ON;--> statement-breakpoint 16 | CREATE UNIQUE INDEX `audit_logs_id_unique` ON `audit_logs` (`id`);--> statement-breakpoint 17 | CREATE INDEX `audit_logs_user_id_idx` ON `audit_logs` (`user_id`);--> statement-breakpoint 18 | CREATE TABLE `__new_users_credentials` ( 19 | `id` text(24) NOT NULL, 20 | `created_at` integer NOT NULL, 21 | `updated_at` integer NOT NULL, 22 | `password_hash` text NOT NULL, 23 | `reset_token` text, 24 | `user_id` text NOT NULL, 25 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade 26 | ); 27 | --> statement-breakpoint 28 | INSERT INTO `__new_users_credentials`("id", "created_at", "updated_at", "password_hash", "reset_token", "user_id") SELECT "id", "created_at", "updated_at", "password_hash", "reset_token", "user_id" FROM `users_credentials`;--> statement-breakpoint 29 | DROP TABLE `users_credentials`;--> statement-breakpoint 30 | ALTER TABLE `__new_users_credentials` RENAME TO `users_credentials`;--> statement-breakpoint 31 | CREATE UNIQUE INDEX `users_credentials_id_unique` ON `users_credentials` (`id`);--> statement-breakpoint 32 | CREATE UNIQUE INDEX `users_credentials_reset_token_unique` ON `users_credentials` (`reset_token`);--> statement-breakpoint 33 | CREATE INDEX `users_credentials_user_id_idx` ON `users_credentials` (`user_id`);--> statement-breakpoint 34 | CREATE INDEX `users_credentials_reset_token_idx` ON `users_credentials` (`reset_token`);--> statement-breakpoint 35 | CREATE TABLE `__new_memberships` ( 36 | `id` text(24) NOT NULL, 37 | `created_at` integer NOT NULL, 38 | `updated_at` integer NOT NULL, 39 | `accepted_at` integer, 40 | `role` text DEFAULT 'member' NOT NULL, 41 | `user_id` text NOT NULL, 42 | `team_id` text NOT NULL, 43 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, 44 | FOREIGN KEY (`team_id`) REFERENCES `teams`(`id`) ON UPDATE no action ON DELETE cascade 45 | ); 46 | --> statement-breakpoint 47 | INSERT INTO `__new_memberships`("id", "created_at", "updated_at", "accepted_at", "role", "user_id", "team_id") SELECT "id", "created_at", "updated_at", "accepted_at", "role", "user_id", "team_id" FROM `memberships`;--> statement-breakpoint 48 | DROP TABLE `memberships`;--> statement-breakpoint 49 | ALTER TABLE `__new_memberships` RENAME TO `memberships`;--> statement-breakpoint 50 | CREATE UNIQUE INDEX `memberships_id_unique` ON `memberships` (`id`);--> statement-breakpoint 51 | CREATE INDEX `memberships_user_id_idx` ON `memberships` (`user_id`);--> statement-breakpoint 52 | CREATE INDEX `memberships_user_team_idx` ON `memberships` (`user_id`,`team_id`);--> statement-breakpoint 53 | CREATE INDEX `memberships_team_id_idx` ON `memberships` (`team_id`);--> statement-breakpoint 54 | CREATE INDEX `memberships_team_user_idx` ON `memberships` (`team_id`,`user_id`);--> statement-breakpoint 55 | CREATE TABLE `__new_sessions` ( 56 | `id` text(24) NOT NULL, 57 | `created_at` integer NOT NULL, 58 | `updated_at` integer NOT NULL, 59 | `last_activity_at` integer, 60 | `user_agent` text, 61 | `ip_address` text, 62 | `payload` text DEFAULT '{}', 63 | `user_id` text NOT NULL, 64 | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade 65 | ); 66 | --> statement-breakpoint 67 | INSERT INTO `__new_sessions`("id", "created_at", "updated_at", "last_activity_at", "user_agent", "ip_address", "payload", "user_id") SELECT "id", "created_at", "updated_at", "last_activity_at", "user_agent", "ip_address", "payload", "user_id" FROM `sessions`;--> statement-breakpoint 68 | DROP TABLE `sessions`;--> statement-breakpoint 69 | ALTER TABLE `__new_sessions` RENAME TO `sessions`;--> statement-breakpoint 70 | CREATE UNIQUE INDEX `sessions_id_unique` ON `sessions` (`id`); -------------------------------------------------------------------------------- /app/views/auth/recover.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "app:components/button"; 2 | import { Spinner } from "app:components/spinner"; 3 | import { anonymous } from "app:helpers/auth"; 4 | import { parseBody } from "app:helpers/body-parser"; 5 | import { cn } from "app:helpers/cn"; 6 | import { rateLimit } from "app:helpers/rate-limit"; 7 | import { badRequest, ok, unprocessableEntity } from "app:helpers/response"; 8 | import { recover } from "app:services.server/auth/recover"; 9 | import { Data } from "@edgefirst-dev/data"; 10 | import { 11 | FormParser, 12 | Parser, 13 | SearchParamsParser, 14 | } from "@edgefirst-dev/data/parser"; 15 | import { Email, Password, StringParser, kv } from "edgekitjs"; 16 | import { Form, redirect, useNavigation } from "react-router"; 17 | import type { Route } from "./+types/recover"; 18 | 19 | export async function loader({ request }: Route.LoaderArgs) { 20 | await anonymous(request, "/profile"); 21 | 22 | let searchParams = new SearchParamsParser(request); 23 | if (!searchParams.has("token")) return ok({ intent: "start" as const }); 24 | 25 | let token = searchParams.get("token"); 26 | let result = await kv().get(`recoveryCode:${token}`); 27 | if (!result.data) return ok({ intent: "start" as const }); 28 | 29 | return ok({ 30 | token, 31 | email: result.data.toString(), 32 | intent: "finish" as const, 33 | }); 34 | } 35 | 36 | export async function action({ request }: Route.ActionArgs) { 37 | await rateLimit(request.headers); 38 | await anonymous(request, "/profile"); 39 | 40 | let data = await parseBody( 41 | request, 42 | class extends Data { 43 | get intent() { 44 | return new StringParser(this.parser.string("intent")).enum( 45 | "start", 46 | "finish", 47 | ); 48 | } 49 | 50 | get email() { 51 | return Email.from(this.parser.string("email")); 52 | } 53 | 54 | get password() { 55 | return Password.from(this.parser.string("password")); 56 | } 57 | 58 | get token() { 59 | return this.parser.string("token"); 60 | } 61 | }, 62 | ); 63 | 64 | try { 65 | await recover(data, new URL(request.url)); 66 | if (data.intent === "start") return ok({}); 67 | throw redirect("/login"); 68 | } catch (error) { 69 | if (error instanceof Parser.Error) { 70 | return unprocessableEntity({ error: error.message }); 71 | } 72 | if (error instanceof Error) { 73 | return badRequest({ error: error.message }); 74 | } 75 | throw error; 76 | } 77 | } 78 | 79 | export default function Component({ 80 | loaderData, 81 | actionData, 82 | }: Route.ComponentProps) { 83 | let navigation = useNavigation(); 84 | let isPending = navigation.state !== "idle"; 85 | 86 | return ( 87 |
88 | 89 | 90 | {loaderData.intent === "finish" ? ( 91 | 92 | ) : null} 93 | 94 |

Recover Account

95 | 96 | {loaderData.intent === "start" ? ( 97 |

98 | Forgot your password? No problem. Just let us know your email address 99 | and we will email you a password reset link that will allow you to 100 | choose a new one. 101 |

102 | ) : ( 103 |

Enter your new password to reset your account.

104 | )} 105 | 106 | {actionData?.ok === false && ( 107 |

{actionData.error}

108 | )} 109 | 110 | {actionData?.ok === true && ( 111 |

112 | We have emailed your account recovery link. 113 |

114 | )} 115 | 116 | 128 | 129 | {loaderData.intent === "finish" ? ( 130 | 139 | ) : null} 140 | 141 |
142 | 154 |
155 |
156 | ); 157 | } 158 | -------------------------------------------------------------------------------- /db/schema.ts: -------------------------------------------------------------------------------- 1 | import { relations } from "drizzle-orm"; 2 | import { index, sqliteTable, text } from "drizzle-orm/sqlite-core"; 3 | import { ID_LENGTH, cuid } from "./helpers/id"; 4 | import { createdAt, timestamp, updatedAt } from "./helpers/timestamp"; 5 | 6 | /** 7 | * This table is where the application will store the data related to each user 8 | * of the application. 9 | */ 10 | export const users = sqliteTable( 11 | "users", 12 | { 13 | id: cuid("id", "users_id_unique"), 14 | // Timestamps 15 | createdAt, 16 | updatedAt, 17 | emailVerifiedAt: timestamp("email_verified_at"), 18 | // Attributes 19 | displayName: text("display_name", { mode: "text" }), 20 | email: text("email", { mode: "text" }).unique().notNull(), 21 | avatarKey: text("avatar_key", { mode: "text", length: ID_LENGTH }), 22 | role: text("role", { mode: "text", enum: ["user", "root"] }).default( 23 | "user", 24 | ), 25 | emailVerificationToken: cuid( 26 | "email_verification_token", 27 | "users_email_verification_token_unique", 28 | ), 29 | }, 30 | (table) => { 31 | return { 32 | emailIndex: index("users_email_idx").on(table.email), 33 | }; 34 | }, 35 | ); 36 | 37 | export const audits = sqliteTable( 38 | "audit_logs", 39 | { 40 | id: cuid("id", "audit_logs_id_unique"), 41 | // Timestamps 42 | createdAt, 43 | // Attributes 44 | action: text("action", { mode: "text" }), 45 | auditable: text("auditable", { mode: "text" }), 46 | payload: text("payload", { mode: "json" }).default(JSON.stringify({})), 47 | // Relationships 48 | userId: text("user_id", { mode: "text" }) 49 | .notNull() 50 | .references(() => users.id, { onDelete: "cascade" }), 51 | }, 52 | (t) => { 53 | return { 54 | userIndex: index("audit_logs_user_id_idx").on(t.userId), 55 | }; 56 | }, 57 | ); 58 | 59 | export const teams = sqliteTable("teams", { 60 | id: cuid("id", "teams_id_unique"), 61 | // Timestamps 62 | createdAt, 63 | updatedAt, 64 | // Attributes 65 | name: text("name", { mode: "text" }), 66 | }); 67 | 68 | export const memberships = sqliteTable( 69 | "memberships", 70 | { 71 | id: cuid("id", "memberships_id_unique"), 72 | // Timestamps 73 | createdAt, 74 | updatedAt, 75 | acceptedAt: timestamp("accepted_at"), 76 | // Attributes 77 | role: text("role", { mode: "text", enum: ["member", "owner"] }) 78 | .notNull() 79 | .default("member"), 80 | // Relationships 81 | userId: text("user_id", { mode: "text" }) 82 | .notNull() 83 | .references(() => users.id, { onDelete: "cascade" }), 84 | teamId: text("team_id", { mode: "text" }) 85 | .notNull() 86 | .references(() => teams.id, { onDelete: "cascade" }), 87 | }, 88 | (t) => { 89 | return { 90 | userIndex: index("memberships_user_id_idx").on(t.userId), 91 | userTeamIndex: index("memberships_user_team_idx").on(t.userId, t.teamId), 92 | teamIndex: index("memberships_team_id_idx").on(t.teamId), 93 | teamUserIndex: index("memberships_team_user_idx").on(t.teamId, t.userId), 94 | }; 95 | }, 96 | ); 97 | 98 | export const credentials = sqliteTable( 99 | "users_credentials", 100 | { 101 | id: cuid("id", "users_credentials_id_unique"), 102 | // Timestamps 103 | createdAt, 104 | updatedAt, 105 | // Attributes 106 | passwordHash: text("password_hash", { mode: "text" }).notNull(), 107 | // Relationships 108 | userId: text("user_id", { mode: "text" }) 109 | .notNull() 110 | .unique() 111 | .references(() => users.id, { onDelete: "cascade" }), 112 | }, 113 | (t) => { 114 | return { 115 | userIdIndex: index("users_credentials_user_id_idx").on(t.userId), 116 | }; 117 | }, 118 | ); 119 | 120 | export const sessions = sqliteTable("sessions", { 121 | id: cuid("id", "sessions_id_unique"), 122 | // Timestamps 123 | createdAt, 124 | updatedAt, 125 | expiresAt: timestamp("expires_at").notNull(), 126 | lastActivityAt: timestamp("last_activity_at"), 127 | // Attributes 128 | userAgent: text("user_agent", { mode: "text" }), 129 | ipAddress: text("ip_address", { mode: "text" }), 130 | payload: text("payload", { mode: "json" }).default(JSON.stringify({})), 131 | // Relationships 132 | userId: text("user_id", { mode: "text" }) 133 | .notNull() 134 | .references(() => users.id, { onDelete: "cascade" }), 135 | }); 136 | 137 | export const usersRelations = relations(users, ({ many, one }) => { 138 | return { 139 | audits: many(audits), 140 | memberships: many(memberships), 141 | credentials: one(credentials), 142 | sessions: many(sessions), 143 | }; 144 | }); 145 | 146 | export const teamsRelations = relations(teams, ({ many }) => { 147 | return { 148 | memberships: many(memberships), 149 | }; 150 | }); 151 | 152 | export const membershipsRelations = relations(memberships, ({ one }) => { 153 | return { 154 | user: one(users, { 155 | fields: [memberships.userId], 156 | references: [users.id], 157 | }), 158 | team: one(teams, { 159 | fields: [memberships.teamId], 160 | references: [teams.id], 161 | }), 162 | }; 163 | }); 164 | 165 | export const credentialsRelations = relations(credentials, ({ one }) => { 166 | return { 167 | user: one(users, { 168 | fields: [credentials.userId], 169 | references: [users.id], 170 | }), 171 | }; 172 | }); 173 | 174 | export const sessionsRelations = relations(sessions, ({ one }) => { 175 | return { 176 | user: one(users, { 177 | fields: [sessions.userId], 178 | references: [users.id], 179 | }), 180 | }; 181 | }); 182 | 183 | export default { 184 | users, 185 | audits, 186 | teams, 187 | memberships, 188 | credentials, 189 | sessions, 190 | usersRelations, 191 | teamsRelations, 192 | membershipsRelations, 193 | credentialsRelations, 194 | sessionsRelations, 195 | }; 196 | -------------------------------------------------------------------------------- /app/helpers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Gravatar } from "app:clients/gravatar"; 2 | import { LoginStrategy } from "app:core/strategies/login"; 3 | import { RegisterStrategy } from "app:core/strategies/register"; 4 | import type { Membership } from "app:entities/membership"; 5 | import type { Team } from "app:entities/team"; 6 | import type { User } from "app:entities/user"; 7 | import { Cookies } from "app:helpers/cookies"; 8 | import { unauthorized } from "app:helpers/response"; 9 | import { createSession, getSession } from "app:helpers/session"; 10 | import { AuditLogsRepository } from "app:repositories.server/audit-logs"; 11 | import { AuthRepository } from "app:repositories.server/auth"; 12 | import { CredentialsRepository } from "app:repositories.server/credentials"; 13 | import { MembershipsRepository } from "app:repositories.server/memberships"; 14 | import { SessionsRepository } from "app:repositories.server/sessions"; 15 | import { TeamsRepository } from "app:repositories.server/teams"; 16 | import { UsersRepository } from "app:repositories.server/users"; 17 | import { geo, waitUntil } from "edgekitjs"; 18 | import { type AppLoadContext, redirect } from "react-router"; 19 | import { Authenticator } from "remix-auth"; 20 | 21 | interface Result { 22 | user: User; 23 | team: Team; 24 | memberships: Membership[]; 25 | } 26 | 27 | const authenticator = new Authenticator(); 28 | const sessions = new SessionsRepository(); 29 | 30 | authenticator.use( 31 | new RegisterStrategy( 32 | { 33 | audits: new AuditLogsRepository(), 34 | auth: new AuthRepository(), 35 | gravatar: new Gravatar(), 36 | users: new UsersRepository(), 37 | }, 38 | async (output) => { 39 | return { 40 | user: output.user, 41 | team: output.team, 42 | memberships: [output.membership], 43 | }; 44 | }, 45 | ), 46 | ); 47 | 48 | authenticator.use( 49 | new LoginStrategy( 50 | { 51 | audits: new AuditLogsRepository(), 52 | credentials: new CredentialsRepository(), 53 | memberships: new MembershipsRepository(), 54 | teams: new TeamsRepository(), 55 | users: new UsersRepository(), 56 | }, 57 | async (output) => { 58 | return { 59 | user: output.user, 60 | team: output.team, 61 | memberships: output.memberships, 62 | }; 63 | }, 64 | ), 65 | ); 66 | 67 | /** Checks if the user is authenticated or not */ 68 | export async function isAuthenticated(request: Request) { 69 | return Boolean(await querySession(request)); 70 | } 71 | 72 | /** Perform the register process */ 73 | export async function register(request: Request, context?: AppLoadContext) { 74 | let output = await authenticator.authenticate("register", request); 75 | 76 | let headers = await createSession({ 77 | user: output.user, 78 | ip: context?.ip, 79 | ua: context?.ua, 80 | payload: { 81 | teamId: output.team.id, 82 | teams: output.memberships.map((m) => m.teamId), 83 | geo: { city: geo().city, country: geo().country }, 84 | }, 85 | }); 86 | 87 | throw redirect("/profile", { headers }); 88 | } 89 | 90 | /** Perform the login process */ 91 | export async function login(request: Request, context?: AppLoadContext) { 92 | let output = await authenticator.authenticate("login", request); 93 | 94 | let headers = await createSession({ 95 | user: output.user, 96 | ip: context?.ip, 97 | ua: context?.ua, 98 | payload: { 99 | teamId: output.team.id, 100 | teams: output.memberships.map((m) => m.teamId), 101 | }, 102 | }); 103 | 104 | throw redirect("/profile", { headers }); 105 | } 106 | 107 | /** Only allow access to a route to authenticated users */ 108 | export async function currentUser(request: Request): Promise { 109 | let session = await getSession(request); 110 | if (!session) throw await requestAuthentication(request); 111 | 112 | let [user] = await new UsersRepository().findById(session.userId); 113 | if (!user) throw await requestAuthentication(request); 114 | 115 | return user; 116 | } 117 | 118 | /** Only allow access to a route to anonymous visitors */ 119 | export async function anonymous(request: Request, returnTo: string) { 120 | let session = await querySession(request); 121 | if (session) throw redirect(returnTo); 122 | } 123 | 124 | /** Only allow access to a route to authenticated root users */ 125 | export async function rootOnly(request: Request) { 126 | let user = await currentUser(request); 127 | if (user.isRoot) return user; 128 | throw unauthorized({ message: "Unauthorized" }); 129 | } 130 | 131 | /** Logout the user by deleting the session, and clearing the cookie */ 132 | export async function logout(request: Request, returnTo = "/") { 133 | let headers = await terminateSession(request); 134 | throw redirect(returnTo, { headers }); 135 | } 136 | 137 | // Private 138 | 139 | async function terminateSession( 140 | request: Request, 141 | responseHeaders = new Headers(), 142 | ) { 143 | let id = await Cookies.session.parse(request.headers.get("cookie")); 144 | await new SessionsRepository().destroy(id); 145 | responseHeaders.append( 146 | "Set-Cookie", 147 | await Cookies.session.serialize(null, { maxAge: 0 }), 148 | ); 149 | responseHeaders.append( 150 | "Set-Cookie", 151 | await Cookies.expiredSession.serialize(null, { maxAge: 0 }), 152 | ); 153 | return responseHeaders; 154 | } 155 | 156 | async function requestAuthentication(request: Request) { 157 | let cookie = await Cookies.returnTo.serialize(request.url); 158 | return redirect("/login", { headers: { "Set-Cookie": cookie } }); 159 | } 160 | 161 | async function querySession(request: Request) { 162 | let session = await findSessionByCookie(request); 163 | 164 | if (!session) return null; 165 | 166 | if (session.hasExpired) { 167 | let headers = await terminateSession(request); 168 | headers.append( 169 | "Set-Cookie", 170 | await Cookies.expiredSession.serialize(session.userId), 171 | ); 172 | throw redirect("/login", { headers }); 173 | } 174 | 175 | waitUntil(sessions.recordActivity(session.id)); 176 | return session; 177 | } 178 | 179 | async function findSessionByCookie(request: Request) { 180 | let sessionId = await Cookies.session.parse(request.headers.get("cookie")); 181 | if (!sessionId) return null; 182 | 183 | let [session] = await sessions.findById(sessionId); 184 | if (!session) return null; 185 | 186 | return session; 187 | } 188 | -------------------------------------------------------------------------------- /app/services.server/auth/login.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, mock, test } from "bun:test"; 2 | import { Credential } from "app:entities/credential"; 3 | import { Email } from "edgekitjs"; 4 | import { Password } from "edgekitjs"; 5 | import { login } from "./login"; 6 | 7 | let waitUntil = mock().mockImplementation( 8 | (promise: Promise) => void 0, 9 | ); 10 | 11 | mock.module("edgekitjs", () => { 12 | return { waitUntil }; 13 | }); 14 | 15 | describe(login.name, () => { 16 | let email = Email.from("john.doe@company.com"); 17 | let password = Password.from("password"); 18 | let credential = Credential.from({ 19 | id: "1", 20 | passwordHash: 21 | "$2a$10$phqHUOF7CTDzw4F7K3PKj.HpLkq/FvxybKb4Cnr54a4Kma997/XwK", 22 | }); 23 | 24 | test("it works", async () => { 25 | let users = { findByEmail: mock() }; 26 | let credentials = { findByUser: mock() }; 27 | let memberships = { findByUser: mock() }; 28 | let teams = { findByMembership: mock() }; 29 | let audits = { create: mock() }; 30 | 31 | users.findByEmail.mockImplementationOnce(async () => [{ id: "1" }]); 32 | credentials.findByUser.mockImplementationOnce(async () => [credential]); 33 | memberships.findByUser.mockImplementationOnce(async () => [{ id: "1" }]); 34 | teams.findByMembership.mockImplementationOnce(async () => [{ id: "1" }]); 35 | 36 | await login( 37 | { email, password }, 38 | { users, credentials, memberships, teams, audits }, 39 | ); 40 | 41 | expect(users.findByEmail).toHaveBeenCalledWith(email); 42 | expect(credentials.findByUser).toHaveBeenCalledWith({ id: "1" }); 43 | expect(memberships.findByUser).toHaveBeenCalledWith({ id: "1" }); 44 | expect(teams.findByMembership).toHaveBeenCalledWith({ id: "1" }); 45 | expect(audits.create).toHaveBeenCalledWith({ id: "1" }, "user_login"); 46 | }); 47 | 48 | test("it throws an error if the user is not found", async () => { 49 | let users = { findByEmail: mock() }; 50 | let credentials = { findByUser: mock() }; 51 | let memberships = { findByUser: mock() }; 52 | let teams = { findByMembership: mock() }; 53 | let audits = { create: mock() }; 54 | 55 | users.findByEmail.mockImplementationOnce(async () => []); 56 | expect( 57 | login( 58 | { email, password }, 59 | { users, credentials, memberships, teams, audits }, 60 | ), 61 | ).rejects.toThrow("User not found"); 62 | }); 63 | 64 | test("it throws an error if the user has no credentials", async () => { 65 | let users = { findByEmail: mock() }; 66 | let credentials = { findByUser: mock() }; 67 | let memberships = { findByUser: mock() }; 68 | let teams = { findByMembership: mock() }; 69 | let audits = { create: mock() }; 70 | 71 | users.findByEmail.mockImplementationOnce(async () => [{ id: "1" }]); 72 | credentials.findByUser.mockImplementationOnce(async () => []); 73 | expect( 74 | login( 75 | { email, password }, 76 | { users, credentials, memberships, teams, audits }, 77 | ), 78 | ).rejects.toThrow("User has no associated credentials"); 79 | }); 80 | 81 | test("it throws an error if the user has no memberships", async () => { 82 | let users = { findByEmail: mock() }; 83 | let credentials = { findByUser: mock() }; 84 | let memberships = { findByUser: mock() }; 85 | let teams = { findByMembership: mock() }; 86 | let audits = { create: mock() }; 87 | 88 | users.findByEmail.mockImplementationOnce(async () => [{ id: "1" }]); 89 | credentials.findByUser.mockImplementationOnce(async () => [credential]); 90 | memberships.findByUser.mockImplementationOnce(async () => []); 91 | expect( 92 | login( 93 | { email, password }, 94 | { users, credentials, memberships, teams, audits }, 95 | ), 96 | ).rejects.toThrow("User has no memberships"); 97 | }); 98 | 99 | test("it throws an error if the user has no team", async () => { 100 | let users = { findByEmail: mock() }; 101 | let credentials = { findByUser: mock() }; 102 | let memberships = { findByUser: mock() }; 103 | let teams = { findByMembership: mock() }; 104 | let audits = { create: mock() }; 105 | 106 | users.findByEmail.mockImplementationOnce(async () => [{ id: "1" }]); 107 | credentials.findByUser.mockImplementationOnce(async () => [credential]); 108 | memberships.findByUser.mockImplementationOnce(async () => [{ id: "1" }]); 109 | teams.findByMembership.mockImplementationOnce(async () => []); 110 | expect( 111 | login( 112 | { email, password }, 113 | { users, credentials, memberships, teams, audits }, 114 | ), 115 | ).rejects.toThrow("User has no team"); 116 | }); 117 | 118 | test("it throws an error if the credentials are invalid", async () => { 119 | let users = { findByEmail: mock() }; 120 | let credentials = { findByUser: mock() }; 121 | let memberships = { findByUser: mock() }; 122 | let teams = { findByMembership: mock() }; 123 | let audits = { create: mock() }; 124 | 125 | users.findByEmail.mockImplementationOnce(async () => [{ id: "1" }]); 126 | credentials.findByUser.mockImplementationOnce(async () => [credential]); 127 | memberships.findByUser.mockImplementationOnce(async () => [{ id: "1" }]); 128 | teams.findByMembership.mockImplementationOnce(async () => [{ id: "1" }]); 129 | expect( 130 | login( 131 | { email, password: Password.from("invalid") }, 132 | { users, credentials, memberships, teams, audits }, 133 | ), 134 | ).rejects.toThrow("Invalid credentials"); 135 | }); 136 | 137 | test("it logs an audit entry for invalid credentials", async () => { 138 | let users = { findByEmail: mock() }; 139 | let credentials = { findByUser: mock() }; 140 | let memberships = { findByUser: mock() }; 141 | let teams = { findByMembership: mock() }; 142 | let audits = { create: mock() }; 143 | 144 | users.findByEmail.mockImplementationOnce(async () => [{ id: "1" }]); 145 | credentials.findByUser.mockImplementationOnce(async () => [credential]); 146 | memberships.findByUser.mockImplementationOnce(async () => [{ id: "1" }]); 147 | teams.findByMembership.mockImplementationOnce(async () => [{ id: "1" }]); 148 | audits.create.mockImplementationOnce(async () => void 0); 149 | 150 | expect( 151 | login( 152 | { email, password: Password.from("invalid") }, 153 | { users, credentials, memberships, teams, audits }, 154 | ), 155 | ).rejects.toThrowError("Invalid credentials"); 156 | 157 | expect(audits.create).toHaveBeenCalledTimes(1); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | The Starter is based on a layered architecture and is designed to be modular and extensible. The architecture is divided into the following layers: 4 | 5 | - **Presentation Layer**: This layer is responsible for rendering the user interface and handling user input. It is implemented using React. 6 | - **HTTP Layer**: This layer is responsible for handling HTTP requests and responses. This are the React Router loaders and actions. 7 | - **Business Logic Layer**: This layer is responsible for implementing the business logic of the application. It's represented by the services functions. 8 | - **Data Access Layer**: This layer is responsible for accessing the data. It's represented by the repositories objects. 9 | 10 | Each layer know only about the layer immediately below it. For example, the Presentation Layer knows only about the HTTP Layer, and the HTTP Layer knows only about the Business Logic Layer, and so on. 11 | 12 | A layer never knows about the layers above it. This means that the Data Access Layer doesn't know about the Business Logic Layer, and the Business Logic Layer doesn't know about the Presentation Layer, and so on. 13 | 14 | ## Presentation Layer 15 | 16 | The Presentation Layer is represented by the React components, each route has an associated component that is responsible for rendering the user interface and handling user input and can render other components. 17 | 18 | The `app/layouts` include routes used as layouts for other more specific routes like the `app/layouts/auth.tsx` which is used for `/login` and `/register`. 19 | 20 | The `app/views` is where non layout routes are defined, here are files like `app/views/home.tsx` or `app/views/admin/dashboard.tsx`. 21 | 22 | ## HTTP Layer 23 | 24 | The HTTP Layer is represented by the React Router loaders and actions 25 | 26 | The loaders are responsible for handling GET requests, and the actions are responsible for handling POST requests, altought an action could handle other HTTP methods as well (PUT, DELETE, etc). 27 | 28 | Consider the loader as a READ function and the action as a WRITE function. 29 | 30 | ## Business Logic Layer 31 | 32 | The Business Logic Layer is represented by the services functions, each service is responsible for implementing a specific business logic, like login, register, etc. 33 | 34 | The Starter comes with a few services already implemented, like the `auth/login` or `auth/register`, but you can create your own services to implement your business logic. 35 | 36 | A service could be a class with a single method, a function, a file with multiple functions, etc. This is up to you but the built-in services follow the same pattern: 37 | 38 | ```ts 39 | export async function serviceName( 40 | input: serviceName.Input, 41 | deps: serviceName.Dependencies = { 42 | // Default instances of the dependencies 43 | } 44 | ): Promise { 45 | // Business logic here 46 | } 47 | 48 | export namespace servieName { 49 | export interface Input { 50 | // Input data 51 | } 52 | 53 | export interface Dependencies { 54 | // Dependencies like repositories or API clients 55 | } 56 | 57 | export interface Output { 58 | // Output data, this can be left to be inferred by the return type of the function 59 | } 60 | } 61 | ``` 62 | 63 | Where `serviceName.Input` is the input data, `serviceName.Dependencies` are the dependencies needed to run the service, and `serviceName.Output` is the output data. 64 | 65 | The dependencies are typed with only the methods that the service needs, this way you can mock the dependencies in the tests easily. 66 | 67 | ## Data Access Layer 68 | 69 | The Data Access Layer is represented by the repositories and API client objects, each repository is responsible for accessing the data from a database. 70 | 71 | A repository defined inside `app/repositories.server/` is a class that exposes all the methods to query a specific table, collection, etc. The Starter comes with a few repositories already implemented, like the `UserRepository`, but you can create your own repositories to access your data. 72 | 73 | The recommendation is to only query the database in the repositories, and keep the business logic in the services, and to keep the methods here as simple as possible without any logic inside, just receive inputs and translate that to DB queries. 74 | 75 | A method should either return nothing or return one or more entities. 76 | 77 | The API clients defined inside `app/clients/` are classes that expose methods to interact with an external API, they extends APIClient class and can define methods to simplify the interaction with the API. 78 | 79 | ## Extras 80 | 81 | ### Data Transfer Objects 82 | 83 | A Data Transfer Object (DTO) is an object that carries data between processes. The Starter uses DTOs to transfer data between the layers, like from the HTTP Layer to the Business Logic Layer. 84 | 85 | A DTO is represented by a class that extends the Data class from `@edgefirst-dev/data` and uses a Parser to validate the data. 86 | 87 | A DTO could also be leveraged to format the data before sending it to or from the Data Access Layer. 88 | 89 | ### Entities 90 | 91 | The `app/entities` has files that represents a single object in the domain model. Most entities are backed by a database table, but some entities may be transient and not persisted to the database, or may be backed by a different kind of data store, like representations of data from an external API. 92 | 93 | Here you can create classes that extends TableEntity or Entity classes, and each class could be as simple as a way to expose accessing in a type-safe way the actual data or expose other derived attributes and methods. 94 | 95 | ### Helpers 96 | 97 | The `app/helpers` folder contains code that is related to the HTTP and Presentation layers, this can be code used in components, or in loader and actions. 98 | 99 | ### Jobs and Tasks 100 | 101 | The Jobs and Tasks are a way to run code in the background, like sending an email, or cleaning up the database, etc. 102 | 103 | Jobs are long-running processes that we enqueue and run in the background. 104 | 105 | Tasks are scheduled jobs that run at a specific time or interval without the need to enqueue them again. 106 | 107 | The Starter comes with an example job to sync the user profile from Gravatar as this can take time so it runs after the user registers. 108 | 109 | An example task is included to clean up the database from expired sessions every hour. 110 | -------------------------------------------------------------------------------- /scripts/setup.ts: -------------------------------------------------------------------------------- 1 | import * as Path from "node:path"; 2 | import * as Util from "node:util"; 3 | import { Octokit } from "@octokit/core"; 4 | import { $, write } from "bun"; 5 | import { Cloudflare } from "cloudflare"; 6 | import consola from "consola"; 7 | import { config } from "dotenv"; 8 | import { parameterize } from "inflected"; 9 | import { Account } from "./setup/cf/account"; 10 | import { Database } from "./setup/cf/d1-database"; 11 | import { KVNamespace } from "./setup/cf/kv-namespace"; 12 | import { Queue } from "./setup/cf/queue"; 13 | import { R2Bucket } from "./setup/cf/r2-bucket"; 14 | import { Secret } from "./setup/cf/secret"; 15 | import { Worker } from "./setup/cf/worker"; 16 | import { ActionSecret } from "./setup/gh/action-secret"; 17 | import { Repository } from "./setup/gh/repository"; 18 | import { User } from "./setup/gh/user"; 19 | import { ask, confirm, createTokenURL } from "./setup/helpers"; 20 | import { Package } from "./setup/package"; 21 | 22 | config({ path: "./.dev.vars" }); 23 | 24 | try { 25 | consola.box("Starting the setup process for your Edge-first App."); 26 | let pkg = await Package.read(); 27 | 28 | /** The paths the setup will use to create new files */ 29 | let paths = { 30 | vars: Path.resolve("./.dev.vars"), 31 | wrangler: Path.resolve("./wrangler.json"), 32 | }; 33 | 34 | /** The project name from the use */ 35 | let { 36 | positionals: [, , projectName], 37 | } = Util.parseArgs({ args: Bun.argv, allowPositionals: true }); 38 | 39 | /** If it was not a positional argument, ask for it to the user */ 40 | projectName ??= await consola.prompt("What is your project name?", { 41 | default: projectName, 42 | initial: projectName, 43 | required: true, 44 | type: "text", 45 | }); 46 | 47 | projectName = projectName.trim(); 48 | projectName = parameterize(projectName); 49 | 50 | if (projectName === "") { 51 | consola.error("Project name is required."); 52 | process.exit(1); 53 | } 54 | 55 | pkg.name = projectName; 56 | pkg.description = "A Cloudflare Worker project."; 57 | 58 | /** Check if we can get the CF API token from env or ask the user */ 59 | let apiToken = await ask( 60 | "Enter your Cloudflare API token if you have one.", 61 | Bun.env.CLOUDFLARE_API_TOKEN, 62 | ); 63 | 64 | if (!apiToken) { 65 | consola.warn("Cloudflare API token is required."); 66 | consola.info( 67 | "Create one with the required permissions going to:\n\t", 68 | createTokenURL().toString(), 69 | ); 70 | process.exit(1); 71 | } 72 | 73 | /** Check if we can get the Gravatar API token from env or ask the user */ 74 | let gravatar = await ask( 75 | "Do you have a Gravatar API token?", 76 | Bun.env.GRAVATAR_API_TOKEN, 77 | ); 78 | 79 | /** Check if we can get the Verifier API key from env or ask the user */ 80 | let verifier = await ask( 81 | "Do you have a Verifier API key?", 82 | Bun.env.VERIFIER_API_KEY, 83 | ); 84 | 85 | /** Create a CF API client instance */ 86 | let cf = new Cloudflare({ apiToken }); 87 | 88 | /** Look up for the CF account to use */ 89 | let account = await Account.find(cf); 90 | 91 | /** Find or create the resources of the app (DB, KV, FS and Queue) */ 92 | let db = await Database.upsert(cf, account, projectName); 93 | let kv = await KVNamespace.upsert(cf, account, projectName); 94 | let r2 = await R2Bucket.upsert(cf, account, projectName); 95 | let queue = await Queue.upsert(cf, account, projectName); 96 | 97 | /** To set the secrets, we will first find a Worker instance or create one */ 98 | let worker = await Worker.upsert(cf, account, projectName); 99 | 100 | /** If the app has a Gravatar API token, create a secret */ 101 | if (gravatar) { 102 | await Secret.create(cf, account, worker, "GRAVATAR_API_TOKEN", gravatar); 103 | } 104 | 105 | /** If the app has a Verifier API key, create a secret */ 106 | if (verifier) { 107 | await Secret.create(cf, account, worker, "VERIFIER_API_KEY", verifier); 108 | } 109 | 110 | consola.info("Creating .dev.vars file with the app environment variables."); 111 | 112 | await write( 113 | paths.vars, 114 | `CLOUDFLARE_ACCOUNT_ID=${account.id} 115 | CLOUDFLARE_DATABASE_ID=${db.name} 116 | CLOUDFLARE_API_TOKEN=${apiToken} 117 | 118 | GRAVATAR_API_TOKEN="${gravatar}" 119 | 120 | VERIFIER_API_KEY="${verifier}"`, 121 | ); 122 | 123 | consola.info("Updating wrangler.toml file with the worker setup."); 124 | 125 | let today = new Date(); 126 | 127 | await write( 128 | paths.wrangler, 129 | JSON.stringify( 130 | { 131 | $schema: "https://unpkg.com/wrangler@latest/config-schema.json", 132 | name: projectName, 133 | main: "./app/entry.worker.ts", 134 | compatibility_date: [ 135 | today.getFullYear(), 136 | today.getMonth() + 1, 137 | today.getDate(), 138 | ].join("-"), 139 | compatibility_flags: ["nodejs_compat"], 140 | workers_dev: true, 141 | dev: { port: 3000 }, 142 | placement: { mode: "off" }, 143 | observability: { enabled: true }, 144 | assets: { directory: "./build/client" }, 145 | browser: { binding: "BROWSER" }, 146 | d1_databases: [ 147 | { 148 | binding: "DB", 149 | database_name: db.name, 150 | database_id: db.id, 151 | migrations_dir: "./db/migrations", 152 | }, 153 | ], 154 | kv_namespaces: [{ binding: "KV", id: kv.id }], 155 | r2_buckets: [{ binding: "FS", bucket_name: r2.name }], 156 | queues: { 157 | consumers: [ 158 | { 159 | queue: queue.name, 160 | max_batch_size: 10, 161 | max_batch_timeout: 30, 162 | max_retries: 10, 163 | }, 164 | ], 165 | producers: [{ binding: "QUEUE", queue: queue.name }], 166 | }, 167 | ai: { binding: "AI" }, 168 | triggers: { crons: ["* * * * *"] }, 169 | vars: { APP_ENV: "development" }, 170 | env: { production: { vars: { APP_ENV: "production" } } }, 171 | }, 172 | null, 173 | "\t", 174 | ), 175 | ); 176 | 177 | consola.info("Running the local database migrations."); 178 | 179 | await $`bun run db:migrate:local ${db.name}`.quiet().nothrow(); 180 | 181 | consola.info("Running seed data against local database."); 182 | 183 | await $`bun run db:seed ${db.name}`.quiet().nothrow(); 184 | 185 | if (await confirm("Do you want to deploy the worker now?")) { 186 | consola.info("Running migrations against the production database."); 187 | await $`bun run db:migrate --remote ${db.name}`.quiet().nothrow(); 188 | consola.info("Building the application."); 189 | await $`bun run build`.quiet().nothrow(); 190 | consola.info("Deploying the worker."); 191 | await $`bun run deploy`.quiet().nothrow(); 192 | consola.success("Worker deployed successfully."); 193 | } 194 | 195 | if (await confirm("Do you want to configure your GitHub repository?")) { 196 | let auth = await ask("What's your GitHub API token?", Bun.env.GITHUB_TOKEN); 197 | 198 | try { 199 | let gh = new Octokit({ auth }); 200 | 201 | let path = await ask( 202 | "What's your GitHub repository? (e.g. edgefirst-dev/my-app or my-app)", 203 | ); 204 | 205 | let isOrg = path.includes("/"); 206 | 207 | let [owner, repo]: [string | null, string] = isOrg 208 | ? (path.split("/") as [string, string]) 209 | : ([null, path] as const); 210 | 211 | if (!owner) { 212 | let user = await User.viewer(gh); 213 | owner = user.login; 214 | } 215 | 216 | let repository = await Repository.upsert(gh, owner, repo, isOrg); 217 | 218 | pkg.repository = repository; 219 | 220 | consola.info(`Configuring action secrets for ${owner}/${repo}.`); 221 | 222 | await ActionSecret.create( 223 | gh, 224 | owner, 225 | repo, 226 | "CLOUDFLARE_ACCOUNT_ID", 227 | account.id, 228 | ); 229 | 230 | await ActionSecret.create( 231 | gh, 232 | owner, 233 | repo, 234 | "CLOUDFLARE_API_TOKEN", 235 | apiToken, 236 | ); 237 | 238 | await ActionSecret.create( 239 | gh, 240 | owner, 241 | repo, 242 | "CLOUDFLARE_DATABASE_NAME", 243 | db.name, 244 | ); 245 | } catch { 246 | consola.error( 247 | "Something failed when trying to setup GitHub, ensure the token in still valid and has the correct permissions `repo` and `read:user`.", 248 | ); 249 | process.exit(1); 250 | } 251 | } 252 | 253 | await pkg.write(); // Save package.json 254 | 255 | if (await confirm("Do you want to clean up the setup files?")) { 256 | consola.info("Cleaning up the setup files."); 257 | await $`rm ./scripts/setup.ts`.quiet().nothrow(); // Delete setup script 258 | await $`rm -rf ./scripts/setup`.quiet().nothrow(); // Delete setup folder 259 | await $`bun rm cloudflare consola @types/libsodium-wrappers libsodium-wrappers` 260 | .quiet() 261 | .nothrow(); // Remove setup-only dependencies 262 | } 263 | 264 | consola.success("Setup completed successfully."); 265 | 266 | process.exit(0); 267 | } catch (error) { 268 | if (error instanceof Error) console.error(error.message); 269 | consola.info( 270 | "If you need help, please open an issue at github.com/edgefirst-dev/starter", 271 | ); 272 | 273 | consola.info( 274 | "If you want to start over, run `bun run setup` again, already created resources will be reused.", 275 | ); 276 | 277 | process.exit(1); 278 | } 279 | -------------------------------------------------------------------------------- /db/migrations/meta/20241020085523_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "989c20a3-4bf8-4e15-89f2-f8d7164bd891", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "audit_logs": { 8 | "name": "audit_logs", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "text(24)", 13 | "primaryKey": false, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "created_at": { 18 | "name": "created_at", 19 | "type": "integer", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "action": { 25 | "name": "action", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false, 29 | "autoincrement": false 30 | }, 31 | "auditable": { 32 | "name": "auditable", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "payload": { 39 | "name": "payload", 40 | "type": "text", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false, 44 | "default": "'{}'" 45 | }, 46 | "user_id": { 47 | "name": "user_id", 48 | "type": "text", 49 | "primaryKey": false, 50 | "notNull": true, 51 | "autoincrement": false 52 | } 53 | }, 54 | "indexes": { 55 | "audit_logs_id_unique": { 56 | "name": "audit_logs_id_unique", 57 | "columns": ["id"], 58 | "isUnique": true 59 | }, 60 | "audit_logs_user_id_idx": { 61 | "name": "audit_logs_user_id_idx", 62 | "columns": ["user_id"], 63 | "isUnique": false 64 | } 65 | }, 66 | "foreignKeys": { 67 | "audit_logs_user_id_users_id_fk": { 68 | "name": "audit_logs_user_id_users_id_fk", 69 | "tableFrom": "audit_logs", 70 | "tableTo": "users", 71 | "columnsFrom": ["user_id"], 72 | "columnsTo": ["id"], 73 | "onDelete": "no action", 74 | "onUpdate": "no action" 75 | } 76 | }, 77 | "compositePrimaryKeys": {}, 78 | "uniqueConstraints": {}, 79 | "checkConstraints": {} 80 | }, 81 | "users_credentials": { 82 | "name": "users_credentials", 83 | "columns": { 84 | "id": { 85 | "name": "id", 86 | "type": "text(24)", 87 | "primaryKey": false, 88 | "notNull": true, 89 | "autoincrement": false 90 | }, 91 | "created_at": { 92 | "name": "created_at", 93 | "type": "integer", 94 | "primaryKey": false, 95 | "notNull": true, 96 | "autoincrement": false 97 | }, 98 | "updated_at": { 99 | "name": "updated_at", 100 | "type": "integer", 101 | "primaryKey": false, 102 | "notNull": true, 103 | "autoincrement": false 104 | }, 105 | "password_hash": { 106 | "name": "password_hash", 107 | "type": "text", 108 | "primaryKey": false, 109 | "notNull": true, 110 | "autoincrement": false 111 | }, 112 | "reset_token": { 113 | "name": "reset_token", 114 | "type": "text", 115 | "primaryKey": false, 116 | "notNull": false, 117 | "autoincrement": false 118 | }, 119 | "user_id": { 120 | "name": "user_id", 121 | "type": "text", 122 | "primaryKey": false, 123 | "notNull": true, 124 | "autoincrement": false 125 | } 126 | }, 127 | "indexes": { 128 | "users_credentials_id_unique": { 129 | "name": "users_credentials_id_unique", 130 | "columns": ["id"], 131 | "isUnique": true 132 | }, 133 | "users_credentials_reset_token_unique": { 134 | "name": "users_credentials_reset_token_unique", 135 | "columns": ["reset_token"], 136 | "isUnique": true 137 | }, 138 | "users_credentials_user_id_idx": { 139 | "name": "users_credentials_user_id_idx", 140 | "columns": ["user_id"], 141 | "isUnique": false 142 | }, 143 | "users_credentials_reset_token_idx": { 144 | "name": "users_credentials_reset_token_idx", 145 | "columns": ["reset_token"], 146 | "isUnique": false 147 | } 148 | }, 149 | "foreignKeys": { 150 | "users_credentials_user_id_users_id_fk": { 151 | "name": "users_credentials_user_id_users_id_fk", 152 | "tableFrom": "users_credentials", 153 | "tableTo": "users", 154 | "columnsFrom": ["user_id"], 155 | "columnsTo": ["id"], 156 | "onDelete": "no action", 157 | "onUpdate": "no action" 158 | } 159 | }, 160 | "compositePrimaryKeys": {}, 161 | "uniqueConstraints": {}, 162 | "checkConstraints": {} 163 | }, 164 | "memberships": { 165 | "name": "memberships", 166 | "columns": { 167 | "id": { 168 | "name": "id", 169 | "type": "text(24)", 170 | "primaryKey": false, 171 | "notNull": true, 172 | "autoincrement": false 173 | }, 174 | "created_at": { 175 | "name": "created_at", 176 | "type": "integer", 177 | "primaryKey": false, 178 | "notNull": true, 179 | "autoincrement": false 180 | }, 181 | "updated_at": { 182 | "name": "updated_at", 183 | "type": "integer", 184 | "primaryKey": false, 185 | "notNull": true, 186 | "autoincrement": false 187 | }, 188 | "accepted_at": { 189 | "name": "accepted_at", 190 | "type": "integer", 191 | "primaryKey": false, 192 | "notNull": false, 193 | "autoincrement": false 194 | }, 195 | "role": { 196 | "name": "role", 197 | "type": "text", 198 | "primaryKey": false, 199 | "notNull": true, 200 | "autoincrement": false, 201 | "default": "'member'" 202 | }, 203 | "user_id": { 204 | "name": "user_id", 205 | "type": "text", 206 | "primaryKey": false, 207 | "notNull": true, 208 | "autoincrement": false 209 | }, 210 | "team_id": { 211 | "name": "team_id", 212 | "type": "text", 213 | "primaryKey": false, 214 | "notNull": true, 215 | "autoincrement": false 216 | } 217 | }, 218 | "indexes": { 219 | "memberships_id_unique": { 220 | "name": "memberships_id_unique", 221 | "columns": ["id"], 222 | "isUnique": true 223 | }, 224 | "memberships_user_id_idx": { 225 | "name": "memberships_user_id_idx", 226 | "columns": ["user_id"], 227 | "isUnique": false 228 | }, 229 | "memberships_user_team_idx": { 230 | "name": "memberships_user_team_idx", 231 | "columns": ["user_id", "team_id"], 232 | "isUnique": false 233 | }, 234 | "memberships_team_id_idx": { 235 | "name": "memberships_team_id_idx", 236 | "columns": ["team_id"], 237 | "isUnique": false 238 | }, 239 | "memberships_team_user_idx": { 240 | "name": "memberships_team_user_idx", 241 | "columns": ["team_id", "user_id"], 242 | "isUnique": false 243 | } 244 | }, 245 | "foreignKeys": { 246 | "memberships_user_id_users_id_fk": { 247 | "name": "memberships_user_id_users_id_fk", 248 | "tableFrom": "memberships", 249 | "tableTo": "users", 250 | "columnsFrom": ["user_id"], 251 | "columnsTo": ["id"], 252 | "onDelete": "no action", 253 | "onUpdate": "no action" 254 | }, 255 | "memberships_team_id_teams_id_fk": { 256 | "name": "memberships_team_id_teams_id_fk", 257 | "tableFrom": "memberships", 258 | "tableTo": "teams", 259 | "columnsFrom": ["team_id"], 260 | "columnsTo": ["id"], 261 | "onDelete": "no action", 262 | "onUpdate": "no action" 263 | } 264 | }, 265 | "compositePrimaryKeys": {}, 266 | "uniqueConstraints": {}, 267 | "checkConstraints": {} 268 | }, 269 | "teams": { 270 | "name": "teams", 271 | "columns": { 272 | "id": { 273 | "name": "id", 274 | "type": "text(24)", 275 | "primaryKey": false, 276 | "notNull": true, 277 | "autoincrement": false 278 | }, 279 | "created_at": { 280 | "name": "created_at", 281 | "type": "integer", 282 | "primaryKey": false, 283 | "notNull": true, 284 | "autoincrement": false 285 | }, 286 | "updated_at": { 287 | "name": "updated_at", 288 | "type": "integer", 289 | "primaryKey": false, 290 | "notNull": true, 291 | "autoincrement": false 292 | }, 293 | "name": { 294 | "name": "name", 295 | "type": "text", 296 | "primaryKey": false, 297 | "notNull": false, 298 | "autoincrement": false 299 | } 300 | }, 301 | "indexes": { 302 | "teams_id_unique": { 303 | "name": "teams_id_unique", 304 | "columns": ["id"], 305 | "isUnique": true 306 | } 307 | }, 308 | "foreignKeys": {}, 309 | "compositePrimaryKeys": {}, 310 | "uniqueConstraints": {}, 311 | "checkConstraints": {} 312 | }, 313 | "users": { 314 | "name": "users", 315 | "columns": { 316 | "id": { 317 | "name": "id", 318 | "type": "text(24)", 319 | "primaryKey": false, 320 | "notNull": true, 321 | "autoincrement": false 322 | }, 323 | "created_at": { 324 | "name": "created_at", 325 | "type": "integer", 326 | "primaryKey": false, 327 | "notNull": true, 328 | "autoincrement": false 329 | }, 330 | "updated_at": { 331 | "name": "updated_at", 332 | "type": "integer", 333 | "primaryKey": false, 334 | "notNull": true, 335 | "autoincrement": false 336 | }, 337 | "email_verified_at": { 338 | "name": "email_verified_at", 339 | "type": "integer", 340 | "primaryKey": false, 341 | "notNull": false, 342 | "autoincrement": false 343 | }, 344 | "display_name": { 345 | "name": "display_name", 346 | "type": "text", 347 | "primaryKey": false, 348 | "notNull": false, 349 | "autoincrement": false 350 | }, 351 | "email": { 352 | "name": "email", 353 | "type": "text", 354 | "primaryKey": false, 355 | "notNull": true, 356 | "autoincrement": false 357 | }, 358 | "avatar_key": { 359 | "name": "avatar_key", 360 | "type": "text(24)", 361 | "primaryKey": false, 362 | "notNull": false, 363 | "autoincrement": false 364 | }, 365 | "role": { 366 | "name": "role", 367 | "type": "text", 368 | "primaryKey": false, 369 | "notNull": false, 370 | "autoincrement": false, 371 | "default": "'user'" 372 | }, 373 | "email_verification_token": { 374 | "name": "email_verification_token", 375 | "type": "text(24)", 376 | "primaryKey": false, 377 | "notNull": true, 378 | "autoincrement": false 379 | } 380 | }, 381 | "indexes": { 382 | "users_id_unique": { 383 | "name": "users_id_unique", 384 | "columns": ["id"], 385 | "isUnique": true 386 | }, 387 | "users_email_unique": { 388 | "name": "users_email_unique", 389 | "columns": ["email"], 390 | "isUnique": true 391 | }, 392 | "users_email_verification_token_unique": { 393 | "name": "users_email_verification_token_unique", 394 | "columns": ["email_verification_token"], 395 | "isUnique": true 396 | }, 397 | "users_email_idx": { 398 | "name": "users_email_idx", 399 | "columns": ["email"], 400 | "isUnique": false 401 | } 402 | }, 403 | "foreignKeys": {}, 404 | "compositePrimaryKeys": {}, 405 | "uniqueConstraints": {}, 406 | "checkConstraints": {} 407 | } 408 | }, 409 | "views": {}, 410 | "enums": {}, 411 | "_meta": { 412 | "schemas": {}, 413 | "tables": {}, 414 | "columns": {} 415 | }, 416 | "internal": { 417 | "indexes": {} 418 | } 419 | } 420 | -------------------------------------------------------------------------------- /db/migrations/meta/20241121083023_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "c0688e60-de1b-4922-809b-18b1964ec19c", 5 | "prevId": "803ec8b9-b930-4df3-a5d1-5f0106714aa0", 6 | "tables": { 7 | "audit_logs": { 8 | "name": "audit_logs", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "text(24)", 13 | "primaryKey": false, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "created_at": { 18 | "name": "created_at", 19 | "type": "integer", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "action": { 25 | "name": "action", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false, 29 | "autoincrement": false 30 | }, 31 | "auditable": { 32 | "name": "auditable", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "payload": { 39 | "name": "payload", 40 | "type": "text", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false, 44 | "default": "'{}'" 45 | }, 46 | "user_id": { 47 | "name": "user_id", 48 | "type": "text", 49 | "primaryKey": false, 50 | "notNull": true, 51 | "autoincrement": false 52 | } 53 | }, 54 | "indexes": { 55 | "audit_logs_id_unique": { 56 | "name": "audit_logs_id_unique", 57 | "columns": ["id"], 58 | "isUnique": true 59 | }, 60 | "audit_logs_user_id_idx": { 61 | "name": "audit_logs_user_id_idx", 62 | "columns": ["user_id"], 63 | "isUnique": false 64 | } 65 | }, 66 | "foreignKeys": { 67 | "audit_logs_user_id_users_id_fk": { 68 | "name": "audit_logs_user_id_users_id_fk", 69 | "tableFrom": "audit_logs", 70 | "tableTo": "users", 71 | "columnsFrom": ["user_id"], 72 | "columnsTo": ["id"], 73 | "onDelete": "cascade", 74 | "onUpdate": "no action" 75 | } 76 | }, 77 | "compositePrimaryKeys": {}, 78 | "uniqueConstraints": {}, 79 | "checkConstraints": {} 80 | }, 81 | "users_credentials": { 82 | "name": "users_credentials", 83 | "columns": { 84 | "id": { 85 | "name": "id", 86 | "type": "text(24)", 87 | "primaryKey": false, 88 | "notNull": true, 89 | "autoincrement": false 90 | }, 91 | "created_at": { 92 | "name": "created_at", 93 | "type": "integer", 94 | "primaryKey": false, 95 | "notNull": true, 96 | "autoincrement": false 97 | }, 98 | "updated_at": { 99 | "name": "updated_at", 100 | "type": "integer", 101 | "primaryKey": false, 102 | "notNull": true, 103 | "autoincrement": false 104 | }, 105 | "password_hash": { 106 | "name": "password_hash", 107 | "type": "text", 108 | "primaryKey": false, 109 | "notNull": true, 110 | "autoincrement": false 111 | }, 112 | "user_id": { 113 | "name": "user_id", 114 | "type": "text", 115 | "primaryKey": false, 116 | "notNull": true, 117 | "autoincrement": false 118 | } 119 | }, 120 | "indexes": { 121 | "users_credentials_id_unique": { 122 | "name": "users_credentials_id_unique", 123 | "columns": ["id"], 124 | "isUnique": true 125 | }, 126 | "users_credentials_user_id_unique": { 127 | "name": "users_credentials_user_id_unique", 128 | "columns": ["user_id"], 129 | "isUnique": true 130 | }, 131 | "users_credentials_user_id_idx": { 132 | "name": "users_credentials_user_id_idx", 133 | "columns": ["user_id"], 134 | "isUnique": false 135 | } 136 | }, 137 | "foreignKeys": { 138 | "users_credentials_user_id_users_id_fk": { 139 | "name": "users_credentials_user_id_users_id_fk", 140 | "tableFrom": "users_credentials", 141 | "tableTo": "users", 142 | "columnsFrom": ["user_id"], 143 | "columnsTo": ["id"], 144 | "onDelete": "cascade", 145 | "onUpdate": "no action" 146 | } 147 | }, 148 | "compositePrimaryKeys": {}, 149 | "uniqueConstraints": {}, 150 | "checkConstraints": {} 151 | }, 152 | "memberships": { 153 | "name": "memberships", 154 | "columns": { 155 | "id": { 156 | "name": "id", 157 | "type": "text(24)", 158 | "primaryKey": false, 159 | "notNull": true, 160 | "autoincrement": false 161 | }, 162 | "created_at": { 163 | "name": "created_at", 164 | "type": "integer", 165 | "primaryKey": false, 166 | "notNull": true, 167 | "autoincrement": false 168 | }, 169 | "updated_at": { 170 | "name": "updated_at", 171 | "type": "integer", 172 | "primaryKey": false, 173 | "notNull": true, 174 | "autoincrement": false 175 | }, 176 | "accepted_at": { 177 | "name": "accepted_at", 178 | "type": "integer", 179 | "primaryKey": false, 180 | "notNull": false, 181 | "autoincrement": false 182 | }, 183 | "role": { 184 | "name": "role", 185 | "type": "text", 186 | "primaryKey": false, 187 | "notNull": true, 188 | "autoincrement": false, 189 | "default": "'member'" 190 | }, 191 | "user_id": { 192 | "name": "user_id", 193 | "type": "text", 194 | "primaryKey": false, 195 | "notNull": true, 196 | "autoincrement": false 197 | }, 198 | "team_id": { 199 | "name": "team_id", 200 | "type": "text", 201 | "primaryKey": false, 202 | "notNull": true, 203 | "autoincrement": false 204 | } 205 | }, 206 | "indexes": { 207 | "memberships_id_unique": { 208 | "name": "memberships_id_unique", 209 | "columns": ["id"], 210 | "isUnique": true 211 | }, 212 | "memberships_user_id_idx": { 213 | "name": "memberships_user_id_idx", 214 | "columns": ["user_id"], 215 | "isUnique": false 216 | }, 217 | "memberships_user_team_idx": { 218 | "name": "memberships_user_team_idx", 219 | "columns": ["user_id", "team_id"], 220 | "isUnique": false 221 | }, 222 | "memberships_team_id_idx": { 223 | "name": "memberships_team_id_idx", 224 | "columns": ["team_id"], 225 | "isUnique": false 226 | }, 227 | "memberships_team_user_idx": { 228 | "name": "memberships_team_user_idx", 229 | "columns": ["team_id", "user_id"], 230 | "isUnique": false 231 | } 232 | }, 233 | "foreignKeys": { 234 | "memberships_user_id_users_id_fk": { 235 | "name": "memberships_user_id_users_id_fk", 236 | "tableFrom": "memberships", 237 | "tableTo": "users", 238 | "columnsFrom": ["user_id"], 239 | "columnsTo": ["id"], 240 | "onDelete": "cascade", 241 | "onUpdate": "no action" 242 | }, 243 | "memberships_team_id_teams_id_fk": { 244 | "name": "memberships_team_id_teams_id_fk", 245 | "tableFrom": "memberships", 246 | "tableTo": "teams", 247 | "columnsFrom": ["team_id"], 248 | "columnsTo": ["id"], 249 | "onDelete": "cascade", 250 | "onUpdate": "no action" 251 | } 252 | }, 253 | "compositePrimaryKeys": {}, 254 | "uniqueConstraints": {}, 255 | "checkConstraints": {} 256 | }, 257 | "sessions": { 258 | "name": "sessions", 259 | "columns": { 260 | "id": { 261 | "name": "id", 262 | "type": "text(24)", 263 | "primaryKey": false, 264 | "notNull": true, 265 | "autoincrement": false 266 | }, 267 | "created_at": { 268 | "name": "created_at", 269 | "type": "integer", 270 | "primaryKey": false, 271 | "notNull": true, 272 | "autoincrement": false 273 | }, 274 | "updated_at": { 275 | "name": "updated_at", 276 | "type": "integer", 277 | "primaryKey": false, 278 | "notNull": true, 279 | "autoincrement": false 280 | }, 281 | "expires_at": { 282 | "name": "expires_at", 283 | "type": "integer", 284 | "primaryKey": false, 285 | "notNull": true, 286 | "autoincrement": false 287 | }, 288 | "last_activity_at": { 289 | "name": "last_activity_at", 290 | "type": "integer", 291 | "primaryKey": false, 292 | "notNull": false, 293 | "autoincrement": false 294 | }, 295 | "user_agent": { 296 | "name": "user_agent", 297 | "type": "text", 298 | "primaryKey": false, 299 | "notNull": false, 300 | "autoincrement": false 301 | }, 302 | "ip_address": { 303 | "name": "ip_address", 304 | "type": "text", 305 | "primaryKey": false, 306 | "notNull": false, 307 | "autoincrement": false 308 | }, 309 | "payload": { 310 | "name": "payload", 311 | "type": "text", 312 | "primaryKey": false, 313 | "notNull": false, 314 | "autoincrement": false, 315 | "default": "'{}'" 316 | }, 317 | "user_id": { 318 | "name": "user_id", 319 | "type": "text", 320 | "primaryKey": false, 321 | "notNull": true, 322 | "autoincrement": false 323 | } 324 | }, 325 | "indexes": { 326 | "sessions_id_unique": { 327 | "name": "sessions_id_unique", 328 | "columns": ["id"], 329 | "isUnique": true 330 | } 331 | }, 332 | "foreignKeys": { 333 | "sessions_user_id_users_id_fk": { 334 | "name": "sessions_user_id_users_id_fk", 335 | "tableFrom": "sessions", 336 | "tableTo": "users", 337 | "columnsFrom": ["user_id"], 338 | "columnsTo": ["id"], 339 | "onDelete": "cascade", 340 | "onUpdate": "no action" 341 | } 342 | }, 343 | "compositePrimaryKeys": {}, 344 | "uniqueConstraints": {}, 345 | "checkConstraints": {} 346 | }, 347 | "teams": { 348 | "name": "teams", 349 | "columns": { 350 | "id": { 351 | "name": "id", 352 | "type": "text(24)", 353 | "primaryKey": false, 354 | "notNull": true, 355 | "autoincrement": false 356 | }, 357 | "created_at": { 358 | "name": "created_at", 359 | "type": "integer", 360 | "primaryKey": false, 361 | "notNull": true, 362 | "autoincrement": false 363 | }, 364 | "updated_at": { 365 | "name": "updated_at", 366 | "type": "integer", 367 | "primaryKey": false, 368 | "notNull": true, 369 | "autoincrement": false 370 | }, 371 | "name": { 372 | "name": "name", 373 | "type": "text", 374 | "primaryKey": false, 375 | "notNull": false, 376 | "autoincrement": false 377 | } 378 | }, 379 | "indexes": { 380 | "teams_id_unique": { 381 | "name": "teams_id_unique", 382 | "columns": ["id"], 383 | "isUnique": true 384 | } 385 | }, 386 | "foreignKeys": {}, 387 | "compositePrimaryKeys": {}, 388 | "uniqueConstraints": {}, 389 | "checkConstraints": {} 390 | }, 391 | "users": { 392 | "name": "users", 393 | "columns": { 394 | "id": { 395 | "name": "id", 396 | "type": "text(24)", 397 | "primaryKey": false, 398 | "notNull": true, 399 | "autoincrement": false 400 | }, 401 | "created_at": { 402 | "name": "created_at", 403 | "type": "integer", 404 | "primaryKey": false, 405 | "notNull": true, 406 | "autoincrement": false 407 | }, 408 | "updated_at": { 409 | "name": "updated_at", 410 | "type": "integer", 411 | "primaryKey": false, 412 | "notNull": true, 413 | "autoincrement": false 414 | }, 415 | "email_verified_at": { 416 | "name": "email_verified_at", 417 | "type": "integer", 418 | "primaryKey": false, 419 | "notNull": false, 420 | "autoincrement": false 421 | }, 422 | "display_name": { 423 | "name": "display_name", 424 | "type": "text", 425 | "primaryKey": false, 426 | "notNull": false, 427 | "autoincrement": false 428 | }, 429 | "email": { 430 | "name": "email", 431 | "type": "text", 432 | "primaryKey": false, 433 | "notNull": true, 434 | "autoincrement": false 435 | }, 436 | "avatar_key": { 437 | "name": "avatar_key", 438 | "type": "text(24)", 439 | "primaryKey": false, 440 | "notNull": false, 441 | "autoincrement": false 442 | }, 443 | "role": { 444 | "name": "role", 445 | "type": "text", 446 | "primaryKey": false, 447 | "notNull": false, 448 | "autoincrement": false, 449 | "default": "'user'" 450 | }, 451 | "email_verification_token": { 452 | "name": "email_verification_token", 453 | "type": "text(24)", 454 | "primaryKey": false, 455 | "notNull": true, 456 | "autoincrement": false 457 | } 458 | }, 459 | "indexes": { 460 | "users_id_unique": { 461 | "name": "users_id_unique", 462 | "columns": ["id"], 463 | "isUnique": true 464 | }, 465 | "users_email_unique": { 466 | "name": "users_email_unique", 467 | "columns": ["email"], 468 | "isUnique": true 469 | }, 470 | "users_email_verification_token_unique": { 471 | "name": "users_email_verification_token_unique", 472 | "columns": ["email_verification_token"], 473 | "isUnique": true 474 | }, 475 | "users_email_idx": { 476 | "name": "users_email_idx", 477 | "columns": ["email"], 478 | "isUnique": false 479 | } 480 | }, 481 | "foreignKeys": {}, 482 | "compositePrimaryKeys": {}, 483 | "uniqueConstraints": {}, 484 | "checkConstraints": {} 485 | } 486 | }, 487 | "views": {}, 488 | "enums": {}, 489 | "_meta": { 490 | "schemas": {}, 491 | "tables": {}, 492 | "columns": {} 493 | }, 494 | "internal": { 495 | "indexes": {} 496 | } 497 | } 498 | --------------------------------------------------------------------------------