@tatara/cli 0.1.0 → 0.1.2

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 (45) hide show
  1. package/dist/bin.mjs +323 -131
  2. package/package.json +1 -1
  3. package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +154 -0
  4. package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +109 -0
  5. package/templates/features/auth-keycloak/files+auth-orgs/src/core/auth/tests/keycloak-organizations.test.ts +36 -0
  6. package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +23 -0
  7. package/templates/features/authz/files/src/core/authz/constants.ts +4 -0
  8. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +5 -5
  9. package/templates/features/authz/files/src/core/authz/services/members.ts +17 -1
  10. package/templates/features/authz/files/src/core/authz/tests/error-messages.test.ts +34 -0
  11. package/templates/features/authz/files/src/core/authz/tests/request-body.test.ts +27 -0
  12. package/templates/features/authz/files/src/core/authz/types.ts +2 -0
  13. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +13 -0
  14. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +7 -0
  15. package/templates/features/base/files/AGENTS.md +2 -0
  16. package/templates/features/base/files/Dockerfile +1 -0
  17. package/templates/features/base/files/_github/workflows/ci.yml +10 -0
  18. package/templates/features/base/files/src/core/commands/define.ts +9 -1
  19. package/templates/features/base/files/src/core/commands/tests/flags.test.ts +16 -0
  20. package/templates/features/base/files/src/core/config/tests/env.test.ts +40 -0
  21. package/templates/features/base/files/src/core/http/app.ts +6 -1
  22. package/templates/features/base/files/src/core/http/errors.ts +40 -12
  23. package/templates/features/base/files/src/core/http/middleware/validation.ts +1 -1
  24. package/templates/features/base/files/src/core/http/response.ts +5 -0
  25. package/templates/features/better-auth/files/src/core/auth/openapi.ts +60 -0
  26. package/templates/features/better-auth/files/src/core/auth/principal.ts +19 -1
  27. package/templates/features/better-auth/files/src/core/auth/tests/openapi.test.ts +45 -0
  28. package/templates/features/better-auth/files/tests/helpers/auth.ts +2 -0
  29. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +8 -0
  30. package/templates/features/better-auth/files+auth-orgs/src/core/auth/tests/principal.test.ts +44 -0
  31. package/templates/features/better-auth/files+auth-orgs+authz/src/core/auth/tests/principal-authz.test.ts +40 -0
  32. package/templates/features/db/files/src/core/db/commands/reset.ts +2 -2
  33. package/templates/features/db/files/src/core/db/tests/reset.test.ts +7 -0
  34. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +2 -2
  35. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +3 -2
  36. package/templates/features/keycloak/files/src/core/auth/principal.ts +0 -27
  37. package/templates/features/keycloak/files/tests/helpers/auth.ts +0 -52
  38. package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +0 -18
  39. package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +0 -10
  40. package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +0 -10
  41. package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +0 -10
  42. package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +0 -13
  43. /package/templates/features/{keycloak → auth-keycloak}/files/keycloak/realm.json +0 -0
  44. /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/commands/keycloak-token.ts +0 -0
  45. /package/templates/features/{keycloak → auth-keycloak}/files/src/core/auth/keycloak.ts +0 -0
