@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,37 @@
1
+ import type { z } from "zod";
2
+ import type { Logger } from "#core/logging/logger";
3
+
4
+ export interface JobContext {
5
+ logger: Logger;
6
+ jobId: string;
7
+ attempt: number;
8
+ }
9
+
10
+ export interface ConsumerDefinition<S extends z.ZodType = z.ZodType> {
11
+ /** job name, e.g. "billing.invoice-paid" */
12
+ topic: string;
13
+ description: string;
14
+ schema: S;
15
+ /** retries before the job is marked failed (default 3) */
16
+ attempts?: number;
17
+ handle(data: z.infer<S>, ctx: JobContext): Promise<void>;
18
+ }
19
+
20
+ /** Default export of every `consumers/*.ts`; run by the worker app. */
21
+ export function defineConsumer<S extends z.ZodType>(definition: ConsumerDefinition<S>) {
22
+ return definition;
23
+ }
24
+
25
+ export interface ScheduleDefinition {
26
+ name: string;
27
+ /** cron expression (seconds optional), evaluated in UTC */
28
+ cron: string;
29
+ /** how long the run holds its lock; one run per tick across all cron processes */
30
+ lockTtlMs?: number;
31
+ run(ctx: { logger: Logger }): Promise<void>;
32
+ }
33
+
34
+ /** Default export of every `schedules/*.ts`; run by the cron app. */
35
+ export function defineSchedule(definition: ScheduleDefinition): ScheduleDefinition {
36
+ return definition;
37
+ }
@@ -0,0 +1,17 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { redis } from "#core/jobs/redis";
3
+
4
+ const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
5
+
6
+ /** Runs `fn` only if this process wins the lock; returns false when another holder has it. */
7
+ export async function withLock(key: string, ttlMs: number, fn: () => Promise<void>): Promise<boolean> {
8
+ const token = randomUUID();
9
+ const acquired = await redis().set(`lock:${key}`, token, "PX", ttlMs, "NX");
10
+ if (acquired !== "OK") return false;
11
+ try {
12
+ await fn();
13
+ } finally {
14
+ await redis().eval(RELEASE, 1, `lock:${key}`, token);
15
+ }
16
+ return true;
17
+ }
@@ -0,0 +1,19 @@
1
+ import type { ConsumerDefinition, JobContext } from "#core/jobs/define";
2
+
3
+ export class UnknownTopicError extends Error {}
4
+
5
+ /** Validates and runs one job; shared by the worker and by tests. */
6
+ export async function processJob(
7
+ consumers: ConsumerDefinition[],
8
+ topic: string,
9
+ data: unknown,
10
+ ctx: JobContext,
11
+ ): Promise<void> {
12
+ const consumer = consumers.find((c) => c.topic === topic);
13
+ if (!consumer) throw new UnknownTopicError(`No consumer for topic "${topic}"`);
14
+ const parsed = consumer.schema.safeParse(data);
15
+ if (!parsed.success) {
16
+ throw new Error(`Invalid payload for ${topic}: ${parsed.error.message}`);
17
+ }
18
+ await consumer.handle(parsed.data, ctx);
19
+ }
@@ -0,0 +1,29 @@
1
+ import { type JobsOptions, Queue } from "bullmq";
2
+ import { env } from "#core/config/env";
3
+ import { redis } from "#core/jobs/redis";
4
+ import { onShutdown } from "#core/process/lifecycle";
5
+
6
+ export const QUEUE_NAME = "__APP_NAME__-jobs";
7
+
8
+ let queue: Queue | undefined;
9
+
10
+ function getQueue(): Queue {
11
+ if (!queue) {
12
+ const q = new Queue(QUEUE_NAME, { connection: redis(), prefix: env.JOBS_PREFIX });
13
+ queue = q;
14
+ onShutdown("queue", () => q.close());
15
+ }
16
+ return queue;
17
+ }
18
+
19
+ /** Enqueues a job for the consumer with this topic. The consumer validates the payload. */
20
+ export async function enqueue(topic: string, data: unknown, options: JobsOptions = {}): Promise<string> {
21
+ const job = await getQueue().add(topic, data, {
22
+ attempts: 3,
23
+ backoff: { type: "exponential", delay: 1000 },
24
+ removeOnComplete: 1000,
25
+ removeOnFail: 5000,
26
+ ...options,
27
+ });
28
+ return job.id ?? "";
29
+ }
@@ -0,0 +1,17 @@
1
+ import { Redis } from "ioredis";
2
+ import { env } from "#core/config/env";
3
+ import { onShutdown } from "#core/process/lifecycle";
4
+
5
+ let client: Redis | undefined;
6
+
7
+ /** Shared connection (BullMQ requires maxRetriesPerRequest: null). */
8
+ export function redis(): Redis {
9
+ if (!client) {
10
+ const connection = new Redis(env.REDIS_URL, { maxRetriesPerRequest: null });
11
+ client = connection;
12
+ onShutdown("redis", async () => {
13
+ await connection.quit();
14
+ });
15
+ }
16
+ return client;
17
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ import { defineConsumer } from "#core/jobs/define";
3
+
4
+ export default defineConsumer({
5
+ topic: "example.send-welcome",
6
+ description: "Example consumer: greets a new user",
7
+ schema: z.object({ name: z.string().min(1) }),
8
+ async handle({ name }, { logger }) {
9
+ logger.info({ name }, "welcome sent");
10
+ },
11
+ });
@@ -0,0 +1,9 @@
1
+ import { defineSchedule } from "#core/jobs/define";
2
+
3
+ export default defineSchedule({
4
+ name: "example.heartbeat",
5
+ cron: "*/5 * * * *",
6
+ async run({ logger }) {
7
+ logger.info("heartbeat");
8
+ },
9
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { withLock } from "#core/jobs/lock";
3
+ import { processJob } from "#core/jobs/process";
4
+ import { logger } from "#core/logging/logger";
5
+ import { consumers } from "#generated/consumers";
6
+
7
+ const ctx = { logger, jobId: "test", attempt: 1 };
8
+
9
+ describe("jobs", () => {
10
+ it("runs the consumer for a topic with a valid payload", async () => {
11
+ await expect(processJob(consumers, "example.send-welcome", { name: "Ada" }, ctx)).resolves.toBeUndefined();
12
+ });
13
+
14
+ it("rejects an invalid payload", async () => {
15
+ await expect(processJob(consumers, "example.send-welcome", { name: "" }, ctx)).rejects.toThrow(
16
+ /Invalid payload/,
17
+ );
18
+ });
19
+
20
+ it("lets only one holder run a locked section", async () => {
21
+ const key = `test-${Date.now()}-${Math.random()}`;
22
+ let inner: boolean | undefined;
23
+ const outer = await withLock(key, 5000, async () => {
24
+ inner = await withLock(key, 5000, async () => {});
25
+ });
26
+ expect(outer).toBe(true);
27
+ expect(inner).toBe(false);
28
+ });
29
+ });
@@ -0,0 +1,83 @@
1
+ {
2
+ "realm": "__APP_NAME__",
3
+ "enabled": true,
4
+ "displayName": "__APP_NAME__ (development)",
5
+ "sslRequired": "none",
6
+ "accessTokenLifespan": 900,
7
+ "registrationAllowed": false,
8
+ "roles": {
9
+ "realm": [
10
+ { "name": "user", "description": "Signed-in user" },
11
+ { "name": "admin", "description": "Administrator" }
12
+ ]
13
+ },
14
+ "clients": [
15
+ {
16
+ "clientId": "__APP_NAME__-api",
17
+ "name": "__APP_NAME__ API (token audience)",
18
+ "enabled": true,
19
+ "publicClient": false,
20
+ "bearerOnly": true,
21
+ "standardFlowEnabled": false,
22
+ "directAccessGrantsEnabled": false
23
+ },
24
+ {
25
+ "clientId": "__APP_NAME__-web",
26
+ "name": "__APP_NAME__ web (public, PKCE)",
27
+ "enabled": true,
28
+ "publicClient": true,
29
+ "standardFlowEnabled": true,
30
+ "directAccessGrantsEnabled": true,
31
+ "redirectUris": ["http://localhost:5173/*", "http://localhost:3000/*"],
32
+ "webOrigins": ["+"],
33
+ "attributes": { "pkce.code.challenge.method": "S256", "post.logout.redirect.uris": "+" },
34
+ "protocolMappers": [
35
+ {
36
+ "name": "api-audience",
37
+ "protocol": "openid-connect",
38
+ "protocolMapper": "oidc-audience-mapper",
39
+ "config": {
40
+ "included.client.audience": "__APP_NAME__-api",
41
+ "access.token.claim": "true",
42
+ "id.token.claim": "false"
43
+ }
44
+ },
45
+ {
46
+ "name": "realm-roles",
47
+ "protocol": "openid-connect",
48
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
49
+ "config": {
50
+ "claim.name": "roles",
51
+ "multivalued": "true",
52
+ "jsonType.label": "String",
53
+ "access.token.claim": "true",
54
+ "id.token.claim": "false",
55
+ "userinfo.token.claim": "false"
56
+ }
57
+ }
58
+ ]
59
+ }
60
+ ],
61
+ "users": [
62
+ {
63
+ "username": "alice",
64
+ "email": "alice@example.com",
65
+ "emailVerified": true,
66
+ "firstName": "Alice",
67
+ "lastName": "Admin",
68
+ "enabled": true,
69
+ "credentials": [{ "type": "password", "value": "alice", "temporary": false }],
70
+ "realmRoles": ["user", "admin"]
71
+ },
72
+ {
73
+ "username": "bob",
74
+ "email": "bob@example.com",
75
+ "emailVerified": true,
76
+ "firstName": "Bob",
77
+ "lastName": "User",
78
+ "enabled": true,
79
+ "credentials": [{ "type": "password", "value": "bob", "temporary": false }],
80
+ "realmRoles": ["user"]
81
+ }
82
+ ]
83
+ }
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+ import { issuer } from "#core/auth/keycloak";
3
+ import { defineCommand } from "#core/commands/define";
4
+ import { env } from "#core/config/env";
5
+
6
+ export default defineCommand({
7
+ name: "keycloak:token",
8
+ description: "Print an access token for a dev-realm user (development only)",
9
+ args: z.object({
10
+ user: z.string().default("alice"),
11
+ password: z.string().optional(),
12
+ client: z.string().default("__APP_NAME__-web"),
13
+ }),
14
+ async run({ user, password, client }) {
15
+ if (env.NODE_ENV === "production") throw new Error("keycloak:token is for development only");
16
+ const res = await fetch(`${issuer}/protocol/openid-connect/token`, {
17
+ method: "POST",
18
+ headers: { "content-type": "application/x-www-form-urlencoded" },
19
+ body: new URLSearchParams({
20
+ grant_type: "password",
21
+ client_id: client,
22
+ username: user,
23
+ password: password ?? user,
24
+ }),
25
+ });
26
+ const body = (await res.json()) as { access_token?: string; error_description?: string };
27
+ if (!res.ok || !body.access_token) {
28
+ throw new Error(`Keycloak refused: ${body.error_description ?? res.statusText}`);
29
+ }
30
+ console.log(body.access_token);
31
+ },
32
+ });
@@ -0,0 +1,58 @@
1
+ import { createRemoteJWKSet, type JWTPayload, type JWTVerifyGetKey, jwtVerify } from "jose";
2
+ import { env } from "#core/config/env";
3
+
4
+ export const issuer = `${env.KEYCLOAK_URL.replace(/\/$/, "")}/realms/${env.KEYCLOAK_REALM}`;
5
+
6
+ let keySource: JWTVerifyGetKey = createRemoteJWKSet(
7
+ new URL(`${issuer}/protocol/openid-connect/certs`),
8
+ );
9
+
10
+ /** Tests replace the realm's key set with a local one. */
11
+ export function setKeySource(source: JWTVerifyGetKey): void {
12
+ keySource = source;
13
+ }
14
+
15
+ export interface KeycloakClaims extends JWTPayload {
16
+ sub: string;
17
+ email?: string;
18
+ name?: string;
19
+ preferred_username?: string;
20
+ azp?: string;
21
+ roles?: string[];
22
+ realm_access?: { roles?: string[] };
23
+ resource_access?: Record<string, { roles?: string[] }>;
24
+ organization?: string[] | Record<string, unknown>;
25
+ }
26
+
27
+ /** Verifies signature, issuer, audience and expiry. Throws when the token is not valid. */
28
+ export async function verifyAccessToken(token: string): Promise<KeycloakClaims> {
29
+ const { payload } = await jwtVerify(token, keySource, {
30
+ issuer,
31
+ audience: env.KEYCLOAK_AUDIENCE,
32
+ });
33
+ if (typeof payload.sub !== "string") throw new Error("token has no subject");
34
+ return payload as KeycloakClaims;
35
+ }
36
+
37
+ export function rolesOf(claims: KeycloakClaims): string[] {
38
+ const roles = new Set<string>([
39
+ ...(claims.roles ?? []),
40
+ ...(claims.realm_access?.roles ?? []),
41
+ ...(claims.resource_access?.[env.KEYCLOAK_AUDIENCE]?.roles ?? []),
42
+ ]);
43
+ return [...roles];
44
+ }
45
+
46
+ /** Keycloak organizations put memberships in the `organization` claim; the first one wins. */
47
+ export function organizationOf(claims: KeycloakClaims): string | null {
48
+ const org = claims.organization;
49
+ if (Array.isArray(org)) return org[0] ?? null;
50
+ if (org && typeof org === "object") {
51
+ const [first] = Object.entries(org);
52
+ if (!first) return null;
53
+ const [alias, value] = first;
54
+ const id = (value as { id?: unknown } | undefined)?.id;
55
+ return typeof id === "string" ? id : alias;
56
+ }
57
+ return null;
58
+ }
@@ -0,0 +1,27 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import { organizationOf, rolesOf, verifyAccessToken } from "#core/auth/keycloak";
3
+ import { ensureLocalUser } from "#core/auth/users";
4
+ import type { AppEnv } from "#core/http/context";
5
+
6
+ /** Verifies `Authorization: Bearer <Keycloak access token>` into `c.var.principal`. */
7
+ export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
8
+ const header = c.req.header("authorization");
9
+ const token = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
10
+ if (token) {
11
+ try {
12
+ const claims = await verifyAccessToken(token);
13
+ const isService = claims.preferred_username?.startsWith("service-account-") ?? false;
14
+ if (!isService) await ensureLocalUser(claims);
15
+ c.set("principal", {
16
+ userId: claims.sub,
17
+ orgId: organizationOf(claims),
18
+ roles: rolesOf(claims),
19
+ kind: isService ? "service" : "user",
20
+ claims,
21
+ });
22
+ } catch (error) {
23
+ c.get("logger").debug({ err: error }, "rejected bearer token");
24
+ }
25
+ }
26
+ await next();
27
+ });
@@ -0,0 +1,52 @@
1
+ import { generateKeyPair, SignJWT } from "jose";
2
+ import { issuer, setKeySource } from "#core/auth/keycloak";
3
+ import { env } from "#core/config/env";
4
+ import { newId } from "#core/db/id";
5
+ // tatara:slot imports
6
+
7
+ const { publicKey, privateKey } = await generateKeyPair("RS256");
8
+ setKeySource(async (header) => {
9
+ if (header.kid !== "test") throw new Error("unknown key");
10
+ return publicKey;
11
+ });
12
+
13
+ export interface AsUserOptions {
14
+ sub?: string;
15
+ email?: string;
16
+ roles?: string[];
17
+ orgId?: string;
18
+ // tatara:slot options
19
+ }
20
+
21
+ /** Signs a token the API accepts, as if Keycloak issued it for a new user. */
22
+ export async function tokenFor(options: AsUserOptions = {}): Promise<string> {
23
+ const sub = options.sub ?? newId();
24
+ return new SignJWT({
25
+ email: options.email ?? `user-${sub}@example.com`,
26
+ preferred_username: `user-${sub.slice(0, 8)}`,
27
+ roles: options.roles ?? ["user"],
28
+ ...(options.orgId ? { organization: [options.orgId] } : {}),
29
+ })
30
+ .setProtectedHeader({ alg: "RS256", kid: "test" })
31
+ .setSubject(sub)
32
+ .setIssuer(issuer)
33
+ .setAudience(env.KEYCLOAK_AUDIENCE)
34
+ .setIssuedAt()
35
+ .setExpirationTime("5m")
36
+ .sign(privateKey);
37
+ }
38
+
39
+ /** A new user (random subject) with a signed access token. */
40
+ export async function authenticate(
41
+ options: AsUserOptions = {},
42
+ ): Promise<{ userId: string; headers: Record<string, string> }> {
43
+ const sub = options.sub ?? newId();
44
+ return { userId: sub, headers: { authorization: `Bearer ${await tokenFor({ ...options, sub })}` } };
45
+ }
46
+
47
+ /** Headers that authenticate as a new user. */
48
+ export async function asUser(options: AsUserOptions = {}): Promise<Record<string, string>> {
49
+ const user = await authenticate(options);
50
+ // tatara:slot as-user
51
+ return user.headers;
52
+ }
@@ -0,0 +1,18 @@
1
+ import type { KeycloakClaims } from "#core/auth/keycloak";
2
+ import { users } from "#core/auth/db/user.schema";
3
+ import { db } from "#core/db/client";
4
+
5
+ const known = new Set<string>();
6
+
7
+ /** Creates the local user row for a token subject the first time it is seen. */
8
+ export async function ensureLocalUser(claims: KeycloakClaims): Promise<void> {
9
+ if (known.has(claims.sub)) return;
10
+ const values = {
11
+ id: claims.sub,
12
+ email: claims.email ?? null,
13
+ name: claims.name ?? claims.preferred_username ?? null,
14
+ };
15
+ // tatara:slot upsert
16
+ if (known.size > 10_000) known.clear();
17
+ known.add(claims.sub);
18
+ }
@@ -0,0 +1,10 @@
1
+ import { mysqlTable, timestamp, varchar } from "drizzle-orm/mysql-core";
2
+
3
+ /** Local copy of Keycloak users (id = token subject), created on first request. */
4
+ export const users = mysqlTable("users", {
5
+ id: varchar({ length: 36 }).primaryKey(),
6
+ email: varchar({ length: 255 }),
7
+ name: varchar({ length: 255 }),
8
+ createdAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
9
+ updatedAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
10
+ });
@@ -0,0 +1,10 @@
1
+ import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
2
+
3
+ /** Local copy of Keycloak users (id = token subject), created on first request. */
4
+ export const users = pgTable("users", {
5
+ id: text().primaryKey(),
6
+ email: text(),
7
+ name: text(),
8
+ createdAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
9
+ updatedAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
10
+ });
@@ -0,0 +1,10 @@
1
+ /// Local copy of Keycloak users (id = token subject), created on first request.
2
+ model User {
3
+ id String @id @db.VarChar(36)
4
+ email String? @db.VarChar(255)
5
+ name String? @db.VarChar(255)
6
+ createdAt DateTime @default(now()) @map("created_at")
7
+ updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
8
+
9
+ @@map("users")
10
+ }
@@ -0,0 +1,13 @@
1
+ import type { KeycloakClaims } from "#core/auth/keycloak";
2
+ import { db } from "#core/db/client";
3
+
4
+ const known = new Set<string>();
5
+
6
+ /** Creates the local user row for a token subject the first time it is seen. */
7
+ export async function ensureLocalUser(claims: KeycloakClaims): Promise<void> {
8
+ if (known.has(claims.sub)) return;
9
+ const data = { email: claims.email ?? null, name: claims.name ?? claims.preferred_username ?? null };
10
+ await db.user.upsert({ where: { id: claims.sub }, create: { id: claims.sub, ...data }, update: data });
11
+ if (known.size > 10_000) known.clear();
12
+ known.add(claims.sub);
13
+ }
@@ -0,0 +1,10 @@
1
+ import { existsSync } from "node:fs";
2
+ import { defineConfig } from "prisma/config";
3
+
4
+ if (existsSync(".env")) process.loadEnvFile(".env");
5
+
6
+ export default defineConfig({
7
+ schema: "prisma",
8
+ migrations: { path: "prisma/migrations" },
9
+ datasource: { url: process.env.DATABASE_URL ?? "" },
10
+ });
@@ -0,0 +1,13 @@
1
+ import { execFile } from "node:child_process";
2
+ import { resolve } from "node:path";
3
+ import { promisify } from "node:util";
4
+
5
+ const run = promisify(execFile);
6
+
7
+ /** Applies prisma/migrations to the database at `url` (`prisma migrate deploy`). */
8
+ export async function migrateDatabase(url: string): Promise<void> {
9
+ await run(resolve("node_modules/.bin/prisma"), ["migrate", "deploy"], {
10
+ // tatara-check-ignore env-access: the Prisma CLI needs the full environment
11
+ env: { ...process.env, DATABASE_URL: url },
12
+ });
13
+ }
@@ -0,0 +1,11 @@
1
+ // Models live in prisma/models/*.prisma (one file per table).
2
+ generator client {
3
+ provider = "prisma-client"
4
+ output = "../src/generated/prisma"
5
+ importFileExtension = "ts"
6
+ moduleFormat = "esm"
7
+ }
8
+
9
+ datasource db {
10
+ provider = "mysql"
11
+ }
@@ -0,0 +1,23 @@
1
+ import { PrismaMariaDb } from "@prisma/adapter-mariadb";
2
+ import { env } from "#core/config/env";
3
+ import { connectionConfig } from "#core/db/admin";
4
+ import { registerReadinessCheck } from "#core/http/health";
5
+ import { onShutdown } from "#core/process/lifecycle";
6
+ import { type Prisma, PrismaClient } from "#generated/prisma/client";
7
+
8
+ export const db = new PrismaClient({
9
+ adapter: new PrismaMariaDb({
10
+ ...connectionConfig(env.DATABASE_URL),
11
+ connectionLimit: env.DATABASE_POOL_MAX,
12
+ }),
13
+ });
14
+
15
+ export type Database = typeof db;
16
+ export type Transaction = Prisma.TransactionClient;
17
+ /** `db` or an open transaction; services take an Executor so callers can compose them. */
18
+ export type Executor = Database | Transaction;
19
+
20
+ registerReadinessCheck("database", async () => {
21
+ await db.$queryRaw`SELECT 1`;
22
+ });
23
+ onShutdown("database", () => db.$disconnect());
@@ -0,0 +1,11 @@
1
+ // Models live in prisma/models/*.prisma (one file per table).
2
+ generator client {
3
+ provider = "prisma-client"
4
+ output = "../src/generated/prisma"
5
+ importFileExtension = "ts"
6
+ moduleFormat = "esm"
7
+ }
8
+
9
+ datasource db {
10
+ provider = "postgresql"
11
+ }
@@ -0,0 +1,19 @@
1
+ import { PrismaPg } from "@prisma/adapter-pg";
2
+ import { env } from "#core/config/env";
3
+ import { registerReadinessCheck } from "#core/http/health";
4
+ import { onShutdown } from "#core/process/lifecycle";
5
+ import { type Prisma, PrismaClient } from "#generated/prisma/client";
6
+
7
+ export const db = new PrismaClient({
8
+ adapter: new PrismaPg({ connectionString: env.DATABASE_URL, max: env.DATABASE_POOL_MAX }),
9
+ });
10
+
11
+ export type Database = typeof db;
12
+ export type Transaction = Prisma.TransactionClient;
13
+ /** `db` or an open transaction; services take an Executor so callers can compose them. */
14
+ export type Executor = Database | Transaction;
15
+
16
+ registerReadinessCheck("database", async () => {
17
+ await db.$queryRaw`SELECT 1`;
18
+ });
19
+ onShutdown("database", () => db.$disconnect());
@@ -0,0 +1,18 @@
1
+ import { defineCommand } from "#core/commands/define";
2
+ import { env } from "#core/config/env";
3
+ import { storage } from "#core/storage/index";
4
+ import { ensureBucket } from "#core/storage/s3";
5
+
6
+ export default defineCommand({
7
+ name: "storage:check",
8
+ description: "Create the bucket if needed, then write, read and delete a probe object",
9
+ async run(_args, { logger }) {
10
+ if (env.STORAGE_DRIVER === "s3" && (await ensureBucket())) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
11
+ const key = `tatara-check/${Date.now()}.txt`;
12
+ await storage.put(key, "ok", "text/plain");
13
+ const body = await storage.get(key);
14
+ await storage.delete(key);
15
+ if (new TextDecoder().decode(body ?? new Uint8Array()) !== "ok") throw new Error("storage round-trip failed");
16
+ logger.info({ driver: env.STORAGE_DRIVER }, "storage OK");
17
+ },
18
+ });
@@ -0,0 +1,8 @@
1
+ import { env } from "#core/config/env";
2
+ import { localStorage } from "#core/storage/local";
3
+ import { s3Storage } from "#core/storage/s3";
4
+ import type { Storage } from "#core/storage/storage";
5
+
6
+ export type { Storage } from "#core/storage/storage";
7
+
8
+ export const storage: Storage = env.STORAGE_DRIVER === "s3" ? s3Storage() : localStorage();