@raiyyan/brisk 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +135 -67
- package/README.md +467 -139
- package/dist/src/cli/agents.js +5 -5
- package/dist/src/cli/agents.js.map +1 -1
- package/dist/src/cli/cli.d.ts +1 -1
- package/dist/src/cli/cli.d.ts.map +1 -1
- package/dist/src/cli/cli.js +22 -36
- package/dist/src/cli/cli.js.map +1 -1
- package/dist/src/cli/init.d.ts +8 -11
- package/dist/src/cli/init.d.ts.map +1 -1
- package/dist/src/cli/init.js +120 -96
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/render.d.ts +30 -0
- package/dist/src/cli/render.d.ts.map +1 -0
- package/dist/src/cli/render.js +101 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/cli/system.d.ts +2 -2
- package/dist/src/cli/system.d.ts.map +1 -1
- package/dist/src/cli/system.js +3 -5
- package/dist/src/cli/system.js.map +1 -1
- package/dist/src/client/client.d.ts.map +1 -1
- package/dist/src/client/client.js +32 -5
- package/dist/src/client/client.js.map +1 -1
- package/dist/src/client/index.d.ts +1 -1
- package/dist/src/client/index.d.ts.map +1 -1
- package/dist/src/di/container.d.ts +10 -2
- package/dist/src/di/container.d.ts.map +1 -1
- package/dist/src/di/container.js +20 -3
- package/dist/src/di/container.js.map +1 -1
- package/dist/src/http/app.d.ts +41 -12
- package/dist/src/http/app.d.ts.map +1 -1
- package/dist/src/http/app.js.map +1 -1
- package/dist/src/http/context.d.ts +5 -5
- package/dist/src/http/context.js +5 -5
- package/dist/src/http/controller.d.ts +26 -9
- package/dist/src/http/controller.d.ts.map +1 -1
- package/dist/src/http/controller.js +25 -52
- package/dist/src/http/controller.js.map +1 -1
- package/dist/src/http/error-handler.d.ts +27 -0
- package/dist/src/http/error-handler.d.ts.map +1 -1
- package/dist/src/http/error-handler.js +26 -3
- package/dist/src/http/error-handler.js.map +1 -1
- package/dist/src/http/instance.d.ts +2 -2
- package/dist/src/http/instance.d.ts.map +1 -1
- package/dist/src/http/instance.js +107 -39
- package/dist/src/http/instance.js.map +1 -1
- package/dist/src/http/middleware.d.ts +104 -17
- package/dist/src/http/middleware.d.ts.map +1 -1
- package/dist/src/http/middleware.js +85 -26
- package/dist/src/http/middleware.js.map +1 -1
- package/dist/src/http/openapi.d.ts.map +1 -1
- package/dist/src/http/openapi.js +30 -2
- package/dist/src/http/openapi.js.map +1 -1
- package/dist/src/http/routes.d.ts +29 -7
- package/dist/src/http/routes.d.ts.map +1 -1
- package/dist/src/http/validation.d.ts +16 -0
- package/dist/src/http/validation.d.ts.map +1 -0
- package/dist/src/http/validation.js +51 -0
- package/dist/src/http/validation.js.map +1 -0
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/testing/test-client.d.ts +8 -6
- package/dist/src/testing/test-client.d.ts.map +1 -1
- package/dist/src/testing/test-client.js +3 -2
- package/dist/src/testing/test-client.js.map +1 -1
- package/package.json +3 -2
- package/starters/minimal/.env.example +3 -0
- package/starters/minimal/.oxfmtrc.json +5 -0
- package/starters/minimal/.oxlintrc.json +14 -0
- package/starters/minimal/AGENTS.md +23 -0
- package/starters/minimal/CLAUDE.md +2 -0
- package/starters/minimal/README.md +53 -0
- package/starters/minimal/_gitignore +3 -0
- package/starters/minimal/package.json +29 -0
- package/starters/minimal/src/app.ts +14 -0
- package/starters/minimal/src/env.ts +7 -0
- package/starters/minimal/src/features/index.ts +8 -0
- package/starters/minimal/src/features/notes/notes.controller.ts +38 -0
- package/starters/minimal/src/features/notes/notes.repository.ts +36 -0
- package/starters/minimal/src/features/notes/notes.schema.ts +24 -0
- package/starters/minimal/src/features/notes/notes.service.ts +33 -0
- package/starters/minimal/src/main.ts +4 -0
- package/starters/minimal/src/schemas.ts +26 -0
- package/starters/minimal/templates/feature/src/features/index.ts.controllers.insert +1 -0
- package/starters/minimal/templates/feature/src/features/index.ts.imports.insert +1 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +38 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +36 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
- package/starters/minimal/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
- package/starters/minimal/templates/feature/test/{{kebab}}.service.test.ts +20 -0
- package/starters/minimal/templates/feature/test/{{kebab}}.test.ts +35 -0
- package/starters/minimal/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
- package/starters/minimal/test/notes.service.test.ts +20 -0
- package/starters/minimal/test/notes.test.ts +35 -0
- package/starters/minimal/tsconfig.json +17 -0
- package/starters/minimal/vitest.config.ts +9 -0
- package/starters/sqlite-auth/.dockerignore +8 -0
- package/starters/sqlite-auth/.env.example +9 -0
- package/starters/sqlite-auth/.oxfmtrc.json +5 -0
- package/starters/sqlite-auth/.oxlintrc.json +14 -0
- package/starters/sqlite-auth/AGENTS.md +23 -0
- package/starters/sqlite-auth/CLAUDE.md +2 -0
- package/starters/sqlite-auth/Dockerfile +23 -0
- package/starters/sqlite-auth/README.md +93 -0
- package/starters/sqlite-auth/_gitignore +5 -0
- package/starters/sqlite-auth/compose.yaml +14 -0
- package/starters/sqlite-auth/package.json +35 -0
- package/starters/sqlite-auth/public/.gitkeep +0 -0
- package/starters/sqlite-auth/src/app.ts +39 -0
- package/starters/sqlite-auth/src/auth/tokens.ts +41 -0
- package/starters/sqlite-auth/src/database/clock.ts +4 -0
- package/starters/sqlite-auth/src/database/database.ts +29 -0
- package/starters/sqlite-auth/src/database/errors.ts +11 -0
- package/starters/sqlite-auth/src/database/migrations.ts +29 -0
- package/starters/sqlite-auth/src/env.ts +17 -0
- package/starters/sqlite-auth/src/features/auth/auth.controller.ts +28 -0
- package/starters/sqlite-auth/src/features/index.ts +12 -0
- package/starters/sqlite-auth/src/features/notes/notes.controller.ts +39 -0
- package/starters/sqlite-auth/src/features/notes/notes.repository.ts +47 -0
- package/starters/sqlite-auth/src/features/notes/notes.schema.ts +24 -0
- package/starters/sqlite-auth/src/features/notes/notes.service.ts +33 -0
- package/starters/sqlite-auth/src/features/users/admin-seed.ts +21 -0
- package/starters/sqlite-auth/src/features/users/users.controller.ts +42 -0
- package/starters/sqlite-auth/src/features/users/users.errors.ts +13 -0
- package/starters/sqlite-auth/src/features/users/users.repository.ts +77 -0
- package/starters/sqlite-auth/src/features/users/users.schema.ts +35 -0
- package/starters/sqlite-auth/src/features/users/users.service.ts +80 -0
- package/starters/sqlite-auth/src/main.ts +4 -0
- package/starters/sqlite-auth/src/middlewares/authenticate.ts +29 -0
- package/starters/sqlite-auth/src/schemas.ts +26 -0
- package/starters/sqlite-auth/templates/feature/src/database/migrations.ts.migrations.insert +5 -0
- package/starters/sqlite-auth/templates/feature/src/features/index.ts.controllers.insert +1 -0
- package/starters/sqlite-auth/templates/feature/src/features/index.ts.imports.insert +1 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.controller.ts +39 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.repository.ts +47 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.schema.ts +24 -0
- package/starters/sqlite-auth/templates/feature/src/features/{{kebab}}/{{kebab}}.service.ts +33 -0
- package/starters/sqlite-auth/templates/feature/test/{{kebab}}.service.test.ts +20 -0
- package/starters/sqlite-auth/templates/feature/test/{{kebab}}.test.ts +41 -0
- package/starters/sqlite-auth/templates/middleware/src/middlewares/{{kebab}}.ts +12 -0
- package/starters/sqlite-auth/test/auth.test.ts +18 -0
- package/starters/sqlite-auth/test/helpers.ts +13 -0
- package/starters/sqlite-auth/test/notes.service.test.ts +20 -0
- package/starters/sqlite-auth/test/notes.test.ts +41 -0
- package/starters/sqlite-auth/test/users.service.test.ts +25 -0
- package/starters/sqlite-auth/test/users.test.ts +164 -0
- package/starters/sqlite-auth/tsconfig.json +17 -0
- package/starters/sqlite-auth/vitest.config.ts +15 -0
- package/dist/src/cli/generate.d.ts +0 -9
- package/dist/src/cli/generate.d.ts.map +0 -1
- package/dist/src/cli/generate.js +0 -39
- package/dist/src/cli/generate.js.map +0 -1
- package/dist/src/cli/templates/auth.d.ts +0 -4
- package/dist/src/cli/templates/auth.d.ts.map +0 -1
- package/dist/src/cli/templates/auth.js +0 -90
- package/dist/src/cli/templates/auth.js.map +0 -1
- package/dist/src/cli/templates/database.d.ts +0 -4
- package/dist/src/cli/templates/database.d.ts.map +0 -1
- package/dist/src/cli/templates/database.js +0 -107
- package/dist/src/cli/templates/database.js.map +0 -1
- package/dist/src/cli/templates/docker.d.ts +0 -4
- package/dist/src/cli/templates/docker.d.ts.map +0 -1
- package/dist/src/cli/templates/docker.js +0 -102
- package/dist/src/cli/templates/docker.js.map +0 -1
- package/dist/src/cli/templates/feature.d.ts +0 -8
- package/dist/src/cli/templates/feature.d.ts.map +0 -1
- package/dist/src/cli/templates/feature.js +0 -145
- package/dist/src/cli/templates/feature.js.map +0 -1
- package/dist/src/cli/templates/project.d.ts +0 -24
- package/dist/src/cli/templates/project.d.ts.map +0 -1
- package/dist/src/cli/templates/project.js +0 -166
- package/dist/src/cli/templates/project.js.map +0 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "starter-sqlite-auth",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "node --watch src/main.ts",
|
|
7
|
+
"start": "node src/main.ts",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"brisk": "brisk",
|
|
11
|
+
"lint": "oxlint --type-aware",
|
|
12
|
+
"format": "oxfmt",
|
|
13
|
+
"format:check": "oxfmt --check"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@raiyyan/brisk": "workspace:*",
|
|
17
|
+
"cors": "^2.8.6",
|
|
18
|
+
"express": "^5.3.0",
|
|
19
|
+
"express-rate-limit": "^8.7.4"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/cors": "^2.8.19",
|
|
23
|
+
"@types/express": "^5.0.6",
|
|
24
|
+
"@types/node": "^26.6.6",
|
|
25
|
+
"oxfmt": "^0.78.0",
|
|
26
|
+
"oxlint": "^1.93.0",
|
|
27
|
+
"oxlint-tsgolint": "^7.0.2007",
|
|
28
|
+
"pino-pretty": "^13.2.0",
|
|
29
|
+
"typescript": "^7.0.2",
|
|
30
|
+
"vitest": "^5.1.0"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=24"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createLogger, defineApp, fromExpress } from "@raiyyan/brisk";
|
|
2
|
+
import cors from "cors";
|
|
3
|
+
import express from "express";
|
|
4
|
+
import { rateLimit } from "express-rate-limit";
|
|
5
|
+
import { uniqueViolation } from "./database/errors.ts";
|
|
6
|
+
import { env } from "./env.ts";
|
|
7
|
+
import { controllers } from "./features/index.ts";
|
|
8
|
+
import { AdminSeed } from "./features/users/admin-seed.ts";
|
|
9
|
+
|
|
10
|
+
export const app = defineApp({
|
|
11
|
+
prefix: "/api",
|
|
12
|
+
controllers,
|
|
13
|
+
providers: [AdminSeed],
|
|
14
|
+
logger: createLogger({
|
|
15
|
+
level: env.NODE_ENV === "test" ? "silent" : env.LOG_LEVEL,
|
|
16
|
+
pretty: env.NODE_ENV === "development",
|
|
17
|
+
}),
|
|
18
|
+
openapi: { title: "brisk demo", version: "1.0.0" },
|
|
19
|
+
errors: {
|
|
20
|
+
mappers: [uniqueViolation],
|
|
21
|
+
exposeInternal: env.NODE_ENV === "development",
|
|
22
|
+
},
|
|
23
|
+
use: [
|
|
24
|
+
fromExpress(cors({ origin: env.WEB_ORIGIN, credentials: true })),
|
|
25
|
+
// Behind a proxy, set http: { settings: { "trust proxy": 1 } } so the limit applies per client, not per proxy.
|
|
26
|
+
fromExpress(
|
|
27
|
+
rateLimit({
|
|
28
|
+
windowMs: 60_000,
|
|
29
|
+
limit: env.RATE_LIMIT_PER_MINUTE,
|
|
30
|
+
standardHeaders: "draft-8",
|
|
31
|
+
legacyHeaders: false,
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
],
|
|
35
|
+
mount: { "/static": express.static("public", { maxAge: "1h" }) },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** The API's type, for a frontend's typed client: `createClient<Api>(manifest, { baseUrl })`. */
|
|
39
|
+
export type Api = typeof app;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { z } from "@raiyyan/brisk";
|
|
3
|
+
import { env } from "../env.ts";
|
|
4
|
+
|
|
5
|
+
export const Role = z.enum(["user", "admin"]);
|
|
6
|
+
export type Role = z.infer<typeof Role>;
|
|
7
|
+
|
|
8
|
+
const Claims = z.object({ sub: z.string(), role: Role, exp: z.number() });
|
|
9
|
+
export type Claims = z.infer<typeof Claims>;
|
|
10
|
+
|
|
11
|
+
const HEADER = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
|
12
|
+
|
|
13
|
+
/** Signs and verifies HS256 JSON Web Tokens with `AUTH_SECRET`. */
|
|
14
|
+
export class Tokens {
|
|
15
|
+
readonly #key = Buffer.from(env.AUTH_SECRET);
|
|
16
|
+
|
|
17
|
+
sign(subject: string, role: Role, ttlSeconds = env.AUTH_TOKEN_TTL_SECONDS): string {
|
|
18
|
+
const claims: Claims = { sub: subject, role, exp: Math.floor(Date.now() / 1000) + ttlSeconds };
|
|
19
|
+
const body = `${HEADER}.${Buffer.from(JSON.stringify(claims)).toString("base64url")}`;
|
|
20
|
+
return `${body}.${this.#signature(body)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The token's claims, or `undefined` when it is malformed, forged or expired. */
|
|
24
|
+
verify(token: string): Claims | undefined {
|
|
25
|
+
const [header, payload, signature, ...rest] = token.split(".");
|
|
26
|
+
if (!header || !payload || !signature || rest.length > 0 || header !== HEADER) return undefined;
|
|
27
|
+
const expected = Buffer.from(this.#signature(`${header}.${payload}`));
|
|
28
|
+
const actual = Buffer.from(signature);
|
|
29
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return undefined;
|
|
30
|
+
try {
|
|
31
|
+
const claims = Claims.parse(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
|
32
|
+
return claims.exp > Date.now() / 1000 ? claims : undefined;
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
#signature(body: string): string {
|
|
39
|
+
return createHmac("sha256", this.#key).update(body).digest("base64url");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { inject, Logger, type OnInit, type OnShutdown } from "@raiyyan/brisk";
|
|
3
|
+
import { env } from "../env.ts";
|
|
4
|
+
import { migrate } from "./migrations.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The app's SQLite connection (Node's built-in `node:sqlite`), opened at boot and closed on shutdown.
|
|
8
|
+
* Repositories inject it: `readonly #db = inject(Database)`, then `this.#db.sql.prepare(...)`.
|
|
9
|
+
*/
|
|
10
|
+
export class Database implements OnInit, OnShutdown {
|
|
11
|
+
readonly #log = inject(Logger);
|
|
12
|
+
#connection: DatabaseSync | undefined;
|
|
13
|
+
|
|
14
|
+
get sql(): DatabaseSync {
|
|
15
|
+
if (!this.#connection) throw new Error("Database used before the app booted");
|
|
16
|
+
return this.#connection;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
onInit(): void {
|
|
20
|
+
this.#connection = new DatabaseSync(env.DATABASE_PATH);
|
|
21
|
+
this.#connection.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;");
|
|
22
|
+
migrate(this.#connection);
|
|
23
|
+
this.#log.info({ path: env.DATABASE_PATH }, "database opened");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onShutdown(): void {
|
|
27
|
+
this.#connection?.close();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ConflictError, type ErrorMapper } from "@raiyyan/brisk";
|
|
2
|
+
|
|
3
|
+
/** A unique constraint failed, here or in an error the driver wrapped: answer 409 instead of 500. */
|
|
4
|
+
export const uniqueViolation: ErrorMapper = (error) => {
|
|
5
|
+
for (let current: unknown = error; current instanceof Error; current = current.cause) {
|
|
6
|
+
if ("errcode" in current && (current.errcode === 2067 || current.errcode === 1555)) {
|
|
7
|
+
return new ConflictError("Already exists", { code: "ALREADY_EXISTS", cause: error });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
|
|
3
|
+
/** Schema changes in order. Append new ones (`brisk g feature` does); never edit one that has shipped. */
|
|
4
|
+
const MIGRATIONS: readonly string[] = [
|
|
5
|
+
`CREATE TABLE users (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
email TEXT NOT NULL UNIQUE,
|
|
8
|
+
name TEXT NOT NULL,
|
|
9
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'admin')),
|
|
10
|
+
password_hash TEXT NOT NULL,
|
|
11
|
+
created_at TEXT NOT NULL
|
|
12
|
+
)`,
|
|
13
|
+
`CREATE TABLE notes (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
created_at TEXT NOT NULL
|
|
17
|
+
)`,
|
|
18
|
+
// @brisk:migrations
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Applies the migrations this database has not seen, tracked with SQLite's `user_version`. */
|
|
22
|
+
export function migrate(sql: DatabaseSync): void {
|
|
23
|
+
const row = sql.prepare("PRAGMA user_version").get();
|
|
24
|
+
const applied = typeof row?.["user_version"] === "number" ? row["user_version"] : 0;
|
|
25
|
+
for (const [index, migration] of MIGRATIONS.entries()) {
|
|
26
|
+
if (index < applied) continue;
|
|
27
|
+
sql.exec(`BEGIN; ${migration}; PRAGMA user_version = ${index + 1}; COMMIT;`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineEnv, z } from "@raiyyan/brisk";
|
|
2
|
+
|
|
3
|
+
export const env = defineEnv({
|
|
4
|
+
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
5
|
+
PORT: z.coerce.number().int().positive().default(3000),
|
|
6
|
+
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
|
|
7
|
+
DATABASE_PATH: z.string().min(1).default("data.db"),
|
|
8
|
+
/** HMAC key for access tokens: 32+ random characters (`openssl rand -base64 48`). */
|
|
9
|
+
AUTH_SECRET: z.string().min(32),
|
|
10
|
+
AUTH_TOKEN_TTL_SECONDS: z.coerce.number().int().positive().default(3600),
|
|
11
|
+
/** Browser origin allowed to call the API, such as http://localhost:5173. */
|
|
12
|
+
WEB_ORIGIN: z.url().default("http://localhost:5173"),
|
|
13
|
+
RATE_LIMIT_PER_MINUTE: z.coerce.number().int().positive().default(300),
|
|
14
|
+
/** First admin, created at boot when no user has this email. Leave unset to skip. */
|
|
15
|
+
ADMIN_EMAIL: z.email().optional(),
|
|
16
|
+
ADMIN_PASSWORD: z.string().min(12).optional(),
|
|
17
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineController, inject, UnauthorizedError, z } from "@raiyyan/brisk";
|
|
2
|
+
import { Tokens } from "../../auth/tokens.ts";
|
|
3
|
+
import { env } from "../../env.ts";
|
|
4
|
+
import { UsersService } from "../users/users.service.ts";
|
|
5
|
+
|
|
6
|
+
const LoginBody = z.object({ email: z.email(), password: z.string().min(1) });
|
|
7
|
+
const TokenResponse = z.object({ token: z.string(), tokenType: z.literal("Bearer"), expiresIn: z.number() });
|
|
8
|
+
|
|
9
|
+
export const authController = defineController({ prefix: "/auth" }, (route) => {
|
|
10
|
+
const users = inject(UsersService);
|
|
11
|
+
const tokens = inject(Tokens);
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
login: route.post(
|
|
15
|
+
"/login",
|
|
16
|
+
{ body: LoginBody, response: TokenResponse, status: 200 },
|
|
17
|
+
async ({ body }) => {
|
|
18
|
+
const user = await users.authenticate(body.email, body.password);
|
|
19
|
+
if (!user) throw new UnauthorizedError("Wrong email or password", { code: "INVALID_CREDENTIALS" });
|
|
20
|
+
return {
|
|
21
|
+
token: tokens.sign(user.id, user.role),
|
|
22
|
+
tokenType: "Bearer" as const,
|
|
23
|
+
expiresIn: env.AUTH_TOKEN_TTL_SECONDS,
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
),
|
|
27
|
+
};
|
|
28
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { authController } from "./auth/auth.controller.ts";
|
|
2
|
+
import { usersController } from "./users/users.controller.ts";
|
|
3
|
+
import { notesController } from "./notes/notes.controller.ts";
|
|
4
|
+
// @brisk:imports
|
|
5
|
+
|
|
6
|
+
/** Every controller by name. The names are the typed client's top level: `api.users.get(...)`. */
|
|
7
|
+
export const controllers = {
|
|
8
|
+
auth: authController,
|
|
9
|
+
users: usersController,
|
|
10
|
+
notes: notesController,
|
|
11
|
+
// @brisk:controllers
|
|
12
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { defineController, inject } from "@raiyyan/brisk";
|
|
2
|
+
import { authenticate } from "../../middlewares/authenticate.ts";
|
|
3
|
+
import { page } from "../../schemas.ts";
|
|
4
|
+
import {
|
|
5
|
+
CreateNoteBody,
|
|
6
|
+
NoteIdParams,
|
|
7
|
+
NoteResponse,
|
|
8
|
+
ListNotesQuery,
|
|
9
|
+
UpdateNoteBody,
|
|
10
|
+
} from "./notes.schema.ts";
|
|
11
|
+
import { NotesService } from "./notes.service.ts";
|
|
12
|
+
|
|
13
|
+
export const notesController = defineController({ prefix: "/notes", use: [authenticate] }, (route) => {
|
|
14
|
+
const notes = inject(NotesService);
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
list: route.get("/", { query: ListNotesQuery, response: page(NoteResponse) }, ({ query }) =>
|
|
18
|
+
notes.list(query),
|
|
19
|
+
),
|
|
20
|
+
|
|
21
|
+
get: route.get("/:id", { params: NoteIdParams, response: NoteResponse }, ({ params }) =>
|
|
22
|
+
notes.get(params.id),
|
|
23
|
+
),
|
|
24
|
+
|
|
25
|
+
create: route.post("/", { body: CreateNoteBody, response: NoteResponse }, ({ body }) =>
|
|
26
|
+
notes.create(body),
|
|
27
|
+
),
|
|
28
|
+
|
|
29
|
+
update: route.patch(
|
|
30
|
+
"/:id",
|
|
31
|
+
{ params: NoteIdParams, body: UpdateNoteBody, response: NoteResponse },
|
|
32
|
+
({ params, body }) => notes.update(params.id, body),
|
|
33
|
+
),
|
|
34
|
+
|
|
35
|
+
remove: route.delete("/:id", { params: NoteIdParams }, async ({ params }) => {
|
|
36
|
+
await notes.remove(params.id);
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { inject, z } from "@raiyyan/brisk";
|
|
2
|
+
import { Database } from "../../database/database.ts";
|
|
3
|
+
|
|
4
|
+
export interface Note {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
createdAt: Date;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A row as SQLite returns it, checked once at the boundary instead of cast. */
|
|
11
|
+
const Row = z
|
|
12
|
+
.object({ id: z.string(), name: z.string(), created_at: z.string() })
|
|
13
|
+
.transform((row): Note => ({ id: row.id, name: row.name, createdAt: new Date(row.created_at) }));
|
|
14
|
+
|
|
15
|
+
/** Data access only: SQL in, note rows out. No HTTP and no business rules. */
|
|
16
|
+
export class NotesRepository {
|
|
17
|
+
readonly #db = inject(Database);
|
|
18
|
+
|
|
19
|
+
async list({ page, limit }: { page: number; limit: number }): Promise<{ items: Note[]; total: number }> {
|
|
20
|
+
const rows = this.#db.sql
|
|
21
|
+
.prepare("SELECT * FROM notes ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?")
|
|
22
|
+
.all(limit, (page - 1) * limit);
|
|
23
|
+
const counted = this.#db.sql.prepare("SELECT count(*) AS total FROM notes").get();
|
|
24
|
+
return { items: rows.map((row) => Row.parse(row)), total: Number(counted?.["total"] ?? 0) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async findById(id: string): Promise<Note | undefined> {
|
|
28
|
+
const row = this.#db.sql.prepare("SELECT * FROM notes WHERE id = ?").get(id);
|
|
29
|
+
return row === undefined ? undefined : Row.parse(row);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async insert(row: Note): Promise<Note> {
|
|
33
|
+
this.#db.sql
|
|
34
|
+
.prepare("INSERT INTO notes (id, name, created_at) VALUES (?, ?, ?)")
|
|
35
|
+
.run(row.id, row.name, row.createdAt.toISOString());
|
|
36
|
+
return row;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async update(row: Note): Promise<Note> {
|
|
40
|
+
this.#db.sql.prepare("UPDATE notes SET name = ? WHERE id = ?").run(row.name, row.id);
|
|
41
|
+
return row;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async delete(id: string): Promise<boolean> {
|
|
45
|
+
return Number(this.#db.sql.prepare("DELETE FROM notes WHERE id = ?").run(id).changes) > 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "@raiyyan/brisk";
|
|
2
|
+
import { isoDate, PageQuery } from "../../schemas.ts";
|
|
3
|
+
|
|
4
|
+
export const CreateNoteBody = z.object({
|
|
5
|
+
name: z.string().trim().min(1).max(200),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// Keep .default() out of the create schema: .partial() keeps defaults, and a PATCH would reset omitted fields.
|
|
9
|
+
export const UpdateNoteBody = CreateNoteBody.partial();
|
|
10
|
+
|
|
11
|
+
export const NoteIdParams = z.object({ id: z.uuid() });
|
|
12
|
+
|
|
13
|
+
export const ListNotesQuery = PageQuery;
|
|
14
|
+
|
|
15
|
+
/** What clients see of a note: fields not listed here never leave the server. */
|
|
16
|
+
export const NoteResponse = z.object({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
name: z.string(),
|
|
19
|
+
createdAt: isoDate(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type CreateNoteBody = z.infer<typeof CreateNoteBody>;
|
|
23
|
+
export type UpdateNoteBody = z.infer<typeof UpdateNoteBody>;
|
|
24
|
+
export type ListNotesQuery = z.infer<typeof ListNotesQuery>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { inject, NotFoundError } from "@raiyyan/brisk";
|
|
3
|
+
import { NotesRepository, type Note } from "./notes.repository.ts";
|
|
4
|
+
import type { CreateNoteBody, ListNotesQuery, UpdateNoteBody } from "./notes.schema.ts";
|
|
5
|
+
|
|
6
|
+
/** Business rules for notes. Throws errors the client understands; knows nothing about Express. */
|
|
7
|
+
export class NotesService {
|
|
8
|
+
readonly #repository = inject(NotesRepository);
|
|
9
|
+
|
|
10
|
+
async list(query: ListNotesQuery) {
|
|
11
|
+
const { items, total } = await this.#repository.list(query);
|
|
12
|
+
return { items, total, page: query.page, limit: query.limit };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async get(id: string): Promise<Note> {
|
|
16
|
+
const row = await this.#repository.findById(id);
|
|
17
|
+
if (!row) throw new NotFoundError("Note not found", { details: { id } });
|
|
18
|
+
return row;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
create(input: CreateNoteBody): Promise<Note> {
|
|
22
|
+
return this.#repository.insert({ id: randomUUID(), name: input.name, createdAt: new Date() });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async update(id: string, { name }: UpdateNoteBody): Promise<Note> {
|
|
26
|
+
const row = await this.get(id);
|
|
27
|
+
return this.#repository.update({ ...row, name: name ?? row.name });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async remove(id: string): Promise<void> {
|
|
31
|
+
if (!(await this.#repository.delete(id))) throw new NotFoundError("Note not found", { details: { id } });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { inject, Logger, type OnReady } from "@raiyyan/brisk";
|
|
2
|
+
import { env } from "../../env.ts";
|
|
3
|
+
import { UsersRepository } from "./users.repository.ts";
|
|
4
|
+
import { UsersService } from "./users.service.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates the first admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD` once the database is ready, unless that user
|
|
8
|
+
* exists. A provider class listed in `defineApp({ providers })`, so it runs even though nothing injects it.
|
|
9
|
+
*/
|
|
10
|
+
export class AdminSeed implements OnReady {
|
|
11
|
+
readonly #users = inject(UsersService);
|
|
12
|
+
readonly #repository = inject(UsersRepository);
|
|
13
|
+
readonly #log = inject(Logger);
|
|
14
|
+
|
|
15
|
+
async onReady(): Promise<void> {
|
|
16
|
+
const { ADMIN_EMAIL: email, ADMIN_PASSWORD: password } = env;
|
|
17
|
+
if (!email || !password || this.#repository.findByEmail(email.toLowerCase())) return;
|
|
18
|
+
await this.#users.create({ email, password, name: "Admin", role: "admin" });
|
|
19
|
+
this.#log.info({ email }, "admin created");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { defineController, inject } from "@raiyyan/brisk";
|
|
2
|
+
import { authenticate, requireRole } from "../../middlewares/authenticate.ts";
|
|
3
|
+
import {
|
|
4
|
+
CreateUserBody,
|
|
5
|
+
ListUsersQuery,
|
|
6
|
+
UpdateUserBody,
|
|
7
|
+
UserIdParams,
|
|
8
|
+
UserResponse,
|
|
9
|
+
} from "./users.schema.ts";
|
|
10
|
+
import { UsersService } from "./users.service.ts";
|
|
11
|
+
|
|
12
|
+
const admin = requireRole("admin");
|
|
13
|
+
|
|
14
|
+
export const usersController = defineController({ prefix: "/users", use: [authenticate] }, (route) => {
|
|
15
|
+
const users = inject(UsersService);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
me: route.get("/me", { response: UserResponse }, ({ user }) => users.get(user.id)),
|
|
19
|
+
|
|
20
|
+
list: route.get("/", { query: ListUsersQuery, response: UserResponse.array() }, ({ query }) =>
|
|
21
|
+
users.list(query),
|
|
22
|
+
),
|
|
23
|
+
|
|
24
|
+
get: route.get("/:id", { params: UserIdParams, response: UserResponse }, ({ params }) =>
|
|
25
|
+
users.get(params.id),
|
|
26
|
+
),
|
|
27
|
+
|
|
28
|
+
create: route.post("/", { use: [admin], body: CreateUserBody, response: UserResponse }, ({ body }) =>
|
|
29
|
+
users.create(body),
|
|
30
|
+
),
|
|
31
|
+
|
|
32
|
+
update: route.patch(
|
|
33
|
+
"/:id",
|
|
34
|
+
{ use: [admin], params: UserIdParams, body: UpdateUserBody, response: UserResponse },
|
|
35
|
+
({ params, body }) => users.update(params.id, body),
|
|
36
|
+
),
|
|
37
|
+
|
|
38
|
+
remove: route.delete("/:id", { use: [admin], params: UserIdParams }, ({ params }) => {
|
|
39
|
+
users.remove(params.id);
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ConflictError, NotFoundError } from "@raiyyan/brisk";
|
|
2
|
+
|
|
3
|
+
export class UserNotFoundError extends NotFoundError {
|
|
4
|
+
constructor(id: string) {
|
|
5
|
+
super("User not found", { code: "USER_NOT_FOUND", details: { id } });
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class EmailTakenError extends ConflictError {
|
|
10
|
+
constructor(email: string) {
|
|
11
|
+
super("Email already registered", { code: "EMAIL_TAKEN", details: { email } });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { inject, z } from "@raiyyan/brisk";
|
|
2
|
+
import { Role } from "../../auth/tokens.ts";
|
|
3
|
+
import { Database } from "../../database/database.ts";
|
|
4
|
+
|
|
5
|
+
export interface User {
|
|
6
|
+
id: string;
|
|
7
|
+
email: string;
|
|
8
|
+
name: string;
|
|
9
|
+
role: Role;
|
|
10
|
+
passwordHash: string;
|
|
11
|
+
createdAt: Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A row as SQLite returns it, checked once at the boundary instead of cast. */
|
|
15
|
+
const Row = z
|
|
16
|
+
.object({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
email: z.string(),
|
|
19
|
+
name: z.string(),
|
|
20
|
+
role: Role,
|
|
21
|
+
password_hash: z.string(),
|
|
22
|
+
created_at: z.string(),
|
|
23
|
+
})
|
|
24
|
+
.transform((row): User => ({
|
|
25
|
+
id: row.id,
|
|
26
|
+
email: row.email,
|
|
27
|
+
name: row.name,
|
|
28
|
+
role: row.role,
|
|
29
|
+
passwordHash: row.password_hash,
|
|
30
|
+
createdAt: new Date(row.created_at),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
/** Data access only: SQL in, users out. No HTTP and no business rules. */
|
|
34
|
+
export class UsersRepository {
|
|
35
|
+
readonly #db = inject(Database);
|
|
36
|
+
|
|
37
|
+
findMany({ search, limit }: { search?: string | undefined; limit: number }): User[] {
|
|
38
|
+
const term = `%${(search ?? "").toLowerCase()}%`;
|
|
39
|
+
const rows = this.#db.sql
|
|
40
|
+
.prepare("SELECT * FROM users WHERE lower(name) LIKE ? OR email LIKE ? ORDER BY created_at, id LIMIT ?")
|
|
41
|
+
.all(term, term, limit);
|
|
42
|
+
return rows.map((row) => Row.parse(row));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
findById(id: string): User | undefined {
|
|
46
|
+
return this.#one("SELECT * FROM users WHERE id = ?", id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
findByEmail(email: string): User | undefined {
|
|
50
|
+
return this.#one("SELECT * FROM users WHERE email = ?", email);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
insert(user: User): User {
|
|
54
|
+
this.#db.sql
|
|
55
|
+
.prepare(
|
|
56
|
+
"INSERT INTO users (id, email, name, role, password_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
57
|
+
)
|
|
58
|
+
.run(user.id, user.email, user.name, user.role, user.passwordHash, user.createdAt.toISOString());
|
|
59
|
+
return user;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
update(user: User): User {
|
|
63
|
+
this.#db.sql
|
|
64
|
+
.prepare("UPDATE users SET email = ?, name = ? WHERE id = ?")
|
|
65
|
+
.run(user.email, user.name, user.id);
|
|
66
|
+
return user;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
delete(id: string): boolean {
|
|
70
|
+
return Number(this.#db.sql.prepare("DELETE FROM users WHERE id = ?").run(id).changes) > 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#one(query: string, value: string): User | undefined {
|
|
74
|
+
const row = this.#db.sql.prepare(query).get(value);
|
|
75
|
+
return row === undefined ? undefined : Row.parse(row);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "@raiyyan/brisk";
|
|
2
|
+
import { isoDate } from "../../schemas.ts";
|
|
3
|
+
import { Role } from "../../auth/tokens.ts";
|
|
4
|
+
|
|
5
|
+
export const CreateUserBody = z.object({
|
|
6
|
+
email: z.email().toLowerCase(),
|
|
7
|
+
name: z.string().trim().min(1).max(100),
|
|
8
|
+
password: z.string().min(12).max(128),
|
|
9
|
+
role: Role.default("user"),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const UpdateUserBody = z.object({
|
|
13
|
+
email: z.email().toLowerCase().optional(),
|
|
14
|
+
name: z.string().trim().min(1).max(100).optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const UserIdParams = z.object({ id: z.uuid() });
|
|
18
|
+
|
|
19
|
+
export const ListUsersQuery = z.object({
|
|
20
|
+
search: z.string().trim().max(100).optional(),
|
|
21
|
+
limit: z.coerce.number().int().min(1).max(100).default(20),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** Public shape of a user. Anything not listed, such as the password hash, never reaches a client. */
|
|
25
|
+
export const UserResponse = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
email: z.string(),
|
|
28
|
+
name: z.string(),
|
|
29
|
+
role: Role,
|
|
30
|
+
createdAt: isoDate(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type CreateUserBody = z.infer<typeof CreateUserBody>;
|
|
34
|
+
export type UpdateUserBody = z.infer<typeof UpdateUserBody>;
|
|
35
|
+
export type ListUsersQuery = z.infer<typeof ListUsersQuery>;
|