@tatara/cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/dist/bin.mjs +2910 -0
- package/package.json +32 -3
- package/templates/features/admin/files/src/apps/admin/app.ts +4 -0
- package/templates/features/admin/files/src/apps/admin/server.ts +5 -0
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +24 -0
- package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +20 -0
- package/templates/features/auth/files/src/core/auth/middleware.ts +29 -0
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +36 -0
- package/templates/features/auth/files/src/core/auth/tests/me.test.ts +25 -0
- package/templates/features/auth-orgs/files+drizzle+mysql/src/core/auth/db/organization.schema.ts +38 -0
- package/templates/features/auth-orgs/files+drizzle+postgres/src/core/auth/db/organization.schema.ts +38 -0
- package/templates/features/auth-orgs/files+prisma/prisma/models/organization.prisma +41 -0
- package/templates/features/authz/files/src/core/authz/cache.ts +31 -0
- package/templates/features/authz/files/src/core/authz/catalog.ts +85 -0
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +21 -0
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +11 -0
- package/templates/features/authz/files/src/core/authz/constants.ts +28 -0
- package/templates/features/authz/files/src/core/authz/db/seed.ts +10 -0
- package/templates/features/authz/files/src/core/authz/middleware.ts +21 -0
- package/templates/features/authz/files/src/core/authz/permissions.ts +31 -0
- package/templates/features/authz/files/src/core/authz/resolver.ts +98 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +281 -0
- package/templates/features/authz/files/src/core/authz/services/audit.ts +24 -0
- package/templates/features/authz/files/src/core/authz/services/grants.ts +29 -0
- package/templates/features/authz/files/src/core/authz/services/members.ts +68 -0
- package/templates/features/authz/files/src/core/authz/services/roles.ts +73 -0
- package/templates/features/authz/files/src/core/authz/services/sync.ts +34 -0
- package/templates/features/authz/files/src/core/authz/system-roles.ts +9 -0
- package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +146 -0
- package/templates/features/authz/files/src/core/authz/types.ts +85 -0
- package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
- package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +262 -0
- package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
- package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
- package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +229 -0
- package/templates/features/base/files/AGENTS.md +69 -0
- package/templates/features/base/files/CLAUDE.md +1 -0
- package/templates/features/base/files/Dockerfile +15 -0
- package/templates/features/base/files/README.md +31 -0
- package/templates/features/base/files/_github/workflows/ci.yml +16 -0
- package/templates/features/base/files/biome.template.json +20 -0
- package/templates/features/base/files/dockerignore +8 -0
- package/templates/features/base/files/gitignore +9 -0
- package/templates/features/base/files/src/apps/api/app.ts +3 -0
- package/templates/features/base/files/src/apps/api/server.ts +5 -0
- package/templates/features/base/files/src/apps/cli/index.ts +6 -0
- package/templates/features/base/files/src/core/commands/define.ts +25 -0
- package/templates/features/base/files/src/core/commands/runner.ts +54 -0
- package/templates/features/base/files/src/core/http/app.ts +52 -0
- package/templates/features/base/files/src/core/http/context.ts +18 -0
- package/templates/features/base/files/src/core/http/errors.ts +51 -0
- package/templates/features/base/files/src/core/http/health.ts +25 -0
- package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
- package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
- package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
- package/templates/features/base/files/src/core/http/response.ts +67 -0
- package/templates/features/base/files/src/core/http/routes.ts +28 -0
- package/templates/features/base/files/src/core/http/server.ts +15 -0
- package/templates/features/base/files/src/core/logging/logger.ts +17 -0
- package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
- package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
- package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
- package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
- package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
- package/templates/features/base/files/tests/helpers/api.ts +54 -0
- package/templates/features/base/files/tests/setup.ts +2 -0
- package/templates/features/base/files/tsconfig.json +20 -0
- package/templates/features/base/files/tsdown.config.ts +15 -0
- package/templates/features/base/files/vitest.config.ts +16 -0
- package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
- package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
- package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
- package/templates/features/better-auth/files/src/core/auth/principal.ts +23 -0
- package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
- package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
- package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
- package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
- package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
- package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
- package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
- package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
- package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
- package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
- package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
- package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
- package/templates/features/db/files/src/core/db/id.ts +13 -0
- package/templates/features/db/files/src/core/db/seed.ts +14 -0
- package/templates/features/db/files/tests/global-setup.ts +27 -0
- package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
- package/templates/features/db/files/tests/helpers/db.ts +7 -0
- package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
- package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
- package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
- package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
- package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
- package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
- package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
- package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
- package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
- package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
- package/templates/features/email/files/src/core/email/send.ts +22 -0
- package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
- package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
- package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
- package/templates/features/i18n/files/src/locales/ar.json +10 -0
- package/templates/features/i18n/files/src/locales/en.json +10 -0
- package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
- package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
- package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
- package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
- package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
- package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
- package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
- package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
- package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
- package/templates/features/keycloak/files/keycloak/realm.json +83 -0
- package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
- package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
- package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
- package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
- package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
- package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
- package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
- package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
- package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
- package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
- package/templates/features/prisma/files/prisma.config.ts +10 -0
- package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
- package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
- package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
- package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
- package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
- package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
- package/templates/features/storage/files/src/core/storage/index.ts +8 -0
- package/templates/features/storage/files/src/core/storage/local.ts +34 -0
- package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
- package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
- package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
- package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
- package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +126 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
- package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
- package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
- package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
- package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
- package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
- package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
- package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
- package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
- package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
- package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
- package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
- package/templates/versions.json +34 -0
- package/README.md +0 -3
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
2
|
+
|
|
3
|
+
export class AppError extends Error {
|
|
4
|
+
readonly status: ContentfulStatusCode;
|
|
5
|
+
readonly code: string;
|
|
6
|
+
readonly details?: unknown;
|
|
7
|
+
|
|
8
|
+
constructor(status: ContentfulStatusCode, code: string, message: string, details?: unknown) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = new.target.name;
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ValidationError extends AppError {
|
|
18
|
+
constructor(message = "Invalid request", details?: unknown) {
|
|
19
|
+
super(400, "VALIDATION_ERROR", message, details);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class BusinessRuleError extends AppError {
|
|
24
|
+
constructor(code: string, message: string, details?: unknown) {
|
|
25
|
+
super(400, code, message, details);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class UnauthorizedError extends AppError {
|
|
30
|
+
constructor(message = "Authentication required") {
|
|
31
|
+
super(401, "UNAUTHORIZED", message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ForbiddenError extends AppError {
|
|
36
|
+
constructor(message = "You do not have permission to do this") {
|
|
37
|
+
super(403, "FORBIDDEN", message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class NotFoundError extends AppError {
|
|
42
|
+
constructor(resource = "Resource") {
|
|
43
|
+
super(404, "NOT_FOUND", `${resource} not found`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ConflictError extends AppError {
|
|
48
|
+
constructor(message = "Resource already exists", details?: unknown) {
|
|
49
|
+
super(409, "CONFLICT", message, details);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
type Check = () => Promise<void>;
|
|
2
|
+
|
|
3
|
+
const checks = new Map<string, Check>();
|
|
4
|
+
|
|
5
|
+
/** Registers a dependency checked by GET /ready (database, redis, …). */
|
|
6
|
+
export function registerReadinessCheck(name: string, check: Check): void {
|
|
7
|
+
checks.set(name, check);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function runReadinessChecks() {
|
|
11
|
+
const results = await Promise.all(
|
|
12
|
+
[...checks].map(async ([name, check]) => {
|
|
13
|
+
try {
|
|
14
|
+
await check();
|
|
15
|
+
return [name, "ok"] as const;
|
|
16
|
+
} catch {
|
|
17
|
+
return [name, "failed"] as const;
|
|
18
|
+
}
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
return {
|
|
22
|
+
ready: results.every(([, status]) => status === "ok"),
|
|
23
|
+
checks: Object.fromEntries(results),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { HTTPException } from "hono/http-exception";
|
|
3
|
+
import type { AppEnv } from "#core/http/context";
|
|
4
|
+
import { AppError } from "#core/http/errors";
|
|
5
|
+
// tatara:slot imports
|
|
6
|
+
|
|
7
|
+
export function errorHandler(err: Error, c: Context<AppEnv>) {
|
|
8
|
+
if (err instanceof AppError) {
|
|
9
|
+
let message = err.message;
|
|
10
|
+
// tatara:slot error-message
|
|
11
|
+
return c.json(
|
|
12
|
+
{
|
|
13
|
+
success: false as const,
|
|
14
|
+
error: message,
|
|
15
|
+
code: err.code,
|
|
16
|
+
...(err.details === undefined ? {} : { details: err.details }),
|
|
17
|
+
},
|
|
18
|
+
err.status,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
if (err instanceof HTTPException) {
|
|
22
|
+
return c.json(
|
|
23
|
+
{ success: false as const, error: err.message || "Request failed", code: "HTTP_ERROR" },
|
|
24
|
+
err.status,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
c.get("logger").error({ err }, "unhandled error");
|
|
28
|
+
return c.json(
|
|
29
|
+
{ success: false as const, error: "Something went wrong", code: "INTERNAL_ERROR" },
|
|
30
|
+
500,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function notFoundHandler(c: Context<AppEnv>) {
|
|
35
|
+
return c.json({ success: false as const, error: "Route not found", code: "NOT_FOUND" }, 404);
|
|
36
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import type { AppEnv } from "#core/http/context";
|
|
3
|
+
import { logger } from "#core/logging/logger";
|
|
4
|
+
|
|
5
|
+
const REQUEST_ID = /^[\w-]{8,128}$/;
|
|
6
|
+
|
|
7
|
+
/** Sets requestId, a request-scoped logger and defaults; logs one line per request. */
|
|
8
|
+
export const requestContext = createMiddleware<AppEnv>(async (c, next) => {
|
|
9
|
+
const incoming = c.req.header("x-request-id");
|
|
10
|
+
const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
|
|
11
|
+
const log = logger.child({ requestId });
|
|
12
|
+
c.set("requestId", requestId);
|
|
13
|
+
c.set("logger", log);
|
|
14
|
+
c.set("principal", null);
|
|
15
|
+
c.set("language", "en");
|
|
16
|
+
c.header("x-request-id", requestId);
|
|
17
|
+
const start = performance.now();
|
|
18
|
+
await next();
|
|
19
|
+
const ms = Math.round(performance.now() - start);
|
|
20
|
+
const status = c.res.status;
|
|
21
|
+
const entry = { method: c.req.method, path: c.req.path, status, ms };
|
|
22
|
+
if (status >= 500) log.error(entry, "request failed");
|
|
23
|
+
else log.info(entry, "request");
|
|
24
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Hook } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "#core/http/context";
|
|
3
|
+
import { ValidationError } from "#core/http/errors";
|
|
4
|
+
|
|
5
|
+
// biome-ignore lint/suspicious/noExplicitAny: the hook is shared by every route schema
|
|
6
|
+
export const validationHook: Hook<any, AppEnv, any, any> = (result) => {
|
|
7
|
+
if (!result.success) {
|
|
8
|
+
throw new ValidationError(
|
|
9
|
+
"Invalid request",
|
|
10
|
+
result.error.issues.map((issue) => ({
|
|
11
|
+
path: issue.path.join("."),
|
|
12
|
+
message: issue.message,
|
|
13
|
+
code: issue.code,
|
|
14
|
+
})),
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from "@hono/zod-openapi";
|
|
2
|
+
|
|
3
|
+
export const errorSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
success: z.literal(false),
|
|
6
|
+
error: z.string(),
|
|
7
|
+
code: z.string(),
|
|
8
|
+
details: z.unknown().optional(),
|
|
9
|
+
})
|
|
10
|
+
.openapi("Error");
|
|
11
|
+
|
|
12
|
+
export const paginationMetaSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
page: z.number().int(),
|
|
15
|
+
pageSize: z.number().int(),
|
|
16
|
+
total: z.number().int(),
|
|
17
|
+
totalPages: z.number().int(),
|
|
18
|
+
})
|
|
19
|
+
.openapi("PaginationMeta");
|
|
20
|
+
|
|
21
|
+
export const paginationQuerySchema = z.object({
|
|
22
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
23
|
+
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
|
|
27
|
+
|
|
28
|
+
export function successSchema<T extends z.ZodType>(data: T) {
|
|
29
|
+
return z.object({ success: z.literal(true), data });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function paginatedSchema<T extends z.ZodType>(item: T) {
|
|
33
|
+
return z.object({ success: z.literal(true), data: z.array(item), meta: paginationMetaSchema });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function success<T>(data: T) {
|
|
37
|
+
return { success: true as const, data };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function paginated<T>(data: T[], page: number, pageSize: number, total: number) {
|
|
41
|
+
return {
|
|
42
|
+
success: true as const,
|
|
43
|
+
data,
|
|
44
|
+
meta: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function jsonContent<T extends z.ZodType>(schema: T, description: string) {
|
|
49
|
+
return { content: { "application/json": { schema } }, description };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ERROR_DESCRIPTIONS = {
|
|
53
|
+
400: "Invalid request",
|
|
54
|
+
401: "Authentication required",
|
|
55
|
+
403: "Forbidden",
|
|
56
|
+
404: "Not found",
|
|
57
|
+
409: "Conflict",
|
|
58
|
+
} as const;
|
|
59
|
+
|
|
60
|
+
type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
|
|
61
|
+
|
|
62
|
+
/** OpenAPI error responses for the given statuses, all using the shared error envelope. */
|
|
63
|
+
export function errorResponses<const S extends ErrorStatus[]>(...statuses: S) {
|
|
64
|
+
return Object.fromEntries(
|
|
65
|
+
statuses.map((status) => [status, jsonContent(errorSchema, ERROR_DESCRIPTIONS[status])]),
|
|
66
|
+
) as { [K in S[number]]: ReturnType<typeof jsonContent<typeof errorSchema>> };
|
|
67
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "#core/http/context";
|
|
3
|
+
import { validationHook } from "#core/http/middleware/validation";
|
|
4
|
+
|
|
5
|
+
export const APP_NAMES = [
|
|
6
|
+
"api",
|
|
7
|
+
// tatara:slot app-names
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export type AppName = (typeof APP_NAMES)[number];
|
|
11
|
+
|
|
12
|
+
export interface RouteDefinition {
|
|
13
|
+
/** which HTTP app serves these routes */
|
|
14
|
+
app: AppName;
|
|
15
|
+
/** mounted under `/api` + basePath, e.g. "/notes" */
|
|
16
|
+
basePath: string;
|
|
17
|
+
router: OpenAPIHono<AppEnv>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Default export of every `routes/*.routes.ts`; discovered by `tatara sync`. */
|
|
21
|
+
export function defineRoutes(definition: RouteDefinition): RouteDefinition {
|
|
22
|
+
return definition;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A router for one routes file. Attach middleware per route or per path, never `use("*")`. */
|
|
26
|
+
export function createRouter(): OpenAPIHono<AppEnv> {
|
|
27
|
+
return new OpenAPIHono<AppEnv>({ defaultHook: validationHook });
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
3
|
+
import type { AppEnv } from "#core/http/context";
|
|
4
|
+
import { logger } from "#core/logging/logger";
|
|
5
|
+
import { handleSignals, onShutdown } from "#core/process/lifecycle";
|
|
6
|
+
|
|
7
|
+
/** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
|
|
8
|
+
export function startServer(app: OpenAPIHono<AppEnv>, port: number, name: string) {
|
|
9
|
+
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
10
|
+
logger.info({ port: info.port, app: name }, `${name} listening on http://localhost:${info.port}`);
|
|
11
|
+
});
|
|
12
|
+
onShutdown(`${name}-http`, () => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
13
|
+
handleSignals();
|
|
14
|
+
return server;
|
|
15
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { pino } from "pino";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
|
|
4
|
+
export const logger = pino({
|
|
5
|
+
level: env.LOG_LEVEL,
|
|
6
|
+
base: { service: "__APP_NAME__" },
|
|
7
|
+
...(env.NODE_ENV === "development"
|
|
8
|
+
? {
|
|
9
|
+
transport: {
|
|
10
|
+
target: "pino-pretty",
|
|
11
|
+
options: { colorize: true, ignore: "pid,hostname,service" },
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
: {}),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export type Logger = typeof logger;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { logger } from "#core/logging/logger";
|
|
2
|
+
|
|
3
|
+
type Hook = () => Promise<void> | void;
|
|
4
|
+
|
|
5
|
+
const hooks: { name: string; run: Hook }[] = [];
|
|
6
|
+
let shuttingDown = false;
|
|
7
|
+
|
|
8
|
+
/** Registers cleanup that runs on SIGTERM/SIGINT or `shutdown()`, last registered first. */
|
|
9
|
+
export function onShutdown(name: string, run: Hook): void {
|
|
10
|
+
hooks.push({ name, run });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function shutdown(code = 0): Promise<never> {
|
|
14
|
+
if (!shuttingDown) {
|
|
15
|
+
shuttingDown = true;
|
|
16
|
+
for (const hook of [...hooks].reverse()) {
|
|
17
|
+
try {
|
|
18
|
+
await hook.run();
|
|
19
|
+
} catch (error) {
|
|
20
|
+
logger.error({ err: error, hook: hook.name }, "shutdown hook failed");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
process.exit(code);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function handleSignals(): void {
|
|
28
|
+
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
|
29
|
+
process.once(signal, () => {
|
|
30
|
+
logger.info({ signal }, "shutting down");
|
|
31
|
+
void shutdown(0);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { errorResponses, jsonContent, success, successSchema } from "#core/http/response";
|
|
3
|
+
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
4
|
+
import { greet } from "#modules/example/services/greeting.service";
|
|
5
|
+
import {
|
|
6
|
+
greetingQuerySchema,
|
|
7
|
+
greetingSchema,
|
|
8
|
+
} from "#modules/example/validators/greeting.validators";
|
|
9
|
+
|
|
10
|
+
const router = createRouter();
|
|
11
|
+
|
|
12
|
+
router.openapi(
|
|
13
|
+
createRoute({
|
|
14
|
+
method: "get",
|
|
15
|
+
path: "/",
|
|
16
|
+
tags: ["Example"],
|
|
17
|
+
summary: "Say hello",
|
|
18
|
+
request: { query: greetingQuerySchema },
|
|
19
|
+
responses: {
|
|
20
|
+
200: jsonContent(successSchema(greetingSchema), "A greeting"),
|
|
21
|
+
...errorResponses(400),
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
(c) => c.json(success(greet(c.req.valid("query").name)), 200),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export default defineRoutes({ app: "api", basePath: "/greetings", router });
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { greet } from "#modules/example/services/greeting.service";
|
|
3
|
+
import { api } from "#tests/helpers/api";
|
|
4
|
+
|
|
5
|
+
describe("greetings", () => {
|
|
6
|
+
it("greets by name", () => {
|
|
7
|
+
expect(greet("Ada")).toEqual({ message: "Hello, Ada!" });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("serves GET /api/greetings", async () => {
|
|
11
|
+
const res = await api.get("/api/greetings?name=Ada");
|
|
12
|
+
expect(res.status).toBe(200);
|
|
13
|
+
expect(res.body).toEqual({ success: true, data: { message: "Hello, Ada!" } });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("rejects an invalid query with the error envelope", async () => {
|
|
17
|
+
const res = await api.get(`/api/greetings?name=${"x".repeat(60)}`);
|
|
18
|
+
expect(res.status).toBe(400);
|
|
19
|
+
expect(res.body).toMatchObject({ success: false, code: "VALIDATION_ERROR" });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from "@hono/zod-openapi";
|
|
2
|
+
|
|
3
|
+
export const greetingQuerySchema = z.object({
|
|
4
|
+
name: z.string().min(1).max(50).optional().openapi({ example: "Ada" }),
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export const greetingSchema = z
|
|
8
|
+
.object({ message: z.string().openapi({ example: "Hello, Ada!" }) })
|
|
9
|
+
.openapi("Greeting");
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { app as apiApp } from "#apps/api/app";
|
|
2
|
+
// tatara:slot imports
|
|
3
|
+
|
|
4
|
+
type HttpApp = { request: (path: string, init?: RequestInit) => Response | Promise<Response> };
|
|
5
|
+
|
|
6
|
+
export interface ApiResponse<T = unknown> {
|
|
7
|
+
status: number;
|
|
8
|
+
headers: Headers;
|
|
9
|
+
body: T;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RequestOptions {
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function send<T>(
|
|
17
|
+
target: HttpApp,
|
|
18
|
+
method: string,
|
|
19
|
+
path: string,
|
|
20
|
+
body: unknown,
|
|
21
|
+
options: RequestOptions = {},
|
|
22
|
+
): Promise<ApiResponse<T>> {
|
|
23
|
+
const headers: Record<string, string> = { ...options.headers };
|
|
24
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
25
|
+
const res = await target.request(path, {
|
|
26
|
+
method,
|
|
27
|
+
headers,
|
|
28
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
29
|
+
});
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
let parsed: unknown = text;
|
|
32
|
+
try {
|
|
33
|
+
parsed = text ? JSON.parse(text) : null;
|
|
34
|
+
} catch {}
|
|
35
|
+
return { status: res.status, headers: res.headers, body: parsed as T };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function client(target: HttpApp) {
|
|
39
|
+
return {
|
|
40
|
+
get: <T = unknown>(path: string, o?: RequestOptions) => send<T>(target, "GET", path, undefined, o),
|
|
41
|
+
post: <T = unknown>(path: string, body?: unknown, o?: RequestOptions) =>
|
|
42
|
+
send<T>(target, "POST", path, body, o),
|
|
43
|
+
put: <T = unknown>(path: string, body?: unknown, o?: RequestOptions) =>
|
|
44
|
+
send<T>(target, "PUT", path, body, o),
|
|
45
|
+
patch: <T = unknown>(path: string, body?: unknown, o?: RequestOptions) =>
|
|
46
|
+
send<T>(target, "PATCH", path, body, o),
|
|
47
|
+
delete: <T = unknown>(path: string, o?: RequestOptions) =>
|
|
48
|
+
send<T>(target, "DELETE", path, undefined, o),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Calls the api app in-process (no network). */
|
|
53
|
+
export const api = client(apiApp);
|
|
54
|
+
// tatara:slot exports
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2024",
|
|
4
|
+
"lib": ["es2024"],
|
|
5
|
+
"module": "nodenext",
|
|
6
|
+
"moduleResolution": "nodenext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noImplicitOverride": true,
|
|
10
|
+
"noFallthroughCasesInSwitch": true,
|
|
11
|
+
"erasableSyntaxOnly": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"types": ["node"]
|
|
18
|
+
},
|
|
19
|
+
"include": ["src", "tests", "*.config.ts"]
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from "tsdown";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: {
|
|
5
|
+
"apps/api/server": "src/apps/api/server.ts",
|
|
6
|
+
"apps/cli/index": "src/apps/cli/index.ts",
|
|
7
|
+
// tatara:slot entries
|
|
8
|
+
},
|
|
9
|
+
format: "esm",
|
|
10
|
+
platform: "node",
|
|
11
|
+
target: "node24",
|
|
12
|
+
outDir: "dist",
|
|
13
|
+
dts: false,
|
|
14
|
+
clean: true,
|
|
15
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { parseEnv } from "node:util";
|
|
3
|
+
import { defineConfig } from "vitest/config";
|
|
4
|
+
|
|
5
|
+
const env = existsSync(".env.test")
|
|
6
|
+
? (parseEnv(readFileSync(".env.test", "utf8")) as Record<string, string>)
|
|
7
|
+
: {};
|
|
8
|
+
|
|
9
|
+
export default defineConfig({
|
|
10
|
+
test: {
|
|
11
|
+
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
|
12
|
+
env,
|
|
13
|
+
setupFiles: ["tests/setup.ts"],
|
|
14
|
+
// tatara:slot vitest
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth";
|
|
2
|
+
import { bearer, openAPI } from "better-auth/plugins";
|
|
3
|
+
import { database } from "#core/auth/adapter";
|
|
4
|
+
import { env } from "#core/config/env";
|
|
5
|
+
import { newId } from "#core/db/id";
|
|
6
|
+
// tatara:slot imports
|
|
7
|
+
|
|
8
|
+
export const auth = betterAuth({
|
|
9
|
+
appName: "__APP_NAME__",
|
|
10
|
+
baseURL: env.APP_URL,
|
|
11
|
+
basePath: "/api/auth",
|
|
12
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
13
|
+
trustedOrigins: env.CORS_ORIGINS,
|
|
14
|
+
database,
|
|
15
|
+
advanced: { database: { generateId: () => newId() } },
|
|
16
|
+
// tatara:slot options
|
|
17
|
+
plugins: [
|
|
18
|
+
// Authorization: Bearer <session token> for mobile and API clients
|
|
19
|
+
bearer(),
|
|
20
|
+
// reference for the auth endpoints at /api/auth/reference
|
|
21
|
+
openAPI(),
|
|
22
|
+
// tatara:slot plugins
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export type Session = typeof auth.$Infer.Session;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { auth } from "#core/auth/auth";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { defineSeed } from "#core/db/seed";
|
|
4
|
+
|
|
5
|
+
/** Development admin account (never created in production). */
|
|
6
|
+
export default defineSeed({
|
|
7
|
+
name: "auth:dev-admin",
|
|
8
|
+
order: 0,
|
|
9
|
+
async run({ logger }) {
|
|
10
|
+
if (env.NODE_ENV !== "development" && env.NODE_ENV !== "test") return;
|
|
11
|
+
const ctx = await auth.$context;
|
|
12
|
+
const email = env.SEED_ADMIN_EMAIL;
|
|
13
|
+
const existing = await ctx.internalAdapter.findUserByEmail(email);
|
|
14
|
+
if (existing) return;
|
|
15
|
+
const user = await ctx.internalAdapter.createUser({
|
|
16
|
+
email,
|
|
17
|
+
name: "Dev admin",
|
|
18
|
+
emailVerified: true,
|
|
19
|
+
// tatara:slot seed-user-fields
|
|
20
|
+
}, { method: "admin" });
|
|
21
|
+
// tatara:slot seed-after-user
|
|
22
|
+
logger.info({ email, userId: user.id }, "created development admin");
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { smsSender } from "#core/auth/sms";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
// tatara:slot imports
|
|
4
|
+
|
|
5
|
+
export interface OutboxMessage {
|
|
6
|
+
channel: "email" | "sms";
|
|
7
|
+
to: string;
|
|
8
|
+
subject?: string;
|
|
9
|
+
text: string;
|
|
10
|
+
/** one-time code, when the message carries one */
|
|
11
|
+
code?: string;
|
|
12
|
+
sentAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const outbox: OutboxMessage[] = [];
|
|
16
|
+
|
|
17
|
+
/** Only with DEV_OUTBOX=true in development or test: the outbox exposes sign-in codes. */
|
|
18
|
+
export function outboxEnabled(): boolean {
|
|
19
|
+
return env.DEV_OUTBOX && (env.NODE_ENV === "development" || env.NODE_ENV === "test");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Messages are kept here so tests and local sign-in can read codes. */
|
|
23
|
+
function record(message: Omit<OutboxMessage, "sentAt">) {
|
|
24
|
+
if (!outboxEnabled()) return;
|
|
25
|
+
outbox.push({ ...message, sentAt: new Date().toISOString() });
|
|
26
|
+
if (outbox.length > 200) outbox.shift();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Newest first. Empty unless the outbox is enabled. */
|
|
30
|
+
export function readOutbox(to?: string): OutboxMessage[] {
|
|
31
|
+
if (!outboxEnabled()) return [];
|
|
32
|
+
return outbox.filter((m) => !to || m.to === to).reverse();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function deliverEmail(message: {
|
|
36
|
+
to: string;
|
|
37
|
+
subject: string;
|
|
38
|
+
text: string;
|
|
39
|
+
code?: string;
|
|
40
|
+
}): Promise<void> {
|
|
41
|
+
record({ channel: "email", ...message });
|
|
42
|
+
// tatara:slot email-delivery
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function deliverSms(message: { to: string; text: string; code?: string }): Promise<void> {
|
|
46
|
+
record({ channel: "sms", ...message });
|
|
47
|
+
await smsSender.send(message.to, message.text);
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import { auth } from "#core/auth/auth";
|
|
3
|
+
import type { AppEnv } from "#core/http/context";
|
|
4
|
+
|
|
5
|
+
/** Resolves the session (cookie or bearer token) into `c.var.principal`. */
|
|
6
|
+
export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
|
|
7
|
+
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
8
|
+
if (session) {
|
|
9
|
+
const user = session.user as typeof session.user & { role?: string | null };
|
|
10
|
+
const s = session.session as typeof session.session & { activeOrganizationId?: string | null };
|
|
11
|
+
c.set("principal", {
|
|
12
|
+
userId: user.id,
|
|
13
|
+
orgId: s.activeOrganizationId ?? null,
|
|
14
|
+
roles: (user.role ?? "user")
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((role) => role.trim())
|
|
17
|
+
.filter(Boolean),
|
|
18
|
+
kind: "user",
|
|
19
|
+
claims: {},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
await next();
|
|
23
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { logger } from "#core/logging/logger";
|
|
2
|
+
|
|
3
|
+
/** Implement this for your SMS provider and return it from `smsSender`. */
|
|
4
|
+
export interface SmsSender {
|
|
5
|
+
send(to: string, text: string): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const consoleSender: SmsSender = {
|
|
9
|
+
async send(to) {
|
|
10
|
+
logger.info({ to }, "sms not sent: no SMS provider configured (see src/core/auth/sms.ts)");
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const smsSender: SmsSender = consoleSender;
|