@tatara/cli 0.3.0 → 0.5.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.
Files changed (36) hide show
  1. package/dist/bin.mjs +311 -132
  2. package/package.json +1 -1
  3. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
  4. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
  5. package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +2 -2
  6. package/templates/features/auth-keycloak/files/src/core/auth/keycloak.ts +7 -56
  7. package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +3 -3
  8. package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +1 -1
  9. package/templates/features/authz/files/src/core/authz/db/seed.ts +1 -1
  10. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +1 -1
  11. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +1 -1
  12. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +1 -1
  13. package/templates/features/base/files/src/core/http/app.ts +2 -8
  14. package/templates/features/base/files/tests/helpers/api.ts +3 -47
  15. package/templates/features/better-auth/files/src/core/auth/auth.ts +1 -1
  16. package/templates/features/better-auth/files/src/core/auth/db/seed.ts +1 -1
  17. package/templates/features/better-auth/files/src/core/auth/delivery.ts +6 -30
  18. package/templates/features/better-auth/files/src/core/auth/principal.ts +7 -38
  19. package/templates/features/better-auth/files/src/core/auth/sms.ts +3 -12
  20. package/templates/features/better-auth/files/tests/helpers/auth.ts +9 -12
  21. package/templates/features/db/files/tests/global-setup.ts +4 -16
  22. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +2 -53
  23. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +7 -78
  24. package/templates/features/db/files+postgres/src/core/db/admin.ts +2 -50
  25. package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +1 -13
  26. package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +1 -13
  27. package/templates/features/prisma/files/src/core/db/migrate.ts +1 -13
  28. package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +1 -1
  29. package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +1 -1
  30. package/templates/versions.json +5 -1
  31. package/templates/features/auth/files/src/core/auth/middleware.ts +0 -29
  32. package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +0 -154
  33. package/templates/features/better-auth/files/src/core/auth/openapi.ts +0 -60
  34. package/templates/features/db/files/src/core/db/id.ts +0 -13
  35. package/templates/features/db/files/src/core/db/seed.ts +0 -14
  36. package/templates/features/db/files/tests/helpers/database-url.ts +0 -9
@@ -1,13 +1 @@
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
- }
1
+ export { migrateDatabase } from "@tatara/db/prisma";
@@ -1,6 +1,6 @@
1
1
  import { count, desc, eq } from "drizzle-orm";
2
2
  import { db, type Executor } from "#core/db/client";
3
- import { newId } from "#core/db/id";
3
+ import { newId } from "@tatara/db";
4
4
  import { NotFoundError } from "@tatara/http";
5
5
  import type { PaginationQuery } from "@tatara/http";
6
6
  import { __namePluralCamel__ } from "#modules/__moduleKebab__/db/__nameKebab__.schema";
@@ -1,5 +1,5 @@
1
1
  import { db, type Executor } from "#core/db/client";
2
- import { newId } from "#core/db/id";
2
+ import { newId } from "@tatara/db";
3
3
  import { NotFoundError } from "@tatara/http";
4
4
  import type { PaginationQuery } from "@tatara/http";
5
5
  import type {
@@ -32,5 +32,9 @@
32
32
  "nodemailer": "10.1.1",
33
33
  "@types/nodemailer": "8.0.2",
34
34
  "@tatara/core": "0.1.0",
35
- "@tatara/http": "0.1.0"
35
+ "@tatara/http": "0.1.0",
36
+ "@tatara/db": "0.1.0",
37
+ "@tatara/testing": "0.1.0",
38
+ "@tatara/auth": "0.1.0",
39
+ "@tatara/keycloak": "0.1.0"
36
40
  }