@@ -4,48 +4,76 @@ export class AppError extends Error {
4
4
  readonly status: ContentfulStatusCode;
5
5
  readonly code: string;
6
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;
7
9
 
8
- constructor(status: ContentfulStatusCode, code: string, message: string, details?: unknown) {
10
+ constructor(
11
+ status: ContentfulStatusCode,
12
+ code: string,
13
+ message: string,
14
+ details?: unknown,
15
+ translatable = false,
16
+ ) {
9
17
  super(message);
10
18
  this.name = new.target.name;
11
19
  this.status = status;
12
20
  this.code = code;
13
21
  this.details = details;
22
+ this.translatable = translatable;
14
23
  }
15
24
  }
16
25
 
17
26
  export class ValidationError extends AppError {
18
- constructor(message = "Invalid request", details?: unknown) {
19
- super(400, "VALIDATION_ERROR", message, details);
27
+ constructor(message?: string, details?: unknown) {
28
+ super(400, "VALIDATION_ERROR", message ?? "Invalid request", details, message === undefined);
20
29
  }
21
30
  }
22
31
 
23
32
  export class BusinessRuleError extends AppError {
24
33
  constructor(code: string, message: string, details?: unknown) {
25
- super(400, code, message, details);
34
+ // codes are specific to the rule, so a translation for the code is never less precise
35
+ super(400, code, message, details, true);
26
36
  }
27
37
  }
28
38
 
29
39
  export class UnauthorizedError extends AppError {
30
- constructor(message = "Authentication required") {
31
- super(401, "UNAUTHORIZED", message);
40
+ constructor(message?: string) {
41
+ super(
42
+ 401,
43
+ "UNAUTHORIZED",
44
+ message ?? "Authentication required",
45
+ undefined,
46
+ message === undefined,
47
+ );
32
48
  }
33
49
  }
34
50
 
35
51
  export class ForbiddenError extends AppError {
36
- constructor(message = "You do not have permission to do this") {
37
- super(403, "FORBIDDEN", message);
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
+ );
38
60
  }
39
61
  }
40
62
 
41
63
  export class NotFoundError extends AppError {
42
- constructor(resource = "Resource") {
43
- super(404, "NOT_FOUND", `${resource} not found`);
64
+ constructor(resource?: string) {
65
+ super(
66
+ 404,
67
+ "NOT_FOUND",
68
+ `${resource ?? "Resource"} not found`,
69
+ undefined,
70
+ resource === undefined,
71
+ );
44
72
  }
45
73
  }
46
74
 
47
75
  export class ConflictError extends AppError {
48
- constructor(message = "Resource already exists", details?: unknown) {
49
- super(409, "CONFLICT", message, details);
76
+ constructor(message?: string, details?: unknown) {
77
+ super(409, "CONFLICT", message ?? "Resource already exists", details, message === undefined);
50
78
  }
51
79
  }
@@ -6,7 +6,7 @@ import { ValidationError } from "#core/http/errors";
6
6
  export const validationHook: Hook<any, AppEnv, any, any> = (result) => {
7
7
  if (!result.success) {
8
8
  throw new ValidationError(
9
- "Invalid request",
9
+ undefined,
10
10
  result.error.issues.map((issue) => ({
11
11
  path: issue.path.join("."),
12
12
  message: issue.message,
@@ -49,6 +49,11 @@ export function jsonContent<T extends z.ZodType>(schema: T, description: string)
49
49
  return { content: { "application/json": { schema } }, description };
50
50
  }
51
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
+
52
57
  const ERROR_DESCRIPTIONS = {
53
58
  400: "Invalid request",
54
59
  401: "Authentication required",
@@ -0,0 +1,60 @@
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
+ }
@@ -2,6 +2,23 @@ import { createMiddleware } from "hono/factory";
2
2
  import { auth } from "#core/auth/auth";
3
3
  import type { AppEnv } from "#core/http/context";
4
4
 
5
+ /**
6
+ * The session's active organization, or null once the user stops being a member of it.
7
+ * better-auth leaves `activeOrganizationId` on the removed member's session.
8
+ */
9
+ async function activeMembership(userId: string, orgId: string | null | undefined) {
10
+ if (!orgId) return null;
11
+ const { adapter } = await auth.$context;
12
+ const member = await adapter.findOne({
13
+ model: "member",
14
+ where: [
15
+ { field: "organizationId", value: orgId },
16
+ { field: "userId", value: userId },
17
+ ],
18
+ });
19
+ return member ? orgId : null;
20
+ }
21
+
5
22
  /** Resolves the session (cookie or bearer token) into `c.var.principal`. */
6
23
  export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
7
24
  const session = await auth.api.getSession({ headers: c.req.raw.headers });
@@ -10,7 +27,7 @@ export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
10
27
  const s = session.session as typeof session.session & { activeOrganizationId?: string | null };
11
28
  c.set("principal", {
12
29
  userId: user.id,
13
- orgId: s.activeOrganizationId ?? null,
30
+ orgId: await activeMembership(user.id, s.activeOrganizationId),
14
31
  roles: (user.role ?? "user")
15
32
  .split(",")
16
33
  .map((role) => role.trim())
@@ -19,5 +36,6 @@ export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
19
36
  claims: {},
20
37
  });
21
38
  }
39
+ // tatara:slot no-session
22
40
  await next();
23
41
  });
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { api } from "#tests/helpers/api";
3
+
4
+ interface Document {
5
+ paths: Record<string, Record<string, { tags?: string[] }>>;
6
+ components: { schemas: Record<string, unknown>; securitySchemes: Record<string, unknown> };
7
+ }
8
+
9
+ async function apiDocument(): Promise<Document> {
10
+ const res = await api.get<Document>("/openapi.json");
11
+ expect(res.status).toBe(200);
12
+ return res.body;
13
+ }
14
+
15
+ function refs(value: unknown): string[] {
16
+ return [...JSON.stringify(value).matchAll(/"\$ref":"#\/components\/schemas\/([^"]+)"/g)].map(
17
+ (match) => match[1] ?? "",
18
+ );
19
+ }
20
+
21
+ describe("OpenAPI document", () => {
22
+ it("documents better-auth endpoints next to the app's own", async () => {
23
+ const doc = await apiDocument();
24
+ expect(doc.paths["/api/auth/get-session"]?.get).toBeDefined();
25
+ expect(doc.paths["/api/me"]?.get).toBeDefined();
26
+ });
27
+
28
+ it("tags better-auth operations as Auth", async () => {
29
+ const doc = await apiDocument();
30
+ expect(doc.paths["/api/auth/get-session"]?.get?.tags).toEqual(["Auth"]);
31
+ });
32
+
33
+ it("prefixes better-auth schemas so app schemas keep their names", async () => {
34
+ const doc = await apiDocument();
35
+ expect(doc.components.schemas.AuthUser).toBeDefined();
36
+ expect(doc.components.schemas.Principal).toBeDefined();
37
+ expect(doc.components.securitySchemes.bearerAuth).toBeDefined();
38
+ });
39
+
40
+ it("resolves every schema reference", async () => {
41
+ const doc = await apiDocument();
42
+ const missing = refs(doc).filter((name) => !(name in doc.components.schemas));
43
+ expect(missing).toEqual([]);
44
+ });
45
+ });
@@ -26,6 +26,7 @@ export async function createTestUser(options: AsUserOptions = {}): Promise<strin
26
26
  export async function authenticate(
27
27
  options: AsUserOptions = {},
28
28
  ): Promise<{ userId: string; headers: Record<string, string> }> {
29
+ // tatara:slot authenticate
29
30
  const userId = await createTestUser(options);
30
31
  const ctx = await auth.$context;
31
32
  const session = await ctx.internalAdapter.createSession(userId);
@@ -38,3 +39,4 @@ export async function asUser(options: AsUserOptions = {}): Promise<Record<string
38
39
  // tatara:slot as-user
39
40
  return user.headers;
40
41
  }
42
+ // tatara:slot helpers
@@ -0,0 +1,8 @@
1
+ import { expect, it } from "vitest";
2
+ import { adminApi } from "#tests/helpers/api";
3
+
4
+ it("leaves auth out of the admin OpenAPI document", async () => {
5
+ const res = await adminApi.get<{ paths: Record<string, unknown> }>("/openapi.json");
6
+ expect(res.status).toBe(200);
7
+ expect(Object.keys(res.body.paths).some((path) => path.startsWith("/api/auth/"))).toBe(false);
8
+ });
@@ -0,0 +1,44 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { auth } from "#core/auth/auth";
3
+ import { api } from "#tests/helpers/api";
4
+ import { authenticate } from "#tests/helpers/auth";
5
+ import { resetDatabase } from "#tests/helpers/db";
6
+
7
+ beforeEach(async () => {
8
+ await resetDatabase();
9
+ });
10
+
11
+ describe("principal", () => {
12
+ it("drops the active organization once the user is no longer a member", async () => {
13
+ const owner = await authenticate();
14
+ const member = await authenticate({ email: "member@example.com" });
15
+ const org = await api.post<{ id: string }>(
16
+ "/api/auth/organization/create",
17
+ { name: "School", slug: "school" },
18
+ { headers: owner.headers },
19
+ );
20
+ await auth.api.addMember({
21
+ body: { userId: member.userId, role: "member", organizationId: org.body.id },
22
+ });
23
+ await api.post(
24
+ "/api/auth/organization/set-active",
25
+ { organizationId: org.body.id },
26
+ { headers: member.headers },
27
+ );
28
+ const before = await api.get<{ data: { orgId: string | null } }>("/api/me", {
29
+ headers: member.headers,
30
+ });
31
+ expect(before.body.data.orgId).toBe(org.body.id);
32
+
33
+ const removed = await api.post(
34
+ "/api/auth/organization/remove-member",
35
+ { memberIdOrEmail: "member@example.com", organizationId: org.body.id },
36
+ { headers: owner.headers },
37
+ );
38
+ expect(removed.status).toBe(200);
39
+ const after = await api.get<{ data: { orgId: string | null } }>("/api/me", {
40
+ headers: member.headers,
41
+ });
42
+ expect(after.body.data.orgId).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,40 @@
1
+ import { beforeEach, expect, it } from "vitest";
2
+ import { auth } from "#core/auth/auth";
3
+ import { api } from "#tests/helpers/api";
4
+ import { authenticate } from "#tests/helpers/auth";
5
+ import { applyTestAccess } from "#tests/helpers/authz";
6
+ import { resetDatabase } from "#tests/helpers/db";
7
+
8
+ beforeEach(async () => {
9
+ await resetDatabase();
10
+ });
11
+
12
+ it("does not restore old org roles when a removed member is added back", async () => {
13
+ const owner = await authenticate();
14
+ const member = await authenticate({ email: "member@example.com" });
15
+ const org = await api.post<{ id: string }>(
16
+ "/api/auth/organization/create",
17
+ { name: "School", slug: "school" },
18
+ { headers: owner.headers },
19
+ );
20
+ await auth.api.addMember({
21
+ body: { userId: member.userId, role: "member", organizationId: org.body.id },
22
+ });
23
+ await applyTestAccess(member.userId, { systemRoles: ["admin"], orgId: org.body.id });
24
+ await api.post(
25
+ "/api/auth/organization/remove-member",
26
+ { memberIdOrEmail: "member@example.com", organizationId: org.body.id },
27
+ { headers: owner.headers },
28
+ );
29
+
30
+ await auth.api.addMember({
31
+ body: { userId: member.userId, role: "member", organizationId: org.body.id },
32
+ });
33
+ await api.post(
34
+ "/api/auth/organization/set-active",
35
+ { organizationId: org.body.id },
36
+ { headers: member.headers },
37
+ );
38
+ const roles = await api.get("/api/authorization/roles", { headers: member.headers });
39
+ expect(roles.status).toBe(403);
40
+ });
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineCommand } from "#core/commands/define";
2
+ import { defineCommand, flag } from "#core/commands/define";
3
3
  import { env } from "#core/config/env";
4
4
  import { dropAllTables } from "#core/db/admin";
5
5
  import { migrateDatabase } from "#core/db/migrate";
@@ -7,7 +7,7 @@ import { migrateDatabase } from "#core/db/migrate";
7
7
  export default defineCommand({
8
8
  name: "db:reset",
9
9
  description: "Drop every table and re-apply migrations (refuses to run in production)",
10
- args: z.object({ force: z.coerce.boolean().default(false) }),
10
+ args: z.object({ force: flag() }),
11
11
  async run({ force }, { logger }) {
12
12
  if (env.NODE_ENV === "production" && !force) {
13
13
  throw new Error("db:reset refuses to run with NODE_ENV=production (pass --force to override)");
@@ -0,0 +1,7 @@
1
+ import { expect, it } from "vitest";
2
+ import { parseFlags } from "#core/commands/runner";
3
+ import reset from "#core/db/commands/reset";
4
+
5
+ it("does not treat --force false as forcing db:reset", () => {
6
+ expect(reset.args?.parse(parseFlags(["--force", "false"]))).toEqual({ force: false });
7
+ });
@@ -1,10 +1,10 @@
1
1
  import { z } from "zod";
2
- import { defineCommand } from "#core/commands/define";
2
+ import { defineCommand, flag } from "#core/commands/define";
3
3
 
4
4
  export default defineCommand({
5
5
  name: "__moduleKebab__:__nameKebab__",
6
6
  description: "__nameTitle__",
7
- args: z.object({ dryRun: z.coerce.boolean().default(false) }),
7
+ args: z.object({ dryRun: flag() }),
8
8
  async run({ dryRun }, { logger }) {
9
9
  logger.info({ dryRun }, "__moduleKebab__:__nameKebab__ ran");
10
10
  },
@@ -1,6 +1,7 @@
1
1
  import { createRoute } from "@hono/zod-openapi";
2
2
  import {
3
3
  errorResponses,
4
+ jsonBody,
4
5
  jsonContent,
5
6
  paginated,
6
7
  paginatedSchema,
@@ -72,7 +73,7 @@ router.openapi(
72
73
  tags,
73
74
  summary: "Create a __nameKebab__",
74
75
  // tatara:slot guard-create
75
- request: { body: jsonContent(create__namePascal__Schema, "The new __nameKebab__") },
76
+ request: { body: jsonBody(create__namePascal__Schema, "The new __nameKebab__") },
76
77
  responses: {
77
78
  201: jsonContent(successSchema(__nameCamel__Schema), "Created"),
78
79
  ...errorResponses(400),
@@ -91,7 +92,7 @@ router.openapi(
91
92
  // tatara:slot guard-update
92
93
  request: {
93
94
  params: __nameCamel__ParamsSchema,
94
- body: jsonContent(update__namePascal__Schema, "Fields to change"),
95
+ body: jsonBody(update__namePascal__Schema, "Fields to change"),
95
96
  },
96
97
  responses: {
97
98
  200: jsonContent(successSchema(__nameCamel__Schema), "Updated"),
@@ -1,27 +0,0 @@
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
- });
@@ -1,52 +0,0 @@
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
- }
@@ -1,18 +0,0 @@
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
- }
@@ -1,10 +0,0 @@
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
- });
@@ -1,10 +0,0 @@
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
- });
@@ -1,10 +0,0 @@
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
- }
@@ -1,13 +0,0 @@
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
- }