@tatara/cli 0.0.0-stage → 0.1.1

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.
Files changed (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bin.mjs +3001 -0
  3. package/package.json +32 -3
  4. package/templates/features/admin/files/src/apps/admin/app.ts +4 -0
  5. package/templates/features/admin/files/src/apps/admin/server.ts +5 -0
  6. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +24 -0
  7. package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +20 -0
  8. package/templates/features/auth/files/src/core/auth/middleware.ts +29 -0
  9. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +36 -0
  10. package/templates/features/auth/files/src/core/auth/tests/me.test.ts +25 -0
  11. package/templates/features/auth-orgs/files+drizzle+mysql/src/core/auth/db/organization.schema.ts +38 -0
  12. package/templates/features/auth-orgs/files+drizzle+postgres/src/core/auth/db/organization.schema.ts +38 -0
  13. package/templates/features/auth-orgs/files+prisma/prisma/models/organization.prisma +41 -0
  14. package/templates/features/authz/files/src/core/authz/cache.ts +31 -0
  15. package/templates/features/authz/files/src/core/authz/catalog.ts +85 -0
  16. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +21 -0
  17. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +11 -0
  18. package/templates/features/authz/files/src/core/authz/constants.ts +32 -0
  19. package/templates/features/authz/files/src/core/authz/db/seed.ts +10 -0
  20. package/templates/features/authz/files/src/core/authz/middleware.ts +21 -0
  21. package/templates/features/authz/files/src/core/authz/permissions.ts +31 -0
  22. package/templates/features/authz/files/src/core/authz/resolver.ts +98 -0
  23. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +281 -0
  24. package/templates/features/authz/files/src/core/authz/services/audit.ts +24 -0
  25. package/templates/features/authz/files/src/core/authz/services/grants.ts +29 -0
  26. package/templates/features/authz/files/src/core/authz/services/members.ts +84 -0
  27. package/templates/features/authz/files/src/core/authz/services/roles.ts +73 -0
  28. package/templates/features/authz/files/src/core/authz/services/sync.ts +34 -0
  29. package/templates/features/authz/files/src/core/authz/system-roles.ts +9 -0
  30. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +146 -0
  31. package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
  32. package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
  33. package/templates/features/authz/files/src/core/authz/types.ts +87 -0
  34. package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
  35. package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
  36. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +275 -0
  37. package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
  38. package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
  39. package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
  40. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +236 -0
  41. package/templates/features/base/files/AGENTS.md +71 -0
  42. package/templates/features/base/files/CLAUDE.md +1 -0
  43. package/templates/features/base/files/Dockerfile +16 -0
  44. package/templates/features/base/files/README.md +31 -0
  45. package/templates/features/base/files/_github/workflows/ci.yml +26 -0
  46. package/templates/features/base/files/biome.template.json +20 -0
  47. package/templates/features/base/files/dockerignore +8 -0
  48. package/templates/features/base/files/gitignore +9 -0
  49. package/templates/features/base/files/src/apps/api/app.ts +3 -0
  50. package/templates/features/base/files/src/apps/api/server.ts +5 -0
  51. package/templates/features/base/files/src/apps/cli/index.ts +6 -0
  52. package/templates/features/base/files/src/core/commands/define.ts +33 -0
  53. package/templates/features/base/files/src/core/commands/runner.ts +54 -0
  54. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
  55. package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
  56. package/templates/features/base/files/src/core/http/app.ts +57 -0
  57. package/templates/features/base/files/src/core/http/context.ts +18 -0
  58. package/templates/features/base/files/src/core/http/errors.ts +79 -0
  59. package/templates/features/base/files/src/core/http/health.ts +25 -0
  60. package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
  61. package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
  62. package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
  63. package/templates/features/base/files/src/core/http/response.ts +72 -0
  64. package/templates/features/base/files/src/core/http/routes.ts +28 -0
  65. package/templates/features/base/files/src/core/http/server.ts +15 -0
  66. package/templates/features/base/files/src/core/logging/logger.ts +17 -0
  67. package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
  68. package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
  69. package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
  70. package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
  71. package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
  72. package/templates/features/base/files/tests/helpers/api.ts +54 -0
  73. package/templates/features/base/files/tests/setup.ts +2 -0
  74. package/templates/features/base/files/tsconfig.json +20 -0
  75. package/templates/features/base/files/tsdown.config.ts +15 -0
  76. package/templates/features/base/files/vitest.config.ts +16 -0
  77. package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
  78. package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
  79. package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
  80. package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
  81. package/templates/features/better-auth/files/src/core/auth/principal.ts +40 -0
  82. package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
  83. package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
  84. package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
  85. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
  86. package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
  87. package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
  88. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
  89. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
  90. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
  91. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
  92. package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
  93. package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
  94. package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
  95. package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
  96. package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
  97. package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
  98. package/templates/features/db/files/src/core/db/id.ts +13 -0
  99. package/templates/features/db/files/src/core/db/seed.ts +14 -0
  100. package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
  101. package/templates/features/db/files/tests/global-setup.ts +27 -0
  102. package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
  103. package/templates/features/db/files/tests/helpers/db.ts +7 -0
  104. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
  105. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
  106. package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
  107. package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
  108. package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
  109. package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
  110. package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
  111. package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
  112. package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
  113. package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
  114. package/templates/features/email/files/src/core/email/send.ts +22 -0
  115. package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
  116. package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
  117. package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
  118. package/templates/features/i18n/files/src/locales/ar.json +10 -0
  119. package/templates/features/i18n/files/src/locales/en.json +10 -0
  120. package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
  121. package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
  122. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
  123. package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
  124. package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
  125. package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
  126. package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
  127. package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
  128. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
  129. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
  130. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
  131. package/templates/features/keycloak/files/keycloak/realm.json +83 -0
  132. package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
  133. package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
  134. package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
  135. package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
  136. package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
  137. package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
  138. package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
  139. package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
  140. package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
  141. package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
  142. package/templates/features/prisma/files/prisma.config.ts +10 -0
  143. package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
  144. package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
  145. package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
  146. package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
  147. package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
  148. package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
  149. package/templates/features/storage/files/src/core/storage/index.ts +8 -0
  150. package/templates/features/storage/files/src/core/storage/local.ts +34 -0
  151. package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
  152. package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
  153. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
  154. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
  155. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
  156. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
  157. package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
  158. package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
  159. package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
  160. package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
  161. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
  162. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +127 -0
  163. package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
  164. package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
  165. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
  166. package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
  167. package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  168. package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  169. package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  170. package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
  171. package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
  172. package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
  173. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
  174. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
  175. package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  176. package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  177. package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  178. package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
  179. package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
  180. package/templates/versions.json +34 -0
  181. package/README.md +0 -3
@@ -0,0 +1,54 @@
1
+ import type { CommandDefinition } from "#core/commands/define";
2
+ import { logger } from "#core/logging/logger";
3
+
4
+ /** Parses `--key value`, `--key=value` and bare `--flag` (true) into an object. */
5
+ export function parseFlags(argv: string[]): Record<string, string | boolean> {
6
+ const flags: Record<string, string | boolean> = {};
7
+ for (let i = 0; i < argv.length; i++) {
8
+ const arg = argv[i] ?? "";
9
+ if (!arg.startsWith("--")) continue;
10
+ const [rawKey, inline] = arg.slice(2).split("=", 2);
11
+ const key = (rawKey ?? "").replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase());
12
+ if (inline !== undefined) {
13
+ flags[key] = inline;
14
+ } else if (argv[i + 1] !== undefined && !argv[i + 1]?.startsWith("--")) {
15
+ flags[key] = argv[i + 1] ?? "";
16
+ i++;
17
+ } else {
18
+ flags[key] = true;
19
+ }
20
+ }
21
+ return flags;
22
+ }
23
+
24
+ export async function runCommand(commands: CommandDefinition[], argv: string[]): Promise<number> {
25
+ const [name, ...rest] = argv;
26
+ if (!name || name === "--list" || name === "list" || name === "help") {
27
+ const width = Math.max(0, ...commands.map((c) => c.name.length));
28
+ console.log("Commands:");
29
+ for (const c of [...commands].sort((a, b) => a.name.localeCompare(b.name))) {
30
+ console.log(` ${c.name.padEnd(width)} ${c.description}`);
31
+ }
32
+ return 0;
33
+ }
34
+ const command = commands.find((c) => c.name === name);
35
+ if (!command) {
36
+ console.error(`Unknown command "${name}". Run with --list to see available commands.`);
37
+ return 1;
38
+ }
39
+ const parsed = (command.args ?? null)?.safeParse(parseFlags(rest));
40
+ if (parsed && !parsed.success) {
41
+ console.error(`Invalid arguments for ${name}:`);
42
+ for (const issue of parsed.error.issues) {
43
+ console.error(` --${issue.path.join(".")}: ${issue.message}`);
44
+ }
45
+ return 1;
46
+ }
47
+ try {
48
+ await command.run(parsed ? parsed.data : {}, { logger: logger.child({ command: name }) });
49
+ return 0;
50
+ } catch (error) {
51
+ logger.error({ err: error, command: name }, "command failed");
52
+ return 1;
53
+ }
54
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+ import { flag } from "#core/commands/define";
4
+ import { parseFlags } from "#core/commands/runner";
5
+
6
+ const args = z.object({ force: flag() });
7
+ const parse = (argv: string[]) => args.parse(parseFlags(argv)).force;
8
+
9
+ describe("flag()", () => {
10
+ it("is true for a bare flag", () => expect(parse(["--force"])).toBe(true));
11
+ it("reads --flag false as false", () => expect(parse(["--force", "false"])).toBe(false));
12
+ it("reads --flag=0 as false", () => expect(parse(["--force=0"])).toBe(false));
13
+ it("reads --flag true as true", () => expect(parse(["--force", "true"])).toBe(true));
14
+ it("defaults to false", () => expect(parse([])).toBe(false));
15
+ it("rejects anything else", () => expect(() => parse(["--force", "maybe"])).toThrow());
16
+ });
@@ -0,0 +1,40 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // .env.test is loaded into process.env, so it holds every variable this app requires
4
+ const saved = { ...process.env };
5
+
6
+ const LOCAL_DEFAULTS = [
7
+ "APP_URL",
8
+ "CORS_ORIGINS",
9
+ "EMAIL_DRIVER",
10
+ "STORAGE_DRIVER",
11
+ "SMTP_URL",
12
+ "KEYCLOAK_URL",
13
+ ].filter((key) => saved[key] !== undefined);
14
+
15
+ async function loadEnv(vars: Record<string, string | undefined>) {
16
+ vi.resetModules();
17
+ process.env = { ...vars } as NodeJS.ProcessEnv;
18
+ return (await import("#core/config/env")).env;
19
+ }
20
+
21
+ afterEach(() => {
22
+ process.env = { ...saved };
23
+ vi.resetModules();
24
+ });
25
+
26
+ describe("env in production", () => {
27
+ it("loads when every setting is given", async () => {
28
+ const env = await loadEnv({ ...saved, NODE_ENV: "production" });
29
+ expect(env.NODE_ENV).toBe("production");
30
+ });
31
+
32
+ it.each(LOCAL_DEFAULTS)("refuses to fall back to the local default for %s", async (key) => {
33
+ await expect(loadEnv({ ...saved, NODE_ENV: "production", [key]: undefined })).rejects.toThrow();
34
+ });
35
+
36
+ it("keeps local defaults outside production", async () => {
37
+ const env = await loadEnv({ ...saved, NODE_ENV: "development", APP_URL: undefined });
38
+ expect(env.APP_URL).toBe("http://localhost:3000");
39
+ });
40
+ });
@@ -0,0 +1,57 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import { Scalar } from "@scalar/hono-api-reference";
3
+ import { bodyLimit } from "hono/body-limit";
4
+ import { cors } from "hono/cors";
5
+ import { secureHeaders } from "hono/secure-headers";
6
+ import { env } from "#core/config/env";
7
+ import type { AppEnv } from "#core/http/context";
8
+ import { runReadinessChecks } from "#core/http/health";
9
+ import { errorHandler, notFoundHandler } from "#core/http/middleware/error-handler";
10
+ import { requestContext } from "#core/http/middleware/request-context";
11
+ import { validationHook } from "#core/http/middleware/validation";
12
+ import type { AppName } from "#core/http/routes";
13
+ import { routes } from "#generated/routes";
14
+ // tatara:slot imports
15
+
16
+ export const API_PREFIX = "/api";
17
+
18
+ export function createHttpApp(name: AppName, options: { title: string }) {
19
+ const app = new OpenAPIHono<AppEnv>({ defaultHook: validationHook });
20
+
21
+ app.use(requestContext);
22
+ app.use(secureHeaders());
23
+ app.use(
24
+ cors({
25
+ origin: env.CORS_ORIGINS,
26
+ credentials: true,
27
+ allowHeaders: ["Content-Type", "Authorization", "X-Request-Id", "Accept-Language"],
28
+ exposeHeaders: ["X-Request-Id"],
29
+ }),
30
+ );
31
+ app.use(bodyLimit({ maxSize: env.BODY_LIMIT_BYTES }));
32
+ // tatara:slot middleware
33
+
34
+ app.get("/health", (c) => c.json({ status: "ok" }));
35
+ app.get("/ready", async (c) => {
36
+ const result = await runReadinessChecks();
37
+ return c.json(result, result.ready ? 200 : 503);
38
+ });
39
+
40
+ // tatara:slot routes
41
+
42
+ for (const definition of routes) {
43
+ if (definition.app === name) app.route(`${API_PREFIX}${definition.basePath}`, definition.router);
44
+ }
45
+
46
+ const openApiConfig = { openapi: "3.1.0", info: { title: options.title, version: "1.0.0" } } as const;
47
+ app.get("/openapi.json", async (c) => {
48
+ const document = app.getOpenAPI31Document(openApiConfig);
49
+ // tatara:slot openapi
50
+ return c.json(document);
51
+ });
52
+ app.get("/docs", Scalar({ url: "/openapi.json", pageTitle: options.title }));
53
+
54
+ app.onError(errorHandler);
55
+ app.notFound(notFoundHandler);
56
+ return app;
57
+ }
@@ -0,0 +1,18 @@
1
+ import type { Logger } from "#core/logging/logger";
2
+
3
+ export interface Principal {
4
+ userId: string;
5
+ orgId: string | null;
6
+ roles: string[];
7
+ kind: "user" | "service";
8
+ claims: Record<string, unknown>;
9
+ }
10
+
11
+ export interface AppEnv {
12
+ Variables: {
13
+ requestId: string;
14
+ logger: Logger;
15
+ principal: Principal | null;
16
+ language: string;
17
+ };
18
+ }
@@ -0,0 +1,79 @@
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
+ /** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
8
+ readonly translatable: boolean;
9
+
10
+ constructor(
11
+ status: ContentfulStatusCode,
12
+ code: string,
13
+ message: string,
14
+ details?: unknown,
15
+ translatable = false,
16
+ ) {
17
+ super(message);
18
+ this.name = new.target.name;
19
+ this.status = status;
20
+ this.code = code;
21
+ this.details = details;
22
+ this.translatable = translatable;
23
+ }
24
+ }
25
+
26
+ export class ValidationError extends AppError {
27
+ constructor(message?: string, details?: unknown) {
28
+ super(400, "VALIDATION_ERROR", message ?? "Invalid request", details, message === undefined);
29
+ }
30
+ }
31
+
32
+ export class BusinessRuleError extends AppError {
33
+ constructor(code: string, message: string, details?: unknown) {
34
+ // codes are specific to the rule, so a translation for the code is never less precise
35
+ super(400, code, message, details, true);
36
+ }
37
+ }
38
+
39
+ export class UnauthorizedError extends AppError {
40
+ constructor(message?: string) {
41
+ super(
42
+ 401,
43
+ "UNAUTHORIZED",
44
+ message ?? "Authentication required",
45
+ undefined,
46
+ message === undefined,
47
+ );
48
+ }
49
+ }
50
+
51
+ export class ForbiddenError extends AppError {
52
+ constructor(message?: string) {
53
+ super(
54
+ 403,
55
+ "FORBIDDEN",
56
+ message ?? "You do not have permission to do this",
57
+ undefined,
58
+ message === undefined,
59
+ );
60
+ }
61
+ }
62
+
63
+ export class NotFoundError extends AppError {
64
+ constructor(resource?: string) {
65
+ super(
66
+ 404,
67
+ "NOT_FOUND",
68
+ `${resource ?? "Resource"} not found`,
69
+ undefined,
70
+ resource === undefined,
71
+ );
72
+ }
73
+ }
74
+
75
+ export class ConflictError extends AppError {
76
+ constructor(message?: string, details?: unknown) {
77
+ super(409, "CONFLICT", message ?? "Resource already exists", details, message === undefined);
78
+ }
79
+ }
@@ -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
+ undefined,
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,72 @@
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
+ /** A required JSON request body. Without `required`, a request with no body skips validation. */
53
+ export function jsonBody<T extends z.ZodType>(schema: T, description: string) {
54
+ return { ...jsonContent(schema, description), required: true };
55
+ }
56
+
57
+ const ERROR_DESCRIPTIONS = {
58
+ 400: "Invalid request",
59
+ 401: "Authentication required",
60
+ 403: "Forbidden",
61
+ 404: "Not found",
62
+ 409: "Conflict",
63
+ } as const;
64
+
65
+ type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
66
+
67
+ /** OpenAPI error responses for the given statuses, all using the shared error envelope. */
68
+ export function errorResponses<const S extends ErrorStatus[]>(...statuses: S) {
69
+ return Object.fromEntries(
70
+ statuses.map((status) => [status, jsonContent(errorSchema, ERROR_DESCRIPTIONS[status])]),
71
+ ) as { [K in S[number]]: ReturnType<typeof jsonContent<typeof errorSchema>> };
72
+ }
@@ -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,3 @@
1
+ export function greet(name?: string): { message: string } {
2
+ return { message: `Hello, ${name ?? "world"}!` };
3
+ }
@@ -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,2 @@
1
+ // Runs in every test worker before the test files.
2
+ // tatara:slot setup