@tatara/cli 0.0.0-stage → 0.1.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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bin.mjs +2910 -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 +28 -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 +68 -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/types.ts +85 -0
  32. package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
  33. package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
  34. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +262 -0
  35. package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
  36. package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
  37. package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
  38. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +229 -0
  39. package/templates/features/base/files/AGENTS.md +69 -0
  40. package/templates/features/base/files/CLAUDE.md +1 -0
  41. package/templates/features/base/files/Dockerfile +15 -0
  42. package/templates/features/base/files/README.md +31 -0
  43. package/templates/features/base/files/_github/workflows/ci.yml +16 -0
  44. package/templates/features/base/files/biome.template.json +20 -0
  45. package/templates/features/base/files/dockerignore +8 -0
  46. package/templates/features/base/files/gitignore +9 -0
  47. package/templates/features/base/files/src/apps/api/app.ts +3 -0
  48. package/templates/features/base/files/src/apps/api/server.ts +5 -0
  49. package/templates/features/base/files/src/apps/cli/index.ts +6 -0
  50. package/templates/features/base/files/src/core/commands/define.ts +25 -0
  51. package/templates/features/base/files/src/core/commands/runner.ts +54 -0
  52. package/templates/features/base/files/src/core/http/app.ts +52 -0
  53. package/templates/features/base/files/src/core/http/context.ts +18 -0
  54. package/templates/features/base/files/src/core/http/errors.ts +51 -0
  55. package/templates/features/base/files/src/core/http/health.ts +25 -0
  56. package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
  57. package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
  58. package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
  59. package/templates/features/base/files/src/core/http/response.ts +67 -0
  60. package/templates/features/base/files/src/core/http/routes.ts +28 -0
  61. package/templates/features/base/files/src/core/http/server.ts +15 -0
  62. package/templates/features/base/files/src/core/logging/logger.ts +17 -0
  63. package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
  64. package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
  65. package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
  66. package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
  67. package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
  68. package/templates/features/base/files/tests/helpers/api.ts +54 -0
  69. package/templates/features/base/files/tests/setup.ts +2 -0
  70. package/templates/features/base/files/tsconfig.json +20 -0
  71. package/templates/features/base/files/tsdown.config.ts +15 -0
  72. package/templates/features/base/files/vitest.config.ts +16 -0
  73. package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
  74. package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
  75. package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
  76. package/templates/features/better-auth/files/src/core/auth/principal.ts +23 -0
  77. package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
  78. package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
  79. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
  80. package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
  81. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
  82. package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
  83. package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
  84. package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
  85. package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
  86. package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
  87. package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
  88. package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
  89. package/templates/features/db/files/src/core/db/id.ts +13 -0
  90. package/templates/features/db/files/src/core/db/seed.ts +14 -0
  91. package/templates/features/db/files/tests/global-setup.ts +27 -0
  92. package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
  93. package/templates/features/db/files/tests/helpers/db.ts +7 -0
  94. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
  95. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
  96. package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
  97. package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
  98. package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
  99. package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
  100. package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
  101. package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
  102. package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
  103. package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
  104. package/templates/features/email/files/src/core/email/send.ts +22 -0
  105. package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
  106. package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
  107. package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
  108. package/templates/features/i18n/files/src/locales/ar.json +10 -0
  109. package/templates/features/i18n/files/src/locales/en.json +10 -0
  110. package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
  111. package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
  112. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
  113. package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
  114. package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
  115. package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
  116. package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
  117. package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
  118. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
  119. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
  120. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
  121. package/templates/features/keycloak/files/keycloak/realm.json +83 -0
  122. package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
  123. package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
  124. package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
  125. package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
  126. package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
  127. package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
  128. package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
  129. package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
  130. package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
  131. package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
  132. package/templates/features/prisma/files/prisma.config.ts +10 -0
  133. package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
  134. package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
  135. package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
  136. package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
  137. package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
  138. package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
  139. package/templates/features/storage/files/src/core/storage/index.ts +8 -0
  140. package/templates/features/storage/files/src/core/storage/local.ts +34 -0
  141. package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
  142. package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
  143. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
  144. package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
  145. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
  146. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
  147. package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
  148. package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
  149. package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
  150. package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
  151. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
  152. package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +126 -0
  153. package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
  154. package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
  155. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
  156. package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
  157. package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  158. package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  159. package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  160. package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
  161. package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
  162. package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
  163. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
  164. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
  165. package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  166. package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
  167. package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
  168. package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
  169. package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
  170. package/templates/versions.json +34 -0
  171. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Create and grow opinionated Hono backends",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "tatara": "./dist/bin.mjs"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "templates"
13
+ ],
14
+ "engines": {
15
+ "node": ">=24"
16
+ },
17
+ "dependencies": {
18
+ "@clack/prompts": "1.8.1",
19
+ "commander": "15.0.0",
20
+ "picocolors": "1.1.1",
21
+ "tinyexec": "1.3.1",
22
+ "tinyglobby": "0.2.17",
23
+ "yaml": "2.9.1"
24
+ },
25
+ "devDependencies": {
26
+ "tsdown": "0.23.1"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "tsdown",
33
+ "typecheck": "tsc --noEmit"
34
+ }
6
35
  }
@@ -0,0 +1,4 @@
1
+ import { createHttpApp } from "#core/http/app";
2
+
3
+ /** Internal/staff API on its own port; every /api route requires the admin role. */
4
+ export const app = createHttpApp("admin", { title: "__APP_NAME__ admin API" });
@@ -0,0 +1,5 @@
1
+ import { app } from "#apps/admin/app";
2
+ import { env } from "#core/config/env";
3
+ import { startServer } from "#core/http/server";
4
+
5
+ startServer(app, env.ADMIN_PORT, "admin");
@@ -0,0 +1,24 @@
1
+ import { createRoute } from "@hono/zod-openapi";
2
+ import { currentPrincipal } from "#core/auth/middleware";
3
+ import { errorResponses, jsonContent, success, successSchema } from "#core/http/response";
4
+ import { createRouter, defineRoutes } from "#core/http/routes";
5
+ import { greetingSchema } from "#modules/example/validators/greeting.validators";
6
+
7
+ const router = createRouter();
8
+
9
+ router.openapi(
10
+ createRoute({
11
+ method: "get",
12
+ path: "/",
13
+ tags: ["Example"],
14
+ summary: "Greet the signed-in admin",
15
+ security: [{ bearerAuth: ["admin"] }],
16
+ responses: {
17
+ 200: jsonContent(successSchema(greetingSchema), "A greeting"),
18
+ ...errorResponses(401, 403),
19
+ },
20
+ }),
21
+ (c) => c.json(success({ message: `Hello, admin ${currentPrincipal(c).userId}!` }), 200),
22
+ );
23
+
24
+ export default defineRoutes({ app: "admin", basePath: "/greetings", router });
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { adminApi } from "#tests/helpers/api";
3
+ import { asUser } from "#tests/helpers/auth";
4
+
5
+ describe("admin app", () => {
6
+ it("requires authentication", async () => {
7
+ const res = await adminApi.get("/api/greetings");
8
+ expect(res.status).toBe(401);
9
+ });
10
+
11
+ it("forbids users without the admin role", async () => {
12
+ const res = await adminApi.get("/api/greetings", { headers: await asUser() });
13
+ expect(res.status).toBe(403);
14
+ });
15
+
16
+ it("serves admins", async () => {
17
+ const res = await adminApi.get("/api/greetings", { headers: await asUser({ roles: ["admin"] }) });
18
+ expect(res.status).toBe(200);
19
+ });
20
+ });
@@ -0,0 +1,29 @@
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 "#core/http/errors";
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
+ }
@@ -0,0 +1,36 @@
1
+ import { createRoute, z } from "@hono/zod-openapi";
2
+ import { currentPrincipal, requireAuth } from "#core/auth/middleware";
3
+ import { errorResponses, jsonContent, success, successSchema } from "#core/http/response";
4
+ import { createRouter, defineRoutes } from "#core/http/routes";
5
+
6
+ const principalSchema = z
7
+ .object({
8
+ userId: z.string(),
9
+ orgId: z.string().nullable(),
10
+ roles: z.array(z.string()),
11
+ kind: z.enum(["user", "service"]),
12
+ })
13
+ .openapi("Principal");
14
+
15
+ const router = createRouter();
16
+
17
+ router.openapi(
18
+ createRoute({
19
+ method: "get",
20
+ path: "/",
21
+ tags: ["Auth"],
22
+ summary: "The authenticated principal",
23
+ middleware: [requireAuth()] as const,
24
+ security: [{ bearerAuth: [] }],
25
+ responses: {
26
+ 200: jsonContent(successSchema(principalSchema), "Who is calling"),
27
+ ...errorResponses(401),
28
+ },
29
+ }),
30
+ (c) => {
31
+ const { userId, orgId, roles, kind } = currentPrincipal(c);
32
+ return c.json(success({ userId, orgId, roles, kind }), 200);
33
+ },
34
+ );
35
+
36
+ export default defineRoutes({ app: "api", basePath: "/me", router });
@@ -0,0 +1,25 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { api } from "#tests/helpers/api";
3
+ import { asUser } from "#tests/helpers/auth";
4
+ import { resetDatabase } from "#tests/helpers/db";
5
+
6
+ beforeEach(async () => {
7
+ await resetDatabase();
8
+ });
9
+
10
+ describe("GET /api/me", () => {
11
+ it("returns 401 without credentials", async () => {
12
+ const res = await api.get("/api/me");
13
+ expect(res.status).toBe(401);
14
+ expect(res.body).toMatchObject({ success: false, code: "UNAUTHORIZED" });
15
+ });
16
+
17
+ it("returns the principal for an authenticated user", async () => {
18
+ const res = await api.get<{ data: { userId: string; kind: string } }>("/api/me", {
19
+ headers: await asUser(),
20
+ });
21
+ expect(res.status).toBe(200);
22
+ expect(res.body.data.kind).toBe("user");
23
+ expect(res.body.data.userId).toBeTruthy();
24
+ });
25
+ });
@@ -0,0 +1,38 @@
1
+ import { mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
2
+ import { users } from "#core/auth/db/auth.schema";
3
+
4
+ export const organizations = mysqlTable("organizations", {
5
+ id: varchar({ length: 36 }).primaryKey(),
6
+ name: varchar({ length: 255 }).notNull(),
7
+ slug: varchar({ length: 255 }).notNull().unique(),
8
+ logo: text(),
9
+ metadata: text(),
10
+ createdAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
11
+ });
12
+
13
+ export const members = mysqlTable("members", {
14
+ id: varchar({ length: 36 }).primaryKey(),
15
+ organizationId: varchar({ length: 36 })
16
+ .notNull()
17
+ .references(() => organizations.id, { onDelete: "cascade" }),
18
+ userId: varchar({ length: 36 })
19
+ .notNull()
20
+ .references(() => users.id, { onDelete: "cascade" }),
21
+ role: varchar({ length: 64 }).notNull().default("member"),
22
+ createdAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
23
+ });
24
+
25
+ export const invitations = mysqlTable("invitations", {
26
+ id: varchar({ length: 36 }).primaryKey(),
27
+ organizationId: varchar({ length: 36 })
28
+ .notNull()
29
+ .references(() => organizations.id, { onDelete: "cascade" }),
30
+ email: varchar({ length: 255 }).notNull(),
31
+ role: varchar({ length: 64 }),
32
+ status: varchar({ length: 32 }).notNull().default("pending"),
33
+ expiresAt: timestamp({ fsp: 3, mode: "date" }).notNull(),
34
+ inviterId: varchar({ length: 36 })
35
+ .notNull()
36
+ .references(() => users.id, { onDelete: "cascade" }),
37
+ createdAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
38
+ });
@@ -0,0 +1,38 @@
1
+ import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
2
+ import { users } from "#core/auth/db/auth.schema";
3
+
4
+ export const organizations = pgTable("organizations", {
5
+ id: text().primaryKey(),
6
+ name: text().notNull(),
7
+ slug: text().notNull().unique(),
8
+ logo: text(),
9
+ metadata: text(),
10
+ createdAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
11
+ });
12
+
13
+ export const members = pgTable("members", {
14
+ id: text().primaryKey(),
15
+ organizationId: text()
16
+ .notNull()
17
+ .references(() => organizations.id, { onDelete: "cascade" }),
18
+ userId: text()
19
+ .notNull()
20
+ .references(() => users.id, { onDelete: "cascade" }),
21
+ role: text().notNull().default("member"),
22
+ createdAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
23
+ });
24
+
25
+ export const invitations = pgTable("invitations", {
26
+ id: text().primaryKey(),
27
+ organizationId: text()
28
+ .notNull()
29
+ .references(() => organizations.id, { onDelete: "cascade" }),
30
+ email: text().notNull(),
31
+ role: text(),
32
+ status: text().notNull().default("pending"),
33
+ expiresAt: timestamp({ withTimezone: true, mode: "date" }).notNull(),
34
+ inviterId: text()
35
+ .notNull()
36
+ .references(() => users.id, { onDelete: "cascade" }),
37
+ createdAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
38
+ });
@@ -0,0 +1,41 @@
1
+ // better-auth organization plugin tables.
2
+
3
+ model Organization {
4
+ id String @id @db.VarChar(36)
5
+ name String @db.VarChar(255)
6
+ slug String @unique @db.VarChar(255)
7
+ logo String? @db.Text
8
+ metadata String? @db.Text
9
+ createdAt DateTime @default(now()) @map("created_at")
10
+ members Member[]
11
+ invitations Invitation[]
12
+
13
+ @@map("organizations")
14
+ }
15
+
16
+ model Member {
17
+ id String @id @db.VarChar(36)
18
+ organizationId String @map("organization_id") @db.VarChar(36)
19
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
20
+ userId String @map("user_id") @db.VarChar(36)
21
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
22
+ role String @default("member") @db.VarChar(64)
23
+ createdAt DateTime @default(now()) @map("created_at")
24
+
25
+ @@map("members")
26
+ }
27
+
28
+ model Invitation {
29
+ id String @id @db.VarChar(36)
30
+ organizationId String @map("organization_id") @db.VarChar(36)
31
+ organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
32
+ email String @db.VarChar(255)
33
+ role String? @db.VarChar(64)
34
+ status String @default("pending") @db.VarChar(32)
35
+ expiresAt DateTime @map("expires_at")
36
+ inviterId String @map("inviter_id") @db.VarChar(36)
37
+ inviter User @relation(fields: [inviterId], references: [id], onDelete: Cascade)
38
+ createdAt DateTime @default(now()) @map("created_at")
39
+
40
+ @@map("invitations")
41
+ }
@@ -0,0 +1,31 @@
1
+ import { CACHE_TTL_MS } from "#core/authz/constants";
2
+ import type { EffectivePermissions } from "#core/authz/types";
3
+
4
+ const entries = new Map<string, { value: EffectivePermissions; expires: number }>();
5
+
6
+ const key = (orgId: string, userId: string, roles: string[]) =>
7
+ `${orgId}:${userId}:${[...roles].sort().join(",")}`;
8
+
9
+ export function getCached(orgId: string, userId: string, roles: string[]) {
10
+ const entry = entries.get(key(orgId, userId, roles));
11
+ if (!entry || entry.expires < Date.now()) return undefined;
12
+ return entry.value;
13
+ }
14
+
15
+ export function setCached(orgId: string, userId: string, roles: string[], value: EffectivePermissions) {
16
+ if (entries.size > 10_000) entries.clear();
17
+ entries.set(key(orgId, userId, roles), { value, expires: Date.now() + CACHE_TTL_MS });
18
+ }
19
+
20
+ /** Drops cached sets for one member (any org when orgId is omitted). */
21
+ export function invalidateMember(userId: string, orgId?: string) {
22
+ for (const k of entries.keys()) {
23
+ const [o, u] = k.split(":");
24
+ if (u === userId && (!orgId || o === orgId)) entries.delete(k);
25
+ }
26
+ }
27
+
28
+ /** Role definitions changed: every cached set may be stale. */
29
+ export function invalidateAll() {
30
+ entries.clear();
31
+ }
@@ -0,0 +1,85 @@
1
+ import { SCOPES, type Scope } from "#core/authz/constants";
2
+ import type { SystemRoleKey } from "#core/authz/system-roles";
3
+
4
+ export interface ActionSpec {
5
+ /** scoped actions are checked at own | team | org */
6
+ readonly scoped: boolean;
7
+ }
8
+
9
+ export type ResourceSpecs = Record<string, Record<string, ActionSpec>>;
10
+
11
+ export type GrantTuple = readonly [resource: string, action: string, scope?: Scope];
12
+
13
+ export interface PermissionSlice<R extends ResourceSpecs = ResourceSpecs> {
14
+ /** resources this module owns and the actions on them */
15
+ readonly resources: R;
16
+ /** grants this module adds to each built-in role, for its own resources */
17
+ readonly roleGrants?: Partial<Record<SystemRoleKey, readonly GrantTuple[]>>;
18
+ }
19
+
20
+ /** "resource:action" keys of one slice, for typed `requirePermission(...)` calls. */
21
+ export type PermissionKeysOf<S> = S extends PermissionSlice<infer R>
22
+ ? { [K in keyof R & string]: `${K}:${keyof R[K] & string}` }[keyof R & string]
23
+ : never;
24
+
25
+ /** Default export of every `permissions/*.permissions.ts`; merged by `tatara sync`. */
26
+ export function definePermissions<const R extends ResourceSpecs>(
27
+ slice: PermissionSlice<R>,
28
+ ): PermissionSlice<R> {
29
+ return slice;
30
+ }
31
+
32
+ export interface Catalog {
33
+ resources: ResourceSpecs;
34
+ keys: string[];
35
+ roleGrants: Partial<Record<SystemRoleKey, GrantTuple[]>>;
36
+ }
37
+
38
+ export function mergeCatalogs(...slices: PermissionSlice[]): Catalog {
39
+ const resources: Record<string, Record<string, ActionSpec>> = {};
40
+ const roleGrants: Partial<Record<SystemRoleKey, GrantTuple[]>> = {};
41
+ for (const slice of slices) {
42
+ for (const [resource, actions] of Object.entries(slice.resources)) {
43
+ if (resources[resource]) throw new Error(`Permission resource "${resource}" is declared twice`);
44
+ resources[resource] = actions;
45
+ }
46
+ for (const [role, grants] of Object.entries(slice.roleGrants ?? {})) {
47
+ const key = role as SystemRoleKey;
48
+ roleGrants[key] = [...(roleGrants[key] ?? []), ...(grants ?? [])];
49
+ }
50
+ }
51
+ const keys = Object.entries(resources).flatMap(([resource, actions]) =>
52
+ Object.keys(actions).map((action) => `${resource}:${action}`),
53
+ );
54
+ return { resources, keys, roleGrants };
55
+ }
56
+
57
+ /** Resources only built-in roles may grant: authorization itself. */
58
+ export const META_RESOURCES: ReadonlySet<string> = new Set(["role", "member", "audit_log"]);
59
+
60
+ export class InvalidGrantError extends Error {}
61
+
62
+ /** Throws unless (resource, action, scope) exists in the catalog with a matching scope shape. */
63
+ export function assertGrantValid(
64
+ catalog: Catalog,
65
+ grant: { resource: string; action: string; scope?: string | null },
66
+ ): void {
67
+ const spec = catalog.resources[grant.resource]?.[grant.action];
68
+ if (!spec) throw new InvalidGrantError(`Unknown permission ${grant.resource}:${grant.action}`);
69
+ const scope = grant.scope ?? null;
70
+ if (spec.scoped && !scope) {
71
+ throw new InvalidGrantError(
72
+ `${grant.resource}:${grant.action} is scoped; pass one of ${SCOPES.join(", ")}`,
73
+ );
74
+ }
75
+ if (spec.scoped && scope && !(SCOPES as readonly string[]).includes(scope)) {
76
+ throw new InvalidGrantError(`Scope "${scope}" must be one of ${SCOPES.join(", ")}`);
77
+ }
78
+ if (!spec.scoped && scope) {
79
+ throw new InvalidGrantError(`${grant.resource}:${grant.action} is not scoped; drop the scope`);
80
+ }
81
+ }
82
+
83
+ export function isScoped(catalog: Catalog, resource: string, action: string): boolean {
84
+ return catalog.resources[resource]?.[action]?.scoped === true;
85
+ }
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "#core/authz/constants";
3
+ import { store } from "#core/authz/db/store";
4
+ import { assignRole } from "#core/authz/services/members";
5
+ import { defineCommand } from "#core/commands/define";
6
+
7
+ export default defineCommand({
8
+ name: "authz:grant-role",
9
+ description: "Assign a role (built-in key or custom role key) to a user in an org",
10
+ args: z.object({
11
+ user: z.string().min(1),
12
+ role: z.string().min(1),
13
+ org: z.string().default(DEFAULT_ORG_ID),
14
+ }),
15
+ async run({ user, role, org }, { logger }) {
16
+ const found = (await store.findRoleByKey(org, role)) ?? (await store.findRoleByKey(SYSTEM_ORG_ID, role));
17
+ if (!found) throw new Error(`No role "${role}" (run authz:sync-permissions first for built-in roles)`);
18
+ const added = await assignRole(null, org, user, found.id);
19
+ logger.info({ user, role, org, added }, added ? "role assigned" : "role already assigned");
20
+ },
21
+ });
@@ -0,0 +1,11 @@
1
+ import { defineCommand } from "#core/commands/define";
2
+ import { syncSystemRoles } from "#core/authz/services/sync";
3
+
4
+ export default defineCommand({
5
+ name: "authz:sync-permissions",
6
+ description: "Create/update built-in roles so their grants match the code (run on every deploy)",
7
+ async run(_args, { logger }) {
8
+ const result = await syncSystemRoles();
9
+ logger.info(result, "built-in roles in sync");
10
+ },
11
+ });
@@ -0,0 +1,28 @@
1
+ /** Scope hierarchy: a grant at a higher rank satisfies a check at a lower one (org > team > own). */
2
+ export const SCOPES = ["own", "team", "org"] as const;
3
+ export type Scope = (typeof SCOPES)[number];
4
+
5
+ export const SCOPE_RANK: Record<Scope, number> = { own: 1, team: 2, org: 3 };
6
+
7
+ export const OVERLAY_EFFECTS = ["grant", "deny"] as const;
8
+ export type OverlayEffect = (typeof OVERLAY_EFFECTS)[number];
9
+
10
+ export const AUDIT_CHANGE_TYPES = [
11
+ "role_created",
12
+ "role_updated",
13
+ "role_deleted",
14
+ "member_role_assigned",
15
+ "member_role_revoked",
16
+ "overlay_granted",
17
+ "overlay_revoked",
18
+ ] as const;
19
+ export type AuditChangeType = (typeof AUDIT_CHANGE_TYPES)[number];
20
+
21
+ /** Org used when the principal has none (single-tenant apps, users without an active org). */
22
+ export const DEFAULT_ORG_ID = "default";
23
+
24
+ /** Org id stored on system (built-in) roles; they apply to every org. */
25
+ export const SYSTEM_ORG_ID = "";
26
+
27
+ /** How long a resolved permission set is cached in-process. */
28
+ export const CACHE_TTL_MS = 30_000;
@@ -0,0 +1,10 @@
1
+ import { syncSystemRoles } from "#core/authz/services/sync";
2
+ import { defineSeed } from "#core/db/seed";
3
+
4
+ export default defineSeed({
5
+ name: "authz:system-roles",
6
+ order: -10,
7
+ async run({ logger }) {
8
+ logger.info(await syncSystemRoles(), "built-in roles in sync");
9
+ },
10
+ });
@@ -0,0 +1,21 @@
1
+ import { createMiddleware } from "hono/factory";
2
+ import type { Scope } from "#core/authz/constants";
3
+ import { checkPermission } from "#core/authz/resolver";
4
+ import type { AppEnv } from "#core/http/context";
5
+ import { ForbiddenError, UnauthorizedError } from "#core/http/errors";
6
+ import type { PermissionKey } from "#generated/permissions";
7
+
8
+ /**
9
+ * 401 without a principal, 403 unless it holds `resource:action` (at `scope` or wider).
10
+ * Pair with `security: [{ bearerAuth: ["resource:action"] }]` so the route documents it.
11
+ */
12
+ export function requirePermission(permission: PermissionKey, scope?: Scope) {
13
+ return createMiddleware<AppEnv>(async (c, next) => {
14
+ const principal = c.get("principal");
15
+ if (!principal) throw new UnauthorizedError();
16
+ if (!(await checkPermission(principal, permission, scope))) {
17
+ throw new ForbiddenError(`Missing permission ${permission}${scope ? ` (${scope})` : ""}`);
18
+ }
19
+ await next();
20
+ });
21
+ }
@@ -0,0 +1,31 @@
1
+ import { definePermissions } from "#core/authz/catalog";
2
+
3
+ /** Authorization's own resources; only built-in roles may grant them. */
4
+ export default definePermissions({
5
+ resources: {
6
+ role: {
7
+ read: { scoped: false },
8
+ create: { scoped: false },
9
+ update: { scoped: false },
10
+ delete: { scoped: false },
11
+ },
12
+ member: {
13
+ read: { scoped: false },
14
+ update: { scoped: false },
15
+ },
16
+ audit_log: {
17
+ read: { scoped: false },
18
+ },
19
+ },
20
+ roleGrants: {
21
+ admin: [
22
+ ["role", "read"],
23
+ ["role", "create"],
24
+ ["role", "update"],
25
+ ["role", "delete"],
26
+ ["member", "read"],
27
+ ["member", "update"],
28
+ ["audit_log", "read"],
29
+ ],
30
+ },
31
+ });
@@ -0,0 +1,98 @@
1
+ import { getCached, setCached } from "#core/authz/cache";
2
+ import { DEFAULT_ORG_ID, SCOPE_RANK, type Scope } from "#core/authz/constants";
3
+ import { store } from "#core/authz/db/store";
4
+ import { SYSTEM_ROLE_KEYS } from "#core/authz/system-roles";
5
+ import { type EffectivePermissions, grantKey, type PermissionGrant } from "#core/authz/types";
6
+ import type { Principal } from "#core/http/context";
7
+
8
+ /** The org a principal acts in: its active org, or the default org. */
9
+ export function orgOf(principal: Pick<Principal, "orgId">): string {
10
+ return principal.orgId ?? DEFAULT_ORG_ID;
11
+ }
12
+
13
+ /** Roles from the identity provider that match a built-in role key are honoured as that role. */
14
+ function impliedRoles(principal: Pick<Principal, "roles">): string[] {
15
+ return principal.roles.filter((role) => SYSTEM_ROLE_KEYS.has(role));
16
+ }
17
+
18
+ /**
19
+ * Effective permissions = grants of the member's roles (assigned in the org, plus built-in
20
+ * roles implied by the identity provider) + active grant overlays − active deny overlays.
21
+ */
22
+ export async function effectivePermissions(
23
+ principal: Pick<Principal, "userId" | "orgId" | "roles">,
24
+ ): Promise<EffectivePermissions> {
25
+ const orgId = orgOf(principal);
26
+ const implied = impliedRoles(principal);
27
+ const cached = getCached(orgId, principal.userId, implied);
28
+ if (cached) return cached;
29
+
30
+ const [{ roles, grants }, overlays] = await Promise.all([
31
+ store.roleGrants(orgId, principal.userId, implied),
32
+ store.activeOverlays(orgId, principal.userId, new Date()),
33
+ ]);
34
+ const all: PermissionGrant[] = [...grants];
35
+ const denies = new Set<string>();
36
+ for (const overlay of overlays) {
37
+ const grant = { resource: overlay.resource, action: overlay.action, scope: overlay.scope };
38
+ if (overlay.effect === "deny") denies.add(grantKey(grant));
39
+ else all.push(grant);
40
+ }
41
+ const value = { grants: all, denies, roles };
42
+ setCached(orgId, principal.userId, implied, value);
43
+ return value;
44
+ }
45
+
46
+ /** Pure check against a resolved set: deny wins; scoped checks honour org > team > own. */
47
+ export function allows(
48
+ permissions: EffectivePermissions,
49
+ resource: string,
50
+ action: string,
51
+ scope?: Scope,
52
+ ): boolean {
53
+ if (permissions.denies.has(`${resource}:${action}:${scope ?? "*"}`)) return false;
54
+ if (scope && permissions.denies.has(`${resource}:${action}:*`)) return false;
55
+ // a denied grant can never satisfy a check, even a narrower one
56
+ const live = permissions.grants.filter(
57
+ (g) => !permissions.denies.has(grantKey(g)) && !permissions.denies.has(`${g.resource}:${g.action}:*`),
58
+ );
59
+ if (!scope) {
60
+ return live.some((g) => g.resource === resource && g.action === action);
61
+ }
62
+ const required = SCOPE_RANK[scope];
63
+ return live.some(
64
+ (g) =>
65
+ g.resource === resource &&
66
+ g.action === action &&
67
+ g.scope !== null &&
68
+ SCOPE_RANK[g.scope] >= required,
69
+ );
70
+ }
71
+
72
+ export async function checkPermission(
73
+ principal: Pick<Principal, "userId" | "orgId" | "roles">,
74
+ permission: string,
75
+ scope?: Scope,
76
+ ): Promise<boolean> {
77
+ const [resource = "", action = ""] = permission.split(":");
78
+ return allows(await effectivePermissions(principal), resource, action, scope);
79
+ }
80
+
81
+ /**
82
+ * The widest scope the principal holds for a scoped action. Middleware proves the caller
83
+ * may act at some scope; services use this to decide which rows to return.
84
+ */
85
+ export async function resolveScope(
86
+ principal: Pick<Principal, "userId" | "orgId" | "roles">,
87
+ permission: string,
88
+ ): Promise<Scope | null> {
89
+ const [resource = "", action = ""] = permission.split(":");
90
+ const permissions = await effectivePermissions(principal);
91
+ let best: Scope | null = null;
92
+ for (const g of permissions.grants) {
93
+ if (g.resource !== resource || g.action !== action || g.scope === null) continue;
94
+ if (permissions.denies.has(grantKey(g))) continue;
95
+ if (!best || SCOPE_RANK[g.scope] > SCOPE_RANK[best]) best = g.scope;
96
+ }
97
+ return best;
98
+ }