Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
coverage
data
dist
node_modules
src/database/data
src/mailpit/data
.DS_Store
.env
*.http
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
│ └── types/
│ └── index.d.ts # Shared TypeScript types (Item, User, etc.)
├── tests/
│ └── contracts.ts # API contract definitions — declarative source of truth
│ └── contracts # API contract definitions — declarative source of truth
└── biome.json # Lint + format config
```

Expand Down
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ services:
restart: unless-stopped
env_file: ./.env
volumes:
- ./src/mailpit/data:/data
- ./data/mailpit:/data
ports:
- ${MP_UI_PORT-8025}:8025
- ${MP_SMTP_PORT-1025}:1025
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
"build": "npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --outDir dist/server --ssr src/entry-server",
"database:schema:load": "tsx --env-file=.env bin/database-sync schema",
"database:seeder:load": "tsx --env-file=.env bin/database-sync seeder",
"database:sync": "tsx --env-file=.env bin/database-sync both",
"database:schema:load": "tsx --env-file=.env scripts/database-sync schema",
"database:seeder:load": "tsx --env-file=.env scripts/database-sync seeder",
"database:sync": "tsx --env-file=.env scripts/database-sync both",
"dev": "tsx watch --include .env --env-file=.env server",
"install:check": "vitest run tests/install",
"make:clone": "tsx --env-file=.env bin/make-clone",
"make:purge": "tsx --env-file=.env bin/make-purge",
"make:clone": "tsx --env-file=.env scripts/make-clone",
"make:purge": "tsx --env-file=.env scripts/make-purge",
"start": "tsx --env-file=.env server",
"test": "vitest run --coverage --exclude tests/install",
"types:check": "tsc --noEmit"
Expand Down
File renamed without changes.
File renamed without changes.
8 changes: 4 additions & 4 deletions bin/make-purge.ts → scripts/make-purge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ async function purgeAuth(rootDir: string) {
await updateFile(rootDir, "src/react/routes.tsx", (content) =>
content
// Remove auth-related imports
.replace(`import LogoutForm from "./components/auth/LogoutForm";\n`, "")
.replace(`import AccountPage from "./components/auth/AccountPage";\n`, "")
.replace(`import VerifyPage from "./components/auth/VerifyPage";\n`, "")
.replace(
`import { AuthProvider } from "./components/auth/AuthContext";\n`,
Expand All @@ -151,9 +151,9 @@ async function purgeAuth(rootDir: string) {
)
// Remove the loader
.replace(/ {4}\/\*\n {6}Root loader:[\s\S]*?\n {4}\},\n/m, "")
// Remove logout and verify routes
// Remove account and verify routes
.replace(
/ {6}\{\n {8}path: "logout",\n {8}element: <LogoutForm \/>,\n {6}\},\n/m,
/ {6}\{\n {8}path: "account",\n {8}element: <AccountPage \/>,\n {6}\},\n/m,
"",
)
.replace(
Expand Down Expand Up @@ -187,7 +187,7 @@ async function purgeAuth(rootDir: string) {
content
.replace(`import { useAuth } from "./auth/AuthContext";\n\n`, "")
.replace(` const { check } = useAuth();\n`, "")
// After purgeItems, only the logout link remains in the auth block.
// After purgeItems, only the account link remains in the auth block.
// Remove the whole conditional block.
.replace(/ {8}\{check\(\) && \(\n[\s\S]*?\n {8}\)}\n/m, ""),
);
Expand Down
5 changes: 4 additions & 1 deletion src/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import fs from "fs-extra";

const dbPath = path.join(import.meta.dirname, "data/database.sqlite");
const dbPath = path.join(
import.meta.dirname,
"../../data/sqlite/database.sqlite",
);

// Ensure the parent directory exists
await fs.ensureDir(path.dirname(dbPath));
Expand Down
20 changes: 11 additions & 9 deletions src/entry-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,19 @@ export const render = async (template: string, req: Request, res: Response) => {
*/
const leaf = context.matches[context.matches.length - 1];

const actionHeaders = context.actionHeaders[leaf.route.id];
if (actionHeaders) {
for (const [key, value] of actionHeaders.entries()) {
res.set(key, value);
if (leaf) {
const actionHeaders = context.actionHeaders[leaf.route.id];
if (actionHeaders) {
for (const [key, value] of actionHeaders.entries()) {
res.set(key, value);
}
}
}

const loaderHeaders = context.loaderHeaders[leaf.route.id];
if (loaderHeaders) {
for (const [key, value] of loaderHeaders.entries()) {
res.set(key, value);
const loaderHeaders = context.loaderHeaders[leaf.route.id];
if (loaderHeaders) {
for (const [key, value] of loaderHeaders.entries()) {
res.set(key, value);
}
}
}

Expand Down
14 changes: 0 additions & 14 deletions src/errors/HttpError.ts

This file was deleted.

13 changes: 0 additions & 13 deletions src/express/modules/auth/authActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,18 +223,6 @@ const destroyAccessToken: RequestHandler = (_req, res) => {
res.sendStatus(204);
};

/* ************************************************************************ */

/*
Return the currently authenticated user.

Preconditions:
- verifyAccessToken has run successfully
*/
const readMe: RequestHandler = (req, res) => {
res.json(req.me);
};

/* ************************************************************************ */
/* Middleware */
/* ************************************************************************ */
Expand Down Expand Up @@ -280,6 +268,5 @@ export default {
sendMagicLink,
verifyMagicLink,
destroyAccessToken,
readMe,
verifyAccessToken,
};
6 changes: 0 additions & 6 deletions src/express/modules/auth/authRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,6 @@ router.post("/api/auth/magic-link", authActions.sendMagicLink);
router.post("/api/auth/verify", authActions.verifyMagicLink);
router.post("/api/auth/logout", authActions.destroyAccessToken);

/* ************************************************************************ */
/* Authenticated routes */
/* ************************************************************************ */

router.get("/api/me", authActions.verifyAccessToken, authActions.readMe);

/* ************************************************************************ */
/* Export */
/* ************************************************************************ */
Expand Down
51 changes: 13 additions & 38 deletions src/express/modules/user/userActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,70 +28,46 @@ import userRepository from "./userRepository";
/* ************************************************************************ */

/*
Browse all users.
Return the currently authenticated user.

Preconditions:
- None (public endpoint)

Response:
- 200 with an array of users
*/
const browse: RequestHandler = (req, res) => {
const offset = Number(req.query.start ?? "0");

const users = userRepository.findAll(10, offset);

res.json(users);
};

/* ************************************************************************ */

/*
Read a single user.

Preconditions:
- `req.user` has been injected by the param converter

Response:
- 200 with the user payload
- verifyAccessToken has run successfully
*/
const read: RequestHandler = (req, res) => {
res.json(req.user);
const readMe: RequestHandler = (req, res) => {
res.json(req.me);
};

/* ************************************************************************ */

/*
Edit an existing user.
Edit the currently authenticated user.

Preconditions:
- User is authenticated
- User is authorized to access this user
- req.body has been validated and sanitized

Response:
- 204 No Content on success
*/
const edit: RequestHandler = (req, res) => {
userRepository.update(req.user.id, req.body);
const editMe: RequestHandler = (req, res) => {
userRepository.update(req.me.id, req.body);

res.sendStatus(204);
};

/* ************************************************************************ */

/*
Soft-delete a user.
Soft-delete the currently authenticated user.

Preconditions:
- User is authenticated
- User is authorized to access this user

Response:
- 204 No Content
*/
const destroy: RequestHandler = (req, res) => {
userRepository.softDelete(req.user.id);
const destroyMe: RequestHandler = (req, res) => {
userRepository.softDelete(req.me.id);

res.sendStatus(204);
};
Expand All @@ -101,8 +77,7 @@ const destroy: RequestHandler = (req, res) => {
/* ************************************************************************ */

export default {
browse,
read,
edit,
destroy,
readMe,
editMe,
destroyMe,
};
56 changes: 5 additions & 51 deletions src/express/modules/user/userRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,25 +78,6 @@ class UserRepository {
return { id: Number(id), email: String(email), name: String(name) };
}

/*
Find all non-deleted users.

Notes:
- Meant to be composed or extended if needed
*/
findAll(limit: number, offset: number): User[] {
const query = database.prepare(
"select id, email, name from user where deleted_at is null limit ? offset ?",
);
const rows = query.all(limit, offset);

return rows.map<User>(({ id, email, name }) => ({
id: Number(id),
email: String(email),
name: String(name),
}));
}

/*
Find a single user by email.

Expand Down Expand Up @@ -133,20 +114,18 @@ class UserRepository {
Why null instead of throwing:
- Allows upper layers to decide HTTP semantics (404, 204, etc.)
*/
findOrCreateByEmail(email: string, name?: string): User {
findOrCreateByEmail(email: string): User {
const user = this.findByEmail(email);
if (user) return user;

const name = email.split("@")[0];

const id = this.create({
email,
name: name ?? email.split("@")[0],
name,
});

return {
id: Number(id),
email: String(email),
name: String(name ?? email.split("@")[0]),
};
return { id, email, name };
}

/* ********************************************************************** */
Expand Down Expand Up @@ -191,31 +170,6 @@ class UserRepository {

return result.changes > 0;
}

/*
Restore a soft-deleted user.
*/
softUndelete(id: RowId): boolean {
const query = database.prepare(
"update user set deleted_at = null where id = ?",
);
const result = query.run(id);

return result.changes > 0;
}

/*
Hard delete a user.

Warning:
- This permanently removes the row
*/
hardDelete(id: RowId): boolean {
const query = database.prepare("delete from user where id = ?");
const result = query.run(id);

return result.changes > 0;
}
}

/* ************************************************************************ */
Expand Down
Loading
Loading