@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,154 @@
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
+ }
@@ -0,0 +1,109 @@
1
+ import { generateKeyPair, SignJWT } from "jose";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { auth } from "#core/auth/auth";
4
+ import { issuer } from "#core/auth/keycloak";
5
+ import { env } from "#core/config/env";
6
+ import { newId } from "#core/db/id";
7
+ import { api } from "#tests/helpers/api";
8
+ import { createTestUser, keycloakToken } from "#tests/helpers/auth";
9
+ import { resetDatabase } from "#tests/helpers/db";
10
+
11
+ interface Me {
12
+ data: { userId: string; orgId: string | null; roles: string[]; kind: string };
13
+ }
14
+
15
+ const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
16
+ const me = (token: string) => api.get<Me>("/api/me", { headers: bearer(token) });
17
+
18
+ // read through better-auth's adapter so the tests work with any ORM
19
+ async function find<T>(model: string, where: Record<string, string>): Promise<T | null> {
20
+ const { adapter } = await auth.$context;
21
+ return adapter.findOne<T>({
22
+ model,
23
+ where: Object.entries(where).map(([field, value]) => ({ field, value })),
24
+ });
25
+ }
26
+ type UserRow = { email: string; emailVerified: boolean };
27
+
28
+ beforeEach(async () => {
29
+ await resetDatabase();
30
+ });
31
+
32
+ describe("Keycloak access tokens", () => {
33
+ it("authenticates a valid token as the token's subject", async () => {
34
+ const sub = newId();
35
+ const res = await me(await keycloakToken({ sub, roles: ["teacher"] }));
36
+ expect(res.status).toBe(200);
37
+ expect(res.body.data).toMatchObject({ userId: sub, kind: "user", orgId: null });
38
+ expect(res.body.data.roles).toContain("teacher");
39
+ });
40
+
41
+ it("creates the local user and links a keycloak account on first use", async () => {
42
+ const sub = newId();
43
+ await me(await keycloakToken({ sub, email: "kc@example.com" }));
44
+ const user = await find<UserRow>("user", { id: sub });
45
+ expect(user?.email).toBe("kc@example.com");
46
+ const account = await find<{ accountId: string }>("account", {
47
+ userId: sub,
48
+ providerId: "keycloak",
49
+ });
50
+ expect(account?.accountId).toBe(sub);
51
+ });
52
+
53
+ it.each([
54
+ ["an expired token", { expiresIn: Math.floor(Date.now() / 1000) - 60 }],
55
+ ["a token from another issuer", { issuer: "https://evil.example.com/realms/app" }],
56
+ ["a token for another audience", { audience: "someone-else" }],
57
+ ])("rejects %s", async (_label, options) => {
58
+ expect((await me(await keycloakToken(options))).status).toBe(401);
59
+ });
60
+
61
+ it("rejects a token signed with an unknown key", async () => {
62
+ const { privateKey } = await generateKeyPair("RS256");
63
+ const forged = await new SignJWT({ roles: ["admin"] })
64
+ .setProtectedHeader({ alg: "RS256", kid: "test" })
65
+ .setSubject(newId())
66
+ .setIssuer(issuer)
67
+ .setAudience(env.KEYCLOAK_AUDIENCE)
68
+ .setExpirationTime("5m")
69
+ .sign(privateKey);
70
+ expect((await me(forged)).status).toBe(401);
71
+ });
72
+
73
+ it("rejects a bearer value that is not a token", async () => {
74
+ expect((await me("not-a-token")).status).toBe(401);
75
+ });
76
+
77
+ it("treats service-account tokens as services without a user row", async () => {
78
+ const sub = newId();
79
+ const res = await me(await keycloakToken({ sub, username: "service-account-billing" }));
80
+ expect(res.body.data).toMatchObject({ userId: sub, kind: "service" });
81
+ expect(await find<UserRow>("user", { id: sub })).toBeNull();
82
+ });
83
+
84
+
85
+ it("rejects a token whose email belongs to another account", async () => {
86
+ await createTestUser({ email: "taken@example.com" });
87
+ const sub = newId();
88
+ expect((await me(await keycloakToken({ sub, email: "taken@example.com" }))).status).toBe(401);
89
+ expect(await find<UserRow>("user", { id: sub })).toBeNull();
90
+ });
91
+
92
+
93
+ it("does not store an email Keycloak has not verified", async () => {
94
+ const sub = newId();
95
+ await me(await keycloakToken({ sub, email: "victim@example.com", emailVerified: false }));
96
+ const user = await find<UserRow>("user", { id: sub });
97
+ expect(user?.email).not.toBe("victim@example.com");
98
+ expect(user?.emailVerified).toBe(false);
99
+ });
100
+
101
+ it("keeps the old email when the token's new email belongs to another account", async () => {
102
+ await createTestUser({ email: "taken@example.com" });
103
+ const sub = newId();
104
+ await me(await keycloakToken({ sub, email: "first@example.com" }));
105
+ const res = await me(await keycloakToken({ sub, email: "taken@example.com" }));
106
+ expect(res.status).toBe(200);
107
+ expect((await find<UserRow>("user", { id: sub }))?.email).toBe("first@example.com");
108
+ });
109
+ });
@@ -0,0 +1,36 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { auth } from "#core/auth/auth";
3
+ import { newId } from "#core/db/id";
4
+ import { api } from "#tests/helpers/api";
5
+ import { keycloakToken } from "#tests/helpers/auth";
6
+ import { resetDatabase } from "#tests/helpers/db";
7
+
8
+ interface Me {
9
+ data: { orgId: string | null };
10
+ }
11
+
12
+ const me = (token: string) =>
13
+ api.get<Me>("/api/me", { headers: { authorization: `Bearer ${token}` } });
14
+
15
+ // read through better-auth's adapter so the tests work with any ORM
16
+ async function find<T>(model: string, where: Record<string, string>): Promise<T | null> {
17
+ const { adapter } = await auth.$context;
18
+ return adapter.findOne<T>({
19
+ model,
20
+ where: Object.entries(where).map(([field, value]) => ({ field, value })),
21
+ });
22
+ }
23
+
24
+ beforeEach(async () => {
25
+ await resetDatabase();
26
+ });
27
+
28
+ describe("Keycloak access tokens with organizations", () => {
29
+ it("takes the organization from the token and records the membership", async () => {
30
+ const sub = newId();
31
+ const res = await me(await keycloakToken({ sub, organization: ["riyadh-school"] }));
32
+ expect(res.body.data.orgId).toBe("riyadh-school");
33
+ expect(await find("organization", { id: "riyadh-school" })).not.toBeNull();
34
+ expect(await find("member", { organizationId: "riyadh-school", userId: sub })).not.toBeNull();
35
+ });
36
+ });
@@ -0,0 +1,23 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { syncSystemRoles } from "#core/authz/services/sync";
3
+ import { api } from "#tests/helpers/api";
4
+ import { keycloakToken } from "#tests/helpers/auth";
5
+ import { resetDatabase } from "#tests/helpers/db";
6
+
7
+ const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
8
+
9
+ beforeEach(async () => {
10
+ await resetDatabase();
11
+ });
12
+
13
+ describe("Keycloak access tokens with authz", () => {
14
+ it("lets token roles match built-in authz roles", async () => {
15
+ await syncSystemRoles();
16
+ const admin = await keycloakToken({ roles: ["admin"] });
17
+ const user = await keycloakToken({ roles: ["user"] });
18
+ expect((await api.get("/api/authorization/roles", { headers: bearer(admin) })).status).toBe(
19
+ 200,
20
+ );
21
+ expect((await api.get("/api/authorization/roles", { headers: bearer(user) })).status).toBe(403);
22
+ });
23
+ });
@@ -15,9 +15,13 @@ export const AUDIT_CHANGE_TYPES = [
15
15
  "member_role_revoked",
16
16
  "overlay_granted",
17
17
  "overlay_revoked",
18
+ "member_removed",
18
19
  ] as const;
19
20
  export type AuditChangeType = (typeof AUDIT_CHANGE_TYPES)[number];
20
21
 
22
+ /** Audit actor for changes made by the system rather than a user (e.g. org membership hooks). */
23
+ export const SYSTEM_ACTOR = "system";
24
+
21
25
  /** Org used when the principal has none (single-tenant apps, users without an active org). */
22
26
  export const DEFAULT_ORG_ID = "default";
23
27
 
@@ -19,7 +19,7 @@ import {
19
19
  userParams,
20
20
  userRoleParams,
21
21
  } from "#core/authz/validators";
22
- import { errorResponses, jsonContent, success, successSchema } from "#core/http/response";
22
+ import { errorResponses, jsonBody, jsonContent, success, successSchema } from "#core/http/response";
23
23
  import { createRouter, defineRoutes } from "#core/http/routes";
24
24
  import { catalog } from "#generated/permissions";
25
25
 
@@ -100,7 +100,7 @@ router.openapi(
100
100
  summary: "Create a custom role",
101
101
  middleware: [requirePermission("role:create")] as const,
102
102
  security: [{ bearerAuth: ["role:create"] }],
103
- request: { body: jsonContent(createRoleSchema, "The role") },
103
+ request: { body: jsonBody(createRoleSchema, "The role") },
104
104
  responses: {
105
105
  201: jsonContent(successSchema(roleSchema), "Created"),
106
106
  ...errorResponses(400, 409),
@@ -122,7 +122,7 @@ router.openapi(
122
122
  summary: "Update a custom role",
123
123
  middleware: [requirePermission("role:update")] as const,
124
124
  security: [{ bearerAuth: ["role:update"] }],
125
- request: { params: idParams, body: jsonContent(updateRoleSchema, "Fields to change") },
125
+ request: { params: idParams, body: jsonBody(updateRoleSchema, "Fields to change") },
126
126
  responses: {
127
127
  200: jsonContent(successSchema(roleSchema), "Updated"),
128
128
  ...errorResponses(400, 404),
@@ -179,7 +179,7 @@ router.openapi(
179
179
  summary: "Assign a role to a member",
180
180
  middleware: [requirePermission("member:update")] as const,
181
181
  security: [{ bearerAuth: ["member:update"] }],
182
- request: { params: userParams, body: jsonContent(z.object({ roleId: z.string() }), "Role") },
182
+ request: { params: userParams, body: jsonBody(z.object({ roleId: z.string() }), "Role") },
183
183
  responses: {
184
184
  200: jsonContent(successSchema(z.object({ assigned: z.boolean() })), "Assigned"),
185
185
  ...errorResponses(404),
@@ -225,7 +225,7 @@ router.openapi(
225
225
  summary: "Grant or deny one permission to a member",
226
226
  middleware: [requirePermission("member:update")] as const,
227
227
  security: [{ bearerAuth: ["member:update"] }],
228
- request: { params: userParams, body: jsonContent(overlayInputSchema, "Overlay") },
228
+ request: { params: userParams, body: jsonBody(overlayInputSchema, "Overlay") },
229
229
  responses: {
230
230
  201: jsonContent(successSchema(z.object({ id: z.string() })), "Created"),
231
231
  ...errorResponses(400),
@@ -1,5 +1,5 @@
1
1
  import { invalidateMember } from "#core/authz/cache";
2
- import type { OverlayEffect, Scope } from "#core/authz/constants";
2
+ import { type OverlayEffect, type Scope, SYSTEM_ACTOR } from "#core/authz/constants";
3
3
  import { store } from "#core/authz/db/store";
4
4
  import { audit } from "#core/authz/services/audit";
5
5
  import { validateGrants } from "#core/authz/services/grants";
@@ -31,6 +31,22 @@ export async function revokeRole(actorId: string, orgId: string, userId: string,
31
31
  await audit({ orgId, actorId, targetUserId: userId, changeType: "member_role_revoked", before: { roleId } });
32
32
  }
33
33
 
34
+ /** Removes all of a user's access in an org; called when they stop being a member. */
35
+ export async function removeMemberAccess(orgId: string, userId: string, actorId = SYSTEM_ACTOR) {
36
+ const removed = await store.removeMember(orgId, userId);
37
+ invalidateMember(userId, orgId);
38
+ if (removed.roles + removed.overlays > 0) {
39
+ await audit({
40
+ orgId,
41
+ actorId,
42
+ targetUserId: userId,
43
+ changeType: "member_removed",
44
+ before: removed,
45
+ });
46
+ }
47
+ return removed;
48
+ }
49
+
34
50
  export interface OverlayInput {
35
51
  resource: string;
36
52
  action: string;
@@ -0,0 +1,34 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { api } from "#tests/helpers/api";
3
+ import { asUser, authenticate } from "#tests/helpers/auth";
4
+ import { resetDatabase } from "#tests/helpers/db";
5
+
6
+ beforeEach(async () => {
7
+ await resetDatabase();
8
+ });
9
+
10
+ describe("error messages", () => {
11
+ it("keeps a specific message instead of the generic one for its code", async () => {
12
+ const headers = await asUser({ systemRoles: ["admin"] });
13
+ const role = await api.post<{ data: { id: string } }>(
14
+ "/api/authorization/roles",
15
+ { key: "auditor", name: "Auditor", grants: [] },
16
+ { headers },
17
+ );
18
+ const member = await authenticate();
19
+ await api.post(
20
+ `/api/authorization/members/${member.userId}/roles`,
21
+ { roleId: role.body.data.id },
22
+ { headers },
23
+ );
24
+ const res = await api.delete<{ code: string; error: string }>(
25
+ `/api/authorization/roles/${role.body.data.id}`,
26
+ { headers },
27
+ );
28
+ expect(res.status).toBe(409);
29
+ expect(res.body).toMatchObject({
30
+ code: "CONFLICT",
31
+ error: 'Role "auditor" is assigned to 1 member(s)',
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,27 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { app } from "#apps/api/app";
3
+ import { asUser } from "#tests/helpers/auth";
4
+ import { resetDatabase } from "#tests/helpers/db";
5
+
6
+ beforeEach(async () => {
7
+ await resetDatabase();
8
+ });
9
+
10
+ // A body sent without Content-Type used to skip validation and reach services as {}
11
+ describe("JSON request bodies", () => {
12
+ it("rejects a missing body with 400", async () => {
13
+ const headers = await asUser({ systemRoles: ["admin"] });
14
+ const res = await app.request("/api/authorization/roles", { method: "POST", headers });
15
+ expect(res.status).toBe(400);
16
+ expect(await res.json()).toMatchObject({ code: "VALIDATION_ERROR" });
17
+ });
18
+
19
+ it("does not assign a role from a missing body", async () => {
20
+ const headers = await asUser({ systemRoles: ["admin"] });
21
+ const res = await app.request("/api/authorization/members/someone/roles", {
22
+ method: "POST",
23
+ headers,
24
+ });
25
+ expect(res.status).toBe(400);
26
+ });
27
+ });
@@ -77,6 +77,8 @@ export interface AuthzStore {
77
77
  memberRoles(orgId: string, userId: string): Promise<RoleRow[]>;
78
78
  assignRole(orgId: string, userId: string, roleId: string, by: string | null): Promise<boolean>;
79
79
  revokeRole(orgId: string, userId: string, roleId: string): Promise<boolean>;
80
+ /** Deletes every role assignment and overlay the user has in the org. */
81
+ removeMember(orgId: string, userId: string): Promise<{ roles: number; overlays: number }>;
80
82
  listOverlays(orgId: string, userId: string): Promise<OverlayRow[]>;
81
83
  addOverlay(input: Omit<OverlayRow, "id" | "createdAt">): Promise<string>;
82
84
  removeOverlay(orgId: string, userId: string, overlayId: string): Promise<OverlayRow | null>;
@@ -194,6 +194,19 @@ export const store: AuthzStore = {
194
194
  return true;
195
195
  },
196
196
 
197
+ async removeMember(orgId, userId) {
198
+ const roleWhere = and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId));
199
+ const overlayWhere = and(eq(authzOverlays.orgId, orgId), eq(authzOverlays.userId, userId));
200
+ // count first: MySQL has no RETURNING
201
+ return db.transaction(async (tx) => {
202
+ const [roles] = await tx.select({ n: count() }).from(authzMemberRoles).where(roleWhere);
203
+ const [overlays] = await tx.select({ n: count() }).from(authzOverlays).where(overlayWhere);
204
+ await tx.delete(authzMemberRoles).where(roleWhere);
205
+ await tx.delete(authzOverlays).where(overlayWhere);
206
+ return { roles: roles?.n ?? 0, overlays: overlays?.n ?? 0 };
207
+ });
208
+ },
209
+
197
210
  async listOverlays(orgId, userId) {
198
211
  const rows = await db
199
212
  .select()
@@ -168,6 +168,13 @@ export const store: AuthzStore = {
168
168
  return count > 0;
169
169
  },
170
170
 
171
+ async removeMember(orgId, userId) {
172
+ const [roles, overlays] = await db.$transaction([
173
+ db.authzMemberRole.deleteMany({ where: { orgId, userId } }),
174
+ db.authzOverlay.deleteMany({ where: { orgId, userId } }),
175
+ ]);
176
+ return { roles: roles.count, overlays: overlays.count };
177
+ },
171
178
  async listOverlays(orgId, userId) {
172
179
  const rows = await db.authzOverlay.findMany({
173
180
  where: { orgId, userId },
@@ -45,6 +45,8 @@ __PM_EXEC__ tatara routes # every route and what protects it
45
45
  ## HTTP
46
46
 
47
47
  - Every route uses `createRoute` from `@hono/zod-openapi`, with request and response schemas.
48
+ Declare request bodies with `jsonBody(schema, description)` (required; a missing body is a 400),
49
+ responses with `jsonContent(...)`.
48
50
  The spec is served at `/openapi.json`, the reference UI at `/docs`.
49
51
  - Responses use the envelope: `success(data)`, `paginated(items, page, pageSize, total)`.
50
52
  Errors are thrown as `AppError` subclasses (`NotFoundError`, `ConflictError`,
@@ -1,3 +1,4 @@
1
+ # syntax=docker/dockerfile:1
1
2
  # One image for every entrypoint. Default: the API.
2
3
  # Other processes: override the command, e.g. `node dist/apps/worker/index.mjs`.
3
4
  FROM node:24-alpine AS base
@@ -5,6 +5,9 @@ on:
5
5
  branches: [main]
6
6
  pull_request:
7
7
 
8
+ permissions:
9
+ contents: read
10
+
8
11
  jobs:
9
12
  verify:
10
13
  runs-on: ubuntu-latest
@@ -14,3 +17,10 @@ jobs:
14
17
  - run: cp .env.example .env
15
18
  # tatara:slot ci-services
16
19
  - run: __PM_RUN__ verify
20
+
21
+ image:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v5
25
+ # catch a broken Dockerfile on the PR, not at deploy
26
+ - run: docker build --tag app:ci .
@@ -9,7 +9,7 @@ export interface CommandDefinition<S extends z.ZodType = z.ZodType> {
9
9
  /** `module:action`, e.g. "billing:recalculate" */
10
10
  name: string;
11
11
  description: string;
12
- /** flags parsed from `--key value`; use z.coerce for numbers and booleans */
12
+ /** flags parsed from `--key value`; use z.coerce for numbers and `flag()` for booleans */
13
13
  args?: S;
14
14
  run(args: z.infer<S>, ctx: CommandContext): Promise<void> | void;
15
15
  }
@@ -23,3 +23,11 @@ export function defineCommand<S extends z.ZodType = typeof noArgs>(
23
23
  }
24
24
 
25
25
  export const noArgs = z.object({});
26
+
27
+ /**
28
+ * A boolean flag: bare `--force` is true, `--force false|0|no|off` is false.
29
+ * (`z.coerce.boolean()` would read the string "false" as true.)
30
+ */
31
+ export function flag() {
32
+ return z.union([z.boolean(), z.stringbool()]).default(false);
33
+ }
@@ -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
+ });
@@ -43,7 +43,12 @@ export function createHttpApp(name: AppName, options: { title: string }) {
43
43
  if (definition.app === name) app.route(`${API_PREFIX}${definition.basePath}`, definition.router);
44
44
  }
45
45
 
46
- app.doc31("/openapi.json", { openapi: "3.1.0", info: { title: options.title, version: "1.0.0" } });
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
+ });
47
52
  app.get("/docs", Scalar({ url: "/openapi.json", pageTitle: options.title }));
48
53
 
49
54
  app.onError(errorHandler);