@@ -1,29 +0,0 @@
1
- import { createMiddleware } from "hono/factory";
2
- import type { Context } from "hono";
3
- import type { AppEnv, Principal } from "#core/http/context";
4
- import { ForbiddenError, UnauthorizedError } from "@tatara/http";
5
-
6
- /** 401 unless the request carries a valid session or token. */
7
- export function requireAuth() {
8
- return createMiddleware<AppEnv>(async (c, next) => {
9
- if (!c.get("principal")) throw new UnauthorizedError();
10
- await next();
11
- });
12
- }
13
-
14
- /** 401 without a principal, 403 unless it has at least one of the roles. */
15
- export function requireRole(...roles: string[]) {
16
- return createMiddleware<AppEnv>(async (c, next) => {
17
- const principal = c.get("principal");
18
- if (!principal) throw new UnauthorizedError();
19
- if (!principal.roles.some((role) => roles.includes(role))) throw new ForbiddenError();
20
- await next();
21
- });
22
- }
23
-
24
- /** The authenticated principal; use in handlers behind requireAuth(). */
25
- export function currentPrincipal(c: Context<AppEnv>): Principal {
26
- const principal = c.get("principal");
27
- if (!principal) throw new UnauthorizedError();
28
- return principal;
29
- }
@@ -1,154 +0,0 @@
1
- import type { BetterAuthPlugin } from "better-auth";
2
- import { createAuthEndpoint } from "better-auth/api";
3
- import { z } from "zod";
4
- import {
5
- type KeycloakClaims,
6
- organizationOf,
7
- rolesOf,
8
- verifyAccessToken,
9
- } from "#core/auth/keycloak";
10
- import { logger } from "#core/logging/logger";
11
-
12
- export interface KeycloakPrincipal {
13
- userId: string;
14
- orgId: string | null;
15
- roles: string[];
16
- kind: "user" | "service";
17
- claims: KeycloakClaims;
18
- }
19
-
20
- export interface KeycloakTokensOptions {
21
- /** Record the token's organization as a better-auth organization + membership. */
22
- organizations?: boolean;
23
- }
24
-
25
- type Adapter = Parameters<NonNullable<BetterAuthPlugin["init"]>>[0]["adapter"];
26
-
27
- const ACCOUNT_PROVIDER = "keycloak";
28
-
29
- async function ensureUser(adapter: Adapter, claims: KeycloakClaims): Promise<boolean> {
30
- // an unverified email could claim someone else's address, so it is never stored
31
- const verified = claims.email_verified === true && Boolean(claims.email);
32
- const email = verified
33
- ? (claims.email as string).toLowerCase()
34
- : `${claims.sub}@users.keycloak.invalid`;
35
- const name = claims.name ?? claims.preferred_username ?? claims.sub;
36
- const owner = await adapter.findOne<{ id: string }>({
37
- model: "user",
38
- where: [{ field: "email", value: email }],
39
- });
40
- const existing = await adapter.findOne<{ id: string }>({
41
- model: "user",
42
- where: [{ field: "id", value: claims.sub }],
43
- });
44
- if (existing) {
45
- const emailFree = !owner || owner.id === claims.sub;
46
- if (!emailFree) logger.warn({ sub: claims.sub }, "keycloak email belongs to another account");
47
- await adapter.update({
48
- model: "user",
49
- where: [{ field: "id", value: claims.sub }],
50
- update: {
51
- name,
52
- ...(emailFree ? { email, emailVerified: verified } : {}),
53
- updatedAt: new Date(),
54
- },
55
- });
56
- return true;
57
- }
58
- if (owner) {
59
- // never attach a Keycloak identity to an account that signs in another way
60
- logger.warn({ sub: claims.sub }, "keycloak token email belongs to another account");
61
- return false;
62
- }
63
- const now = new Date();
64
- await adapter.create({
65
- model: "user",
66
- data: { id: claims.sub, email, emailVerified: verified, name, createdAt: now, updatedAt: now },
67
- forceAllowId: true,
68
- });
69
- await adapter.create({
70
- model: "account",
71
- data: {
72
- userId: claims.sub,
73
- accountId: claims.sub,
74
- providerId: ACCOUNT_PROVIDER,
75
- createdAt: now,
76
- updatedAt: now,
77
- },
78
- });
79
- return true;
80
- }
81
-
82
- async function ensureMembership(adapter: Adapter, orgId: string, userId: string): Promise<void> {
83
- const org = await adapter.findOne({
84
- model: "organization",
85
- where: [{ field: "id", value: orgId }],
86
- });
87
- if (!org) {
88
- await adapter.create({
89
- model: "organization",
90
- data: { id: orgId, name: orgId, slug: orgId, createdAt: new Date() },
91
- forceAllowId: true,
92
- });
93
- }
94
- const member = await adapter.findOne({
95
- model: "member",
96
- where: [
97
- { field: "organizationId", value: orgId },
98
- { field: "userId", value: userId },
99
- ],
100
- });
101
- if (!member) {
102
- await adapter.create({
103
- model: "member",
104
- data: { organizationId: orgId, userId, role: "member", createdAt: new Date() },
105
- });
106
- }
107
- }
108
-
109
- /**
110
- * Lets the API accept access tokens issued by Keycloak (a resource server).
111
- * Verified tokens become principals; their users and organizations are kept in better-auth's tables.
112
- */
113
- export function keycloakTokens(options: KeycloakTokensOptions = {}) {
114
- // subjects already synced by this process; tokens are re-verified on every request regardless
115
- const synced = new Set<string>();
116
- return {
117
- id: "keycloak-tokens",
118
- endpoints: {
119
- // no path: callable only on the server as auth.api.verifyKeycloakToken
120
- verifyKeycloakToken: createAuthEndpoint(
121
- { method: "POST", body: z.object({ token: z.string() }) },
122
- async (ctx): Promise<KeycloakPrincipal | null> => {
123
- let claims: KeycloakClaims;
124
- try {
125
- claims = await verifyAccessToken(ctx.body.token);
126
- } catch (error) {
127
- logger.debug({ err: error }, "rejected keycloak token");
128
- return null;
129
- }
130
- const isService = claims.preferred_username?.startsWith("service-account-") ?? false;
131
- const orgId = organizationOf(claims);
132
- if (!isService) {
133
- const key = `${claims.sub}|${orgId ?? ""}|${claims.email ?? ""}`;
134
- if (!synced.has(key)) {
135
- const { adapter } = ctx.context;
136
- if (!(await ensureUser(adapter, claims))) return null;
137
- if (orgId && options.organizations)
138
- await ensureMembership(adapter, orgId, claims.sub);
139
- if (synced.size > 10_000) synced.clear();
140
- synced.add(key);
141
- }
142
- }
143
- return {
144
- userId: claims.sub,
145
- orgId,
146
- roles: rolesOf(claims),
147
- kind: isService ? "service" : "user",
148
- claims,
149
- };
150
- },
151
- ),
152
- },
153
- } satisfies BetterAuthPlugin;
154
- }
@@ -1,60 +0,0 @@
1
- import type { OpenAPIHono } from "@hono/zod-openapi";
2
- import { auth } from "#core/auth/auth";
3
-
4
- type JsonObject = Record<string, unknown>;
5
- type OpenApiDocument = ReturnType<OpenAPIHono["getOpenAPI31Document"]>;
6
- type Paths = NonNullable<OpenApiDocument["paths"]>;
7
- type Components = NonNullable<OpenApiDocument["components"]>;
8
-
9
- interface AuthDocument {
10
- paths?: Record<string, JsonObject>;
11
- components?: { schemas?: JsonObject; securitySchemes?: JsonObject };
12
- }
13
-
14
- const SCHEMA_REF = "#/components/schemas/";
15
-
16
- // better-auth names schemas like the app (User, Member, …); prefix them so neither side is overwritten
17
- function renameRefs(value: unknown, rename: (name: string) => string): AuthDocument {
18
- return JSON.parse(
19
- JSON.stringify(value).replaceAll(
20
- new RegExp(`"\\$ref":"${SCHEMA_REF}([^"]+)"`, "g"),
21
- (_match, name: string) => `"$ref":"${SCHEMA_REF}${rename(name)}"`,
22
- ),
23
- ) as AuthDocument;
24
- }
25
-
26
- function retag(paths: Record<string, JsonObject>, tag: string): Record<string, JsonObject> {
27
- for (const operations of Object.values(paths)) {
28
- for (const operation of Object.values(operations as Record<string, JsonObject>)) {
29
- if (operation && typeof operation === "object") operation.tags = [tag];
30
- }
31
- }
32
- return paths;
33
- }
34
-
35
- /** Adds better-auth's endpoints (served under `basePath`) to the app's OpenAPI document. */
36
- export async function mergeAuthOpenApi(document: OpenApiDocument, basePath: string): Promise<void> {
37
- const authDoc = renameRefs(await auth.api.generateOpenAPISchema(), (name) => `Auth${name}`);
38
-
39
- document.paths ??= {};
40
- for (const [path, operations] of Object.entries(retag(authDoc.paths ?? {}, "Auth"))) {
41
- document.paths[`${basePath}${path}`] = operations as Paths[string];
42
- }
43
-
44
- document.components ??= {};
45
- document.components.schemas ??= {};
46
- for (const [name, schema] of Object.entries(authDoc.components?.schemas ?? {})) {
47
- document.components.schemas[`Auth${name}`] = schema as NonNullable<
48
- Components["schemas"]
49
- >[string];
50
- }
51
- document.components.securitySchemes = {
52
- ...(authDoc.components?.securitySchemes as Components["securitySchemes"]),
53
- ...document.components.securitySchemes,
54
- };
55
-
56
- document.tags = [
57
- ...(document.tags ?? []),
58
- { name: "Auth", description: "Sign-up, sign-in, sessions, organizations (better-auth)" },
59
- ];
60
- }
@@ -1,13 +0,0 @@
1
- import { randomFillSync } from "node:crypto";
2
-
3
- /** UUIDv7: random but time-ordered, so sorting by id is sorting by creation time. */
4
- export function newId(): string {
5
- const bytes = new Uint8Array(16);
6
- randomFillSync(bytes);
7
- const ms = BigInt(Date.now());
8
- for (let i = 0; i < 6; i++) bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
9
- bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x70;
10
- bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
11
- const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
12
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
13
- }
@@ -1,14 +0,0 @@
1
- import type { Logger } from "#core/logging/logger";
2
-
3
- export interface SeedDefinition {
4
- name: string;
5
- /** lower runs first */
6
- order?: number;
7
- /** must be idempotent: seeds run on every `tatara dev` */
8
- run(ctx: { logger: Logger }): Promise<void>;
9
- }
10
-
11
- /** Default export of every `db/seed.ts`; discovered by `tatara sync`. */
12
- export function defineSeed(definition: SeedDefinition): SeedDefinition {
13
- return definition;
14
- }
@@ -1,9 +0,0 @@
1
- /** Number of Vitest workers; global setup prepares one database per worker. */
2
- export const TEST_WORKERS = 4;
3
-
4
- /** postgres://…/app_test → postgres://…/app_test_<worker> */
5
- export function workerDatabaseUrl(baseUrl: string, worker: number | string): string {
6
- const url = new URL(baseUrl);
7
- url.pathname = `${url.pathname}_${worker}`;
8
- return url.toString();
9
- }