added technical api structure

This commit is contained in:
rykuno 2024-05-25 01:02:26 -05:00
commit 1d955ed438
66 changed files with 6770 additions and 0 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
ORIGIN=http://localhost:5173
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres

13
.eslintignore Normal file
View file

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

31
.eslintrc.cjs Normal file
View file

@ -0,0 +1,31 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

8
.prettierrc Normal file
View file

@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

23
docker-compose.yaml Normal file
View file

@ -0,0 +1,23 @@
version: "3.8"
services:
postgres:
image: postgres:latest
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:latest
ports:
- "6379:6379"
volumes:
- redis_data:/data
volumes:
postgres_data:
redis_data:

16
drizzle.config.ts Normal file
View file

@ -0,0 +1,16 @@
import type { Config } from 'drizzle-kit';
export default {
out: './src/lib/server/api/infrastructure/database/migrations',
schema: './src/lib/server/api/infrastructure/database/tables/*.table.ts',
breakpoints: false,
strict: true,
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!
},
migrations: {
table: 'migrations',
schema: 'public'
}
} satisfies Config;

70
package.json Normal file
View file

@ -0,0 +1,70 @@
{
"name": "taro-stack",
"version": "0.0.1",
"private": true,
"scripts": {
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio --verbose",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:integration": "playwright test",
"test:unit": "vitest"
},
"devDependencies": {
"@hono/zod-validator": "^0.2.1",
"@lucia-auth/adapter-drizzle": "^1.0.7",
"@paralleldrive/cuid2": "^2.2.2",
"@playwright/test": "^1.28.1",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/kit": "^2.5.7",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^8.56.0",
"@types/node": "^20.12.7",
"@types/nodemailer": "^6.4.15",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"arctic": "^1.9.0",
"dayjs": "^1.11.11",
"dotenv-cli": "^7.4.1",
"drizzle-kit": "^0.21.4",
"drizzle-orm": "^0.30.10",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0-next.4",
"handlebars": "^4.7.8",
"hono": "^4.2.8",
"lucia": "^3.2.0",
"nanoid": "^5.0.7",
"nodemailer": "^6.9.13",
"pg": "^8.11.5",
"postgres": "^3.4.4",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"reflect-metadata": "^0.2.2",
"svelte": "^5.0.0-next.1",
"svelte-check": "^3.6.0",
"svelte-eslint-parser": "^0.36.0",
"tslib": "^2.4.1",
"tsx": "^4.7.3",
"tsyringe": "^4.8.0",
"typescript": "^5.0.0",
"vite": "^5.2.11",
"vite-plugin-full-reload": "^1.1.0",
"vite-plugin-restart": "^0.4.0",
"vitest": "^1.2.0",
"zod": "^3.23.4"
},
"type": "module",
"dependencies": {
"resend": "^3.2.0"
}
}

12
playwright.config.ts Normal file
View file

@ -0,0 +1,12 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/
};
export default config;

5000
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

20
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
import { ApiClient } from '$lib/server/api';
import type { User } from 'lucia';
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
api: ApiClient['api'];
getAuthedUser: () => Promise<User | null>;
getAuthedUserOrThrow: () => Promise<User>;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

35
src/hooks.server.ts Normal file
View file

@ -0,0 +1,35 @@
import { hc, type ClientResponse } from 'hono/client';
import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import type { ApiRoutes } from '$lib/server/api';
import { parseApiResponse } from '$lib/helpers';
const apiClient: Handle = async ({ event, resolve }) => {
const { api } = hc<ApiRoutes>('/', {
fetch: event.fetch,
headers: {
'x-forwarded-for': event.getClientAddress()
}
});
async function getAuthedUser() {
const { data } = await parseApiResponse(api.iam.user.$get());
return data && data.user;
}
async function getAuthedUserOrThrow() {
const { data } = await parseApiResponse(api.iam.user.$get());
if (!data || !data.user) throw redirect(307, '/');
return data?.user;
}
// set contexts
event.locals.api = api;
event.locals.getAuthedUser = getAuthedUser;
event.locals.getAuthedUserOrThrow = getAuthedUserOrThrow;
const response = await resolve(event);
return response;
};
export const handle = sequence(apiClient);

7
src/index.test.ts Normal file
View file

@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

35
src/lib/helpers/index.ts Normal file
View file

@ -0,0 +1,35 @@
import type { ClientResponse } from 'hono/client';
export async function parseApiResponse<T>(fetchCall: Promise<ClientResponse<T>>) {
const response = await fetchCall;
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
return response.ok
? { data: null, error: null, response }
: { data: null, error: 'An unknown error has occured', response };
}
if (response.ok) {
const data = await response.json()!;
return { data, error: null, status: response.status };
}
// handle errors
let error = await response.text();
try {
error = JSON.parse(error); // attempt to parse as JSON
} catch {
// noop
}
return { data: null, error, response };
}
export function getTimezoneAbbr(timezone: string) {
return new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short'
})
.format(new Date())
.split(' ')[1];
}

View file

@ -0,0 +1,15 @@
import {
DATABASE_URL,
DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET,
ORIGIN,
RESEND_API_KEY
} from '$env/static/private';
export const config = {
DATABASE_URL,
ORIGIN,
RESEND_API_KEY,
DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET
};

View file

@ -0,0 +1,21 @@
import { HTTPException } from 'hono/http-exception';
export function Forbidden(message: string = 'Forbidden') {
return new HTTPException(403, { message });
}
export function Unauthorized(message: string = 'Unauthorized') {
return new HTTPException(401, { message });
}
export function NotFound(message: string = 'Not Found') {
return new HTTPException(404, { message });
}
export function BadRequest(message: string = 'Bad Request') {
return new HTTPException(400, { message });
}
export function InternalError(message: string = 'Internal Error') {
return new HTTPException(500, { message });
}

View file

@ -0,0 +1,47 @@
import { Lucia } from 'lucia';
import { DrizzlePostgreSQLAdapter } from '@lucia-auth/adapter-drizzle';
import { Discord } from 'arctic';
import { config } from './config';
import { sessionsTable } from '../infrastructure/database/tables/sessions.table';
import { db } from '../infrastructure/database';
import { usersTable } from '../infrastructure/database/tables/users.table';
const adapter = new DrizzlePostgreSQLAdapter(db, sessionsTable, usersTable);
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
// set to `true` when using HTTPS
secure: process.env.NODE_ENV === 'production'
}
},
getUserAttributes: (attributes) => {
return {
// attributes has the type of DatabaseUserAttributes
id: attributes.id,
avatar: attributes.avatar,
email: attributes.email,
verified: attributes.verified
};
}
});
export const discord = new Discord(
config.DISCORD_CLIENT_ID!,
config.DISCORD_CLIENT_SECRET!,
'http://localhost:5173/api/iam/discord/callback'
);
interface DatabaseUserAttributes {
id: string;
email: string;
avatar: string | null;
verified: boolean;
}
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}

View file

@ -0,0 +1,101 @@
import { inject, injectable } from 'tsyringe';
import { ControllerProvider } from '../providers';
import { zValidator } from '@hono/zod-validator';
import { registerEmailDto } from '../dtos/register-email.dto';
import { IamService } from '../services/iam.service';
import { signInEmailDto } from '../dtos/signin-email.dto';
import { setCookie } from 'hono/cookie';
import { LuciaProvider } from '../providers/lucia.provider';
import { requireAuth } from '../middleware/require-auth.middleware';
import { updateEmailDto } from '../dtos/update-email.dto';
import { verifyEmailDto } from '../dtos/verify-email.dto';
@injectable()
export class IamController {
constructor(
@inject(ControllerProvider) private controller: ControllerProvider,
@inject(IamService) private iamService: IamService,
@inject(LuciaProvider) private lucia: LuciaProvider
) {}
getAuthedUser() {
return this.controller.get('/user', async (c) => {
const user = c.var.user;
return c.json({ user: user });
});
}
registerEmail() {
return this.controller.post(
'/email/register',
zValidator('json', registerEmailDto),
async (c) => {
const { email } = c.req.valid('json');
await this.iamService.registerEmail({ email });
return c.json({ message: 'Verification email sent' });
}
);
}
signInEmail() {
return this.controller.post('/email/signin', zValidator('json', signInEmailDto), async (c) => {
const { email, token } = c.req.valid('json');
const session = await this.iamService.signinEmail({ email, token });
const sessionCookie = this.lucia.createSessionCookie(session.id);
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
maxAge: sessionCookie.attributes.maxAge,
domain: sessionCookie.attributes.domain,
sameSite: sessionCookie.attributes.sameSite as any,
secure: sessionCookie.attributes.secure,
httpOnly: sessionCookie.attributes.httpOnly,
expires: sessionCookie.attributes.expires
});
return c.json({ message: 'ok' });
});
}
logout() {
return this.controller.post('/logout', requireAuth, async (c) => {
const sessionId = c.var.session.id;
await this.iamService.logout(sessionId);
const sessionCookie = this.lucia.createBlankSessionCookie();
setCookie(c, sessionCookie.name, sessionCookie.value, {
path: sessionCookie.attributes.path,
maxAge: sessionCookie.attributes.maxAge,
domain: sessionCookie.attributes.domain,
sameSite: sessionCookie.attributes.sameSite as any,
secure: sessionCookie.attributes.secure,
httpOnly: sessionCookie.attributes.httpOnly,
expires: sessionCookie.attributes.expires
});
return c.json({ status: 'success' });
});
}
updateEmail() {
return this.controller.post(
'/email/update',
requireAuth,
zValidator('json', updateEmailDto),
async (c) => {
const json = c.req.valid('json');
await this.iamService.updateEmail(c.var.user.id, json);
return c.json({ message: 'Verification email sent' });
}
);
}
verifyEmail() {
return this.controller.post(
'/email/verify',
requireAuth,
zValidator('json', verifyEmailDto),
async (c) => {
const json = c.req.valid('json');
await this.iamService.verifyEmail(c.var.user.id, json.token);
return c.json({ message: 'Verified and updated' });
}
);
}
}

View file

@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
CREATE TABLE IF NOT EXISTS "tokens" (
"id" text PRIMARY KEY NOT NULL,
"token" text NOT NULL,
"user_id" text NOT NULL,
"email" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tokens_token_unique" UNIQUE("token")
);
CREATE TABLE IF NOT EXISTS "users" (
"id" text PRIMARY KEY NOT NULL,
"avatar" text,
"email" "citext" NOT NULL,
"verified" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
DO $$ BEGIN
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "tokens" ADD CONSTRAINT "tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,190 @@
{
"id": "d887b9c2-b8ca-406b-a093-f6827dc6c256",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "6",
"dialect": "postgresql",
"tables": {
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.tokens": {
"name": "tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"tokens_user_id_users_id_fk": {
"name": "tokens_user_id_users_id_fk",
"tableFrom": "tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tokens_token_unique": {
"name": "tokens_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
}
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "citext",
"primaryKey": false,
"notNull": true
},
"verified": {
"name": "verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
}
}
},
"enums": {},
"schemas": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "6",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1716599292711,
"tag": "0000_abnormal_earthquake",
"breakpoints": false
}
]
}

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
export const registerEmailDto = z.object({
email: z.string().email()
});
export type RegisterEmailDto = z.infer<typeof registerEmailDto>;

View file

@ -0,0 +1,8 @@
import { z } from 'zod';
export const signInEmailDto = z.object({
email: z.string().email(),
token: z.string()
});
export type SignInEmailDto = z.infer<typeof signInEmailDto>;

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
export const updateEmailDto = z.object({
email: z.string()
});
export type UpdateEmailDto = z.infer<typeof updateEmailDto>;

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
export const verifyEmailDto = z.object({
token: z.string()
});
export type VerifyEmailDto = z.infer<typeof verifyEmailDto>;

View file

@ -0,0 +1,26 @@
<html lang='en'>
<head>
<meta http-equiv='X-UA-Compatible' content='IE=edge' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>Message</title>
</head>
<body>
<p class='title'>Verify your email address</p>
<p>
Thanks for using example.com. We want to make sure it's really you. Please enter the following
verification code when prompted. If you don't have an exmaple.com an account, you can ignore
this message.</p>
{{!-- <p>{{token}}</p> --}}
<div class='center'>
<p class="token-title">Verification Code</p>
<p class='token-text'>{{token}}</p>
<p class='token-subtext'>(This code is valid for 15 minutes)</p>
</div>
</body>
<style>
.title { font-size: 24px; font-weight: 700; } .token-text { font-size: 24px; font-weight: 700; margin-top: 8px; }
.token-title { font-size: 18px; font-weight: 700; margin-bottom: 0px; }
.center { display: flex; justify-content: center; align-items: center; flex-direction: column;}
.token-subtext { font-size: 12px; margin-top: 0px; }
</style>
</html>

View file

@ -0,0 +1,34 @@
import 'reflect-metadata';
import './providers';
import { Hono } from 'hono';
import { hc } from 'hono/client';
import { container } from 'tsyringe';
import { processAuth } from './middleware/process-auth.middleware';
import { IamController } from './controllers/iam.controller';
/* -------------------------------------------------------------------------- */
/* API */
/* -------------------------------------------------------------------------- */
const app = new Hono().basePath('/api');
/* -------------------------------------------------------------------------- */
/* Global Middlewares */
/* -------------------------------------------------------------------------- */
app.use(processAuth); // all this does is set the session and user variables in the request object
/* -------------------------------------------------------------------------- */
/* Routes */
/* -------------------------------------------------------------------------- */
const routes = app
.route('/iam', container.resolve(IamController).registerEmail())
.route('/iam', container.resolve(IamController).signInEmail())
.route('/iam', container.resolve(IamController).getAuthedUser())
.route('/iam', container.resolve(IamController).updateEmail())
.route('/iam', container.resolve(IamController).verifyEmail());
/* -------------------------------------------------------------------------- */
/* Exports */
/* -------------------------------------------------------------------------- */
export const rpc = hc<typeof routes>('http://localhost:5173');
export type ApiClient = typeof rpc;
export type ApiRoutes = typeof routes;
export { app };

View file

@ -0,0 +1,7 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { config } from '../../common/config';
import * as schema from './tables';
export const client = postgres(config.DATABASE_URL!, { max: 1 });
export const db = drizzle(client, { schema });

View file

@ -0,0 +1,38 @@
CREATE TABLE IF NOT EXISTS "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL
);
CREATE TABLE IF NOT EXISTS "tokens" (
"id" text PRIMARY KEY NOT NULL,
"token" text NOT NULL,
"user_id" text NOT NULL,
"email" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tokens_token_unique" UNIQUE("token")
);
CREATE TABLE IF NOT EXISTS "users" (
"id" text PRIMARY KEY NOT NULL,
"avatar" text,
"email" "citext" NOT NULL,
"verified" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
DO $$ BEGIN
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
ALTER TABLE "tokens" ADD CONSTRAINT "tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,190 @@
{
"id": "8bb6c6c1-e68f-4b94-a390-b51666d00dbb",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "6",
"dialect": "postgresql",
"tables": {
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"public.tokens": {
"name": "tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"tokens_user_id_users_id_fk": {
"name": "tokens_user_id_users_id_fk",
"tableFrom": "tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tokens_token_unique": {
"name": "tokens_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
}
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"avatar": {
"name": "avatar",
"type": "text",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "citext",
"primaryKey": false,
"notNull": true
},
"verified": {
"name": "verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
}
}
},
"enums": {},
"schemas": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "6",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1716599372513,
"tag": "0000_first_crystal",
"breakpoints": false
}
]
}

View file

@ -0,0 +1,3 @@
export * from './sessions.table';
export * from './users.table';
export * from './tokens.table';

View file

@ -0,0 +1,14 @@
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { usersTable } from './users.table';
import { cuid2 } from '../utils';
export const sessionsTable = pgTable('sessions', {
id: cuid2('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => usersTable.id),
expiresAt: timestamp('expires_at', {
withTimezone: true,
mode: 'date'
}).notNull()
});

View file

@ -0,0 +1,25 @@
import { createId } from '@paralleldrive/cuid2';
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { timestamps } from '../utils';
import { relations } from 'drizzle-orm';
import { usersTable } from './users.table';
export const tokensTable = pgTable('tokens', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
token: text('token').notNull().unique(),
userId: text('user_id')
.notNull()
.references(() => usersTable.id),
email: text('email').notNull(),
expiresAt: timestamp('expires_at', {
mode: 'date',
withTimezone: true
}).notNull(),
...timestamps
});
export const tokensRealations = relations(tokensTable, ({ one }) => ({
user: one(usersTable)
}));

View file

@ -0,0 +1,21 @@
import cuid2, { createId } from '@paralleldrive/cuid2';
import { boolean, pgTable, text } from 'drizzle-orm/pg-core';
import { citext, timestamps } from '../utils';
import { relations } from 'drizzle-orm';
import { sessionsTable } from './sessions.table';
import { tokensTable } from './tokens.table';
export const usersTable = pgTable('users', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
avatar: text('avatar'),
email: citext('email').notNull().unique(),
verified: boolean('verified').notNull().default(false),
...timestamps
});
export const usersRelations = relations(usersTable, ({ many }) => ({
sessions: many(sessionsTable),
tokens: many(tokensTable)
}));

View file

@ -0,0 +1,43 @@
import { HTTPException } from 'hono/http-exception';
import { timestamp } from 'drizzle-orm/pg-core';
import { customType } from 'drizzle-orm/pg-core';
export const citext = customType<{ data: string }>({
dataType() {
return 'citext';
}
});
export const cuid2 = customType<{ data: string }>({
dataType() {
return 'text';
}
});
export const takeFirst = <T>(values: T[]): T | null => {
if (values.length === 0) return null;
return values[0]!;
};
export const takeFirstOrThrow = <T>(values: T[]): T => {
if (values.length === 0)
throw new HTTPException(404, {
message: 'Resource not found'
});
return values[0]!;
};
export const timestamps = {
createdAt: timestamp('created_at', {
mode: 'date',
withTimezone: true
})
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', {
mode: 'date',
withTimezone: true
})
.notNull()
.defaultNow()
};

View file

@ -0,0 +1,3 @@
interface INotifier {
sendEmail(): any;
}

View file

@ -0,0 +1,5 @@
import type { DatabaseProvider } from '../providers';
export interface Repository {
trxHost(trx: DatabaseProvider): any;
}

View file

@ -0,0 +1,46 @@
import type { MiddlewareHandler } from 'hono';
import { createMiddleware } from 'hono/factory';
import { getCookie, setCookie } from 'hono/cookie';
import type { HonoTypes } from '../types';
import { lucia } from '../common/lucia';
export const processAuth: MiddlewareHandler<HonoTypes> = createMiddleware(async (c, next) => {
const sessionId = getCookie(c, 'auth_session');
if (!sessionId) {
c.set('session', null);
c.set('user', null);
return next();
}
const { session, user } = await lucia.validateSession(sessionId);
if (session && session.fresh) {
const cookie = lucia.createSessionCookie(session.id);
setCookie(c, cookie.name, cookie.value, {
path: cookie.attributes.path,
maxAge: cookie.attributes.maxAge,
domain: cookie.attributes.domain,
sameSite: cookie.attributes.sameSite as any,
secure: cookie.attributes.secure,
httpOnly: cookie.attributes.httpOnly,
expires: cookie.attributes.expires
});
}
if (!session) {
const cookie = lucia.createBlankSessionCookie();
setCookie(c, cookie.name, cookie.value, {
path: cookie.attributes.path,
maxAge: cookie.attributes.maxAge,
domain: cookie.attributes.domain,
sameSite: cookie.attributes.sameSite as any,
secure: cookie.attributes.secure,
httpOnly: cookie.attributes.httpOnly,
expires: cookie.attributes.expires
});
}
c.set('session', session);
c.set('user', user);
return next();
});

View file

@ -0,0 +1,15 @@
import type { MiddlewareHandler } from 'hono';
import { createMiddleware } from 'hono/factory';
import type { Session, User } from 'lucia';
import { Unauthorized } from '../common/errors';
export const requireAuth: MiddlewareHandler<{
Variables: {
session: Session;
user: User;
};
}> = createMiddleware(async (c, next) => {
const user = c.var.user;
if (!user) throw Unauthorized('You must be logged in to access this resource');
return next();
});

View file

@ -0,0 +1,15 @@
import { container } from 'tsyringe';
import { Hono } from 'hono';
import type { HonoTypes } from '../types';
// Symbol
export const ControllerProvider = Symbol('CONTROLLER_PROVIDER');
// Type
export type ControllerProvider = typeof controller;
// Value
const controller = new Hono<HonoTypes>();
// Register
container.register<ControllerProvider>(ControllerProvider, { useValue: controller });

View file

@ -0,0 +1,11 @@
import { container } from 'tsyringe';
import { db } from '../infrastructure/database';
// Symbol
export const DatabaseProvider = Symbol('DATABASE_TOKEN');
// Type
export type DatabaseProvider = typeof db;
// Register
container.register<DatabaseProvider>(DatabaseProvider, { useValue: db });

View file

@ -0,0 +1,2 @@
export * from './database.provider';
export * from './controller.provider'

View file

@ -0,0 +1,11 @@
import { container } from 'tsyringe';
import { lucia } from '../common/lucia';
// Symbol
export const LuciaProvider = Symbol('LUCIA_PROVIDER');
// Type
export type LuciaProvider = typeof lucia;
// Register
container.register<LuciaProvider>(LuciaProvider, { useValue: lucia });

View file

@ -0,0 +1,39 @@
import { inject, injectable } from 'tsyringe';
import { DatabaseProvider } from '../providers';
import { eq, type InferInsertModel } from 'drizzle-orm';
import { tokensTable } from '../infrastructure/database/tables';
import { takeFirstOrThrow } from '../infrastructure/database/utils';
import type { Repository } from '../interfaces/repository.interface';
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export type InsertToken = InferInsertModel<typeof tokensTable>;
/* -------------------------------------------------------------------------- */
/* Repository */
/* -------------------------------------------------------------------------- */
@injectable()
export class TokensRepository implements Repository {
constructor(@inject(DatabaseProvider) private db: DatabaseProvider) {}
async findOneByToken(token: string) {
return this.db.query.tokensTable.findFirst({ where: eq(tokensTable.token, token) });
}
async delete(id: string) {
return this.db
.delete(tokensTable)
.where(eq(tokensTable.id, id))
.returning()
.then(takeFirstOrThrow);
}
async create(data: InsertToken) {
return this.db.insert(tokensTable).values(data).returning().then(takeFirstOrThrow);
}
trxHost(trx: DatabaseProvider) {
return new TokensRepository(trx);
}
}

View file

@ -0,0 +1,49 @@
import { inject, injectable } from 'tsyringe';
import type { Repository } from '../interfaces/repository.interface';
import { DatabaseProvider, type DatabaseProvider } from '../providers';
import { eq, type InferInsertModel } from 'drizzle-orm';
import { usersTable } from '../infrastructure/database/tables/users.table';
import { takeFirstOrThrow } from '../infrastructure/database/utils';
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export type CreateUser = Pick<InferInsertModel<typeof usersTable>, 'avatar' | 'email' | 'verified'>;
export type UpdateUser = Partial<CreateUser>;
/* -------------------------------------------------------------------------- */
/* Repository */
/* -------------------------------------------------------------------------- */
@injectable()
export class UsersRepository implements Repository {
constructor(@inject(DatabaseProvider) private db: DatabaseProvider) {}
async findOneById(id: string) {
return this.db.query.usersTable.findFirst({
where: eq(usersTable.id, id)
});
}
async findOneByEmail(email: string) {
return this.db.query.usersTable.findFirst({
where: eq(usersTable.email, email)
});
}
async create(data: CreateUser) {
return this.db.insert(usersTable).values(data).returning().then(takeFirstOrThrow);
}
async update(id: string, data: UpdateUser) {
return this.db
.update(usersTable)
.set(data)
.where(eq(usersTable.id, id))
.returning()
.then(takeFirstOrThrow);
}
trxHost(trx: DatabaseProvider) {
return new UsersRepository(trx);
}
}

View file

@ -0,0 +1,94 @@
import { inject, injectable } from 'tsyringe';
import type { RegisterEmailDto } from '../dtos/register-email.dto';
import { UsersRepository } from '../repositories/users.repository';
import { MailerService } from './mailer.service';
import { TokensService } from './tokens.service';
import type { SignInEmailDto } from '../dtos/signin-email.dto';
import { BadRequest } from '../common/errors';
import { LuciaProvider } from '../providers/lucia.provider';
import type { UpdateEmailDto } from '../dtos/update-email.dto';
import type { VerifyEmailDto } from '../dtos/verify-email.dto';
@injectable()
export class IamService {
constructor(
@inject(UsersRepository) private usersRepository: UsersRepository,
@inject(TokensService) private tokensService: TokensService,
@inject(MailerService) private mailerService: MailerService,
@inject(LuciaProvider) private lucia: LuciaProvider
) {}
async registerEmail(data: RegisterEmailDto) {
const existingUser = await this.usersRepository.findOneByEmail(data.email);
if (!existingUser) {
const newUser = await this.usersRepository.create({ email: data.email, verified: false });
return this.createValidationReuqest(newUser.id, newUser.email);
}
return this.createValidationReuqest(existingUser.id, existingUser.email);
}
async signinEmail(data: SignInEmailDto) {
const user = await this.usersRepository.findOneByEmail(data.email);
if (!user) {
throw BadRequest('Bad credentials');
}
const isValidToken = await this.tokensService.validateToken(user.id, data.token);
if (!isValidToken) {
throw BadRequest('Bad credentials');
}
// if this is a new unverified user, send a welcome email and update the user
if (!user.verified) {
await this.usersRepository.update(user.id, { verified: true });
await this.mailerService.send({
to: user.email,
subject: 'Welcome!',
html: 'Welcome to example.com'
});
}
return this.lucia.createSession(user.id, {});
}
async verifyEmail(userId: string, token: string) {
const user = await this.usersRepository.findOneById(userId);
if (!user) {
throw BadRequest('User not found');
}
const validToken = await this.tokensService.validateToken(user.id, token);
if (!validToken) {
throw BadRequest('Invalid token');
}
await this.usersRepository.update(user.id, { email: validToken.email });
}
async updateEmail(userId: string, data: UpdateEmailDto) {
return this.createValidationReuqest(userId, data.email);
}
async logout(sessionId: string) {
return this.lucia.invalidateSession(sessionId);
}
private async createValidationReuqest(userId: string, email: string) {
const validationToken = await this.tokensService.create(userId, email);
await this.mailerService.sendEmailVerification({
to: email,
props: { token: validationToken.token }
});
// return await this.mailerService.send({
// to: email,
// subject: 'Verify your email',
// html: `Your token is ${validationToken.token}`
// });
}
}

View file

@ -0,0 +1,60 @@
import nodemailer from 'nodemailer';
import { injectable } from 'tsyringe';
import handlebars from 'handlebars';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
type SendMail = {
to: string | string[];
subject: string;
html: string;
};
type SendTemplate<T> = {
to: string | string[];
props: T;
};
@injectable()
export class MailerService {
private nodemailer = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false, // Use `true` for port 465, `false` for all other ports
auth: {
user: 'adella.hoppe@ethereal.email',
pass: 'dshNQZYhATsdJ3ENke'
}
});
sendEmailVerification(data: SendTemplate<{ token: string }>) {
const template = handlebars.compile(this.getTemplate('email-verification'));
return this.send({
to: data.to,
subject: 'Email Verification',
html: template({ token: data.props.token })
});
}
private async send({ to, subject, html }: SendMail) {
const message = await this.nodemailer.sendMail({
from: '"Example" <example@ethereal.email>', // sender address
bcc: to,
subject, // Subject line
text: html,
html
});
console.log(nodemailer.getTestMessageUrl(message));
}
private getTemplate(template: string) {
const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
const __dirname = path.dirname(__filename); // get the name of the directory
return fs.readFileSync(
path.join(__dirname, `../email-templates/${template}.handlebars`),
'utf-8'
);
}
}

View file

@ -0,0 +1,49 @@
import { inject, injectable } from 'tsyringe';
import { TokensRepository } from '../repositories/tokens.repository';
import dayjs from 'dayjs';
import { customAlphabet } from 'nanoid';
import { DatabaseProvider } from '../providers';
@injectable()
export class TokensService {
constructor(
@inject(TokensRepository) private tokensRepository: TokensRepository,
@inject(DatabaseProvider) private db: DatabaseProvider
) {}
async create(userId: string, email: string) {
return this.tokensRepository.create({
userId,
email,
token: this.generateToken(),
expiresAt: dayjs().add(15, 'minutes').toDate()
});
}
async validateToken(userId: string, token: string) {
const foundToken = await this.db.transaction(async (trx) => {
const foundToken = await this.tokensRepository.trxHost(trx).findOneByToken(token);
foundToken && (await this.tokensRepository.trxHost(trx).delete(foundToken.id));
return foundToken;
});
if (!foundToken) {
return false;
}
if (foundToken.userId !== userId) {
return false;
}
if (foundToken.expiresAt < new Date()) {
return false;
}
return foundToken;
}
private generateToken() {
const tokenAlphabet = '123456789ACDEFGHJKLMNPQRSTUVWXYZ'; // O and I removed for readability
return customAlphabet(tokenAlphabet, 6)();
}
}

View file

@ -0,0 +1,8 @@
import type { Session, User } from 'lucia';
export type HonoTypes = {
Variables: {
session: Session | null;
user: User | null;
};
};

View file

@ -0,0 +1,4 @@
export const load = async ({ locals }) => {
const user = await locals.getAuthedUser();
return { user: user };
};

6
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,6 @@
<script>
const { data } = $props();
</script>
<h1>Welcome {data?.user?.email}</h1>
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>

View file

@ -0,0 +1,9 @@
import { app } from '$lib/server/api';
import type { RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = ({ request }) => app.request(request);
export const PUT: RequestHandler = ({ request }) => app.request(request);
export const DELETE: RequestHandler = ({ request }) => app.fetch(request);
export const POST: RequestHandler = ({ request }) => app.fetch(request);
export const PATCH: RequestHandler = ({ request }) => app.fetch(request);
export const fallback: RequestHandler = ({ request }) => app.fetch(request);

View file

@ -0,0 +1,19 @@
import { redirect } from '@sveltejs/kit';
export const actions = {
register: async ({ locals, request }) => {
const data = await request.formData();
const email = data.get('email')?.toString()!;
await locals.api.iam.email.register.$post({ json: { email } });
redirect(301, `/register?verify=true&email=${email}`);
},
signin: async ({ locals, request }) => {
const data = await request.formData();
const email = data.get('email')?.toString()!;
const token = data.get('token')?.toString()!;
await locals.api.iam.email.signin.$post({ json: { email, token } });
redirect(301, '/');
}
};

View file

@ -0,0 +1,19 @@
<script>
import { enhance } from '$app/forms';
</script>
<h3>Register</h3>
<form action="?/register" method="POST" use:enhance>
<label for="email">Email</label>
<input name="email" type="email" />
<button type="submit">Register</button>
</form>
<h3>Verify</h3>
<form action="?/signin" method="POST" use:enhance>
<label for="email">Email</label>
<input name="email" type="email" />
<label for="token">Token</label>
<input name="token" type="text" />
<button type="submit">Login</button>
</form>

View file

@ -0,0 +1,22 @@
export const load = async ({ locals }) => {
const authedUser = await locals.getAuthedUserOrThrow();
return {
authedUser
};
};
export const actions = {
updateEmail: async ({ request, locals }) => {
const data = await request.formData();
const email = data.get('email')?.toString()!;
await locals.api.iam.email.update.$post({ json: { email } });
},
verifyEmail: async ({ request, locals }) => {
const data = await request.formData();
const token = data.get('token')?.toString()!;
await locals.api.iam.email.verify.$post({ json: { token } });
}
};

View file

@ -0,0 +1,19 @@
<script>
import { enhance } from '$app/forms';
let { data } = $props();
</script>
<h3>Change Email</h3>
<h5>Current Email: {data.authedUser.email}</h5>
<form method="POST" action="?/updateEmail" use:enhance>
<label for="email">Enter new email</label>
<input name="email" type="email" />
<button>Send Request</button>
</form>
<form method="POST" action="?/verifyEmail" use:enhance>
<label for="token">Verify Token</label>
<input name="token" />
<button>Verify</button>
</form>

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
svelte.config.js Normal file
View file

@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

6
tests/test.ts Normal file
View file

@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('index page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible();
});

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

15
vite.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
import ViteRestart from 'vite-plugin-restart';
export default defineConfig({
plugins: [
sveltekit(),
ViteRestart({
restart: ['./src/lib/server/api/**']
})
],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});