@tatara/cli 0.4.0 → 0.6.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 (42) hide show
  1. package/dist/bin.mjs +377 -94
  2. package/package.json +1 -1
  3. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +1 -1
  4. package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +1 -1
  5. package/templates/features/auth-keycloak/files/src/core/auth/commands/keycloak-token.ts +2 -2
  6. package/templates/features/auth-keycloak/files/src/core/auth/keycloak.ts +7 -56
  7. package/templates/features/auth-keycloak/files/src/core/auth/tests/keycloak-tokens.test.ts +2 -2
  8. package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +2 -2
  9. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +5 -5
  10. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +2 -2
  11. package/templates/features/authz/files/src/core/authz/db/seed.ts +2 -2
  12. package/templates/features/authz/files/src/core/authz/index.ts +9 -0
  13. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +9 -280
  14. package/templates/features/authz/files/src/core/authz/system-roles.ts +0 -2
  15. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +1 -2
  16. package/templates/features/authz/files/tests/helpers/authz.ts +6 -8
  17. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +3 -3
  18. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +3 -3
  19. package/templates/features/better-auth/files/src/core/auth/delivery.ts +6 -30
  20. package/templates/features/better-auth/files/src/core/auth/principal.ts +7 -38
  21. package/templates/features/better-auth/files/src/core/auth/sms.ts +3 -12
  22. package/templates/features/better-auth/files/tests/helpers/auth.ts +9 -12
  23. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  24. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  25. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  26. package/templates/versions.json +4 -1
  27. package/templates/features/auth/files/src/core/auth/middleware.ts +0 -29
  28. package/templates/features/auth-keycloak/files/src/core/auth/keycloak-plugin.ts +0 -154
  29. package/templates/features/authz/files/src/core/authz/cache.ts +0 -31
  30. package/templates/features/authz/files/src/core/authz/catalog.ts +0 -85
  31. package/templates/features/authz/files/src/core/authz/constants.ts +0 -32
  32. package/templates/features/authz/files/src/core/authz/middleware.ts +0 -21
  33. package/templates/features/authz/files/src/core/authz/permissions.ts +0 -31
  34. package/templates/features/authz/files/src/core/authz/resolver.ts +0 -98
  35. package/templates/features/authz/files/src/core/authz/services/audit.ts +0 -24
  36. package/templates/features/authz/files/src/core/authz/services/grants.ts +0 -29
  37. package/templates/features/authz/files/src/core/authz/services/members.ts +0 -84
  38. package/templates/features/authz/files/src/core/authz/services/roles.ts +0 -73
  39. package/templates/features/authz/files/src/core/authz/services/sync.ts +0 -34
  40. package/templates/features/authz/files/src/core/authz/types.ts +0 -87
  41. package/templates/features/authz/files/src/core/authz/validators.ts +0 -99
  42. package/templates/features/better-auth/files/src/core/auth/openapi.ts +0 -60
@@ -1,5 +1,5 @@
1
1
  import { createRoute } from "@hono/zod-openapi";
2
- import { currentPrincipal } from "#core/auth/middleware";
2
+ import { currentPrincipal } from "@tatara/auth";
3
3
  import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
4
4
  import { createRouter, defineRoutes } from "#core/http/routes";
5
5
  import { greetingSchema } from "#modules/example/validators/greeting.validators";
@@ -1,5 +1,5 @@
1
1
  import { createRoute, z } from "@hono/zod-openapi";
2
- import { currentPrincipal, requireAuth } from "#core/auth/middleware";
2
+ import { currentPrincipal, requireAuth } from "@tatara/auth";
3
3
  import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
4
4
  import { createRouter, defineRoutes } from "#core/http/routes";
5
5
 
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { issuer } from "#core/auth/keycloak";
2
+ import { keycloak } from "#core/auth/keycloak";
3
3
  import { defineCommand } from "@tatara/core";
4
4
  import { env } from "#core/config/env";
5
5
 
@@ -13,7 +13,7 @@ export default defineCommand({
13
13
  }),
14
14
  async run({ user, password, client }) {
15
15
  if (env.NODE_ENV === "production") throw new Error("keycloak:token is for development only");
16
- const res = await fetch(`${issuer}/protocol/openid-connect/token`, {
16
+ const res = await fetch(`${keycloak.issuer}/protocol/openid-connect/token`, {
17
17
  method: "POST",
18
18
  headers: { "content-type": "application/x-www-form-urlencoded" },
19
19
  body: new URLSearchParams({
@@ -1,58 +1,9 @@
1
- import { createRemoteJWKSet, type JWTPayload, type JWTVerifyGetKey, jwtVerify } from "jose";
1
+ import { keycloakVerifier } from "@tatara/keycloak";
2
2
  import { env } from "#core/config/env";
3
3
 
4
- export const issuer = `${env.KEYCLOAK_URL.replace(/\/$/, "")}/realms/${env.KEYCLOAK_REALM}`;
5
-
6
- let keySource: JWTVerifyGetKey = createRemoteJWKSet(
7
- new URL(`${issuer}/protocol/openid-connect/certs`),
8
- );
9
-
10
- /** Tests replace the realm's key set with a local one. */
11
- export function setKeySource(source: JWTVerifyGetKey): void {
12
- keySource = source;
13
- }
14
-
15
- export interface KeycloakClaims extends JWTPayload {
16
- sub: string;
17
- email?: string;
18
- name?: string;
19
- preferred_username?: string;
20
- azp?: string;
21
- roles?: string[];
22
- realm_access?: { roles?: string[] };
23
- resource_access?: Record<string, { roles?: string[] }>;
24
- organization?: string[] | Record<string, unknown>;
25
- }
26
-
27
- /** Verifies signature, issuer, audience and expiry. Throws when the token is not valid. */
28
- export async function verifyAccessToken(token: string): Promise<KeycloakClaims> {
29
- const { payload } = await jwtVerify(token, keySource, {
30
- issuer,
31
- audience: env.KEYCLOAK_AUDIENCE,
32
- });
33
- if (typeof payload.sub !== "string") throw new Error("token has no subject");
34
- return payload as KeycloakClaims;
35
- }
36
-
37
- export function rolesOf(claims: KeycloakClaims): string[] {
38
- const roles = new Set<string>([
39
- ...(claims.roles ?? []),
40
- ...(claims.realm_access?.roles ?? []),
41
- ...(claims.resource_access?.[env.KEYCLOAK_AUDIENCE]?.roles ?? []),
42
- ]);
43
- return [...roles];
44
- }
45
-
46
- /** Keycloak organizations put memberships in the `organization` claim; the first one wins. */
47
- export function organizationOf(claims: KeycloakClaims): string | null {
48
- const org = claims.organization;
49
- if (Array.isArray(org)) return org[0] ?? null;
50
- if (org && typeof org === "object") {
51
- const [first] = Object.entries(org);
52
- if (!first) return null;
53
- const [alias, value] = first;
54
- const id = (value as { id?: unknown } | undefined)?.id;
55
- return typeof id === "string" ? id : alias;
56
- }
57
- return null;
58
- }
4
+ /** Verifies access tokens from the realm; `keycloakTokens()` in auth.ts uses it. */
5
+ export const keycloak = keycloakVerifier({
6
+ url: env.KEYCLOAK_URL,
7
+ realm: env.KEYCLOAK_REALM,
8
+ audience: env.KEYCLOAK_AUDIENCE,
9
+ });
@@ -1,7 +1,7 @@
1
1
  import { generateKeyPair, SignJWT } from "jose";
2
2
  import { beforeEach, describe, expect, it } from "vitest";
3
3
  import { auth } from "#core/auth/auth";
4
- import { issuer } from "#core/auth/keycloak";
4
+ import { keycloak } from "#core/auth/keycloak";
5
5
  import { env } from "#core/config/env";
6
6
  import { newId } from "@tatara/db";
7
7
  import { api } from "#tests/helpers/api";
@@ -63,7 +63,7 @@ describe("Keycloak access tokens", () => {
63
63
  const forged = await new SignJWT({ roles: ["admin"] })
64
64
  .setProtectedHeader({ alg: "RS256", kid: "test" })
65
65
  .setSubject(newId())
66
- .setIssuer(issuer)
66
+ .setIssuer(keycloak.issuer)
67
67
  .setAudience(env.KEYCLOAK_AUDIENCE)
68
68
  .setExpirationTime("5m")
69
69
  .sign(privateKey);
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
- import { syncSystemRoles } from "#core/authz/services/sync";
2
+ import { authz } from "#core/authz/index";
3
3
  import { api } from "#tests/helpers/api";
4
4
  import { keycloakToken } from "#tests/helpers/auth";
5
5
  import { resetDatabase } from "#tests/helpers/db";
@@ -12,7 +12,7 @@ beforeEach(async () => {
12
12
 
13
13
  describe("Keycloak access tokens with authz", () => {
14
14
  it("lets token roles match built-in authz roles", async () => {
15
- await syncSystemRoles();
15
+ await authz.syncSystemRoles();
16
16
  const admin = await keycloakToken({ roles: ["admin"] });
17
17
  const user = await keycloakToken({ roles: ["user"] });
18
18
  expect((await api.get("/api/authorization/roles", { headers: bearer(admin) })).status).toBe(
@@ -1,7 +1,6 @@
1
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";
2
+ import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "@tatara/authz";
3
+ import { authz } from "#core/authz/index";
5
4
  import { defineCommand } from "@tatara/core";
6
5
 
7
6
  export default defineCommand({
@@ -13,9 +12,10 @@ export default defineCommand({
13
12
  org: z.string().default(DEFAULT_ORG_ID),
14
13
  }),
15
14
  async run({ user, role, org }, { logger }) {
16
- const found = (await store.findRoleByKey(org, role)) ?? (await store.findRoleByKey(SYSTEM_ORG_ID, role));
15
+ const found = (await authz.store.findRoleByKey(org, role)) ??
16
+ (await authz.store.findRoleByKey(SYSTEM_ORG_ID, role));
17
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);
18
+ const added = await authz.assignRole(null, org, user, found.id);
19
19
  logger.info({ user, role, org, added }, added ? "role assigned" : "role already assigned");
20
20
  },
21
21
  });
@@ -1,11 +1,11 @@
1
1
  import { defineCommand } from "@tatara/core";
2
- import { syncSystemRoles } from "#core/authz/services/sync";
2
+ import { authz } from "#core/authz/index";
3
3
 
4
4
  export default defineCommand({
5
5
  name: "authz:sync-permissions",
6
6
  description: "Create/update built-in roles so their grants match the code (run on every deploy)",
7
7
  async run(_args, { logger }) {
8
- const result = await syncSystemRoles();
8
+ const result = await authz.syncSystemRoles();
9
9
  logger.info(result, "built-in roles in sync");
10
10
  },
11
11
  });
@@ -1,10 +1,10 @@
1
- import { syncSystemRoles } from "#core/authz/services/sync";
1
+ import { authz } from "#core/authz/index";
2
2
  import { defineSeed } from "@tatara/db";
3
3
 
4
4
  export default defineSeed({
5
5
  name: "authz:system-roles",
6
6
  order: -10,
7
7
  async run({ logger }) {
8
- logger.info(await syncSystemRoles(), "built-in roles in sync");
8
+ logger.info(await authz.syncSystemRoles(), "built-in roles in sync");
9
9
  },
10
10
  });
@@ -0,0 +1,9 @@
1
+ import { createAuthz } from "@tatara/authz";
2
+ import { store } from "#core/authz/db/store";
3
+ import { SYSTEM_ROLES } from "#core/authz/system-roles";
4
+ import { catalog, type PermissionKey } from "#generated/permissions";
5
+
6
+ /** The app's authorization: its store, the permission catalog from `tatara sync`, built-in roles. */
7
+ export const authz = createAuthz<PermissionKey>({ store, catalog, systemRoles: SYSTEM_ROLES });
8
+
9
+ export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
@@ -1,281 +1,10 @@
1
- import { createRoute, z } from "@hono/zod-openapi";
2
- import { currentPrincipal, requireAuth } from "#core/auth/middleware";
3
- import { requirePermission } from "#core/authz/middleware";
4
- import { effectivePermissions, orgOf } from "#core/authz/resolver";
5
- import { listAudit } from "#core/authz/services/audit";
6
- import { addOverlay, assignRole, getMember, removeOverlay, revokeRole } from "#core/authz/services/members";
7
- import { createRole, deleteRole, listRoles, updateRole } from "#core/authz/services/roles";
8
- import type { OverlayRow } from "#core/authz/types";
9
- import {
10
- auditEntrySchema,
11
- createRoleSchema,
12
- idParams,
13
- memberSchema,
14
- myPermissionsSchema,
15
- overlayInputSchema,
16
- roleSchema,
17
- updateRoleSchema,
18
- userOverlayParams,
19
- userParams,
20
- userRoleParams,
21
- } from "#core/authz/validators";
22
- import { errorResponses, jsonBody, jsonContent, success, successSchema } from "@tatara/http";
23
- import { createRouter, defineRoutes } from "#core/http/routes";
24
- import { catalog } from "#generated/permissions";
25
-
26
- const router = createRouter();
27
- const tags = ["Authorization"];
28
- const toOverlay = (o: OverlayRow) => ({
29
- id: o.id,
30
- resource: o.resource,
31
- action: o.action,
32
- scope: o.scope,
33
- effect: o.effect,
34
- reason: o.reason,
35
- expiresAt: o.expiresAt ? o.expiresAt.toISOString() : null,
1
+ import { authorizationRoutes } from "@tatara/authz";
2
+ import { authz } from "#core/authz/index";
3
+ import type { AppEnv } from "#core/http/context";
4
+ import { defineRoutes } from "#core/http/routes";
5
+
6
+ export default defineRoutes({
7
+ app: "api",
8
+ basePath: "/authorization",
9
+ router: authorizationRoutes<AppEnv>(authz),
36
10
  });
37
-
38
- router.openapi(
39
- createRoute({
40
- method: "get",
41
- path: "/catalog",
42
- tags,
43
- summary: "Every permission the API knows",
44
- middleware: [requireAuth()] as const,
45
- security: [{ bearerAuth: [] }],
46
- responses: {
47
- 200: jsonContent(
48
- successSchema(z.object({ keys: z.array(z.string()), resources: z.record(z.string(), z.unknown()) })),
49
- "Permission catalog",
50
- ),
51
- ...errorResponses(401),
52
- },
53
- }),
54
- (c) => c.json(success({ keys: catalog.keys, resources: catalog.resources }), 200),
55
- );
56
-
57
- router.openapi(
58
- createRoute({
59
- method: "get",
60
- path: "/me",
61
- tags,
62
- summary: "The caller's effective permissions in their org",
63
- middleware: [requireAuth()] as const,
64
- security: [{ bearerAuth: [] }],
65
- responses: { 200: jsonContent(successSchema(myPermissionsSchema), "Effective permissions"), ...errorResponses(401) },
66
- }),
67
- async (c) => {
68
- const principal = currentPrincipal(c);
69
- const permissions = await effectivePermissions(principal);
70
- return c.json(
71
- success({
72
- orgId: orgOf(principal),
73
- roles: permissions.roles,
74
- grants: permissions.grants,
75
- denies: [...permissions.denies],
76
- }),
77
- 200,
78
- );
79
- },
80
- );
81
-
82
- router.openapi(
83
- createRoute({
84
- method: "get",
85
- path: "/roles",
86
- tags,
87
- summary: "Built-in roles and the org's custom roles",
88
- middleware: [requirePermission("role:read")] as const,
89
- security: [{ bearerAuth: ["role:read"] }],
90
- responses: { 200: jsonContent(successSchema(z.array(roleSchema)), "Roles"), ...errorResponses(401, 403) },
91
- }),
92
- async (c) => c.json(success(await listRoles(orgOf(currentPrincipal(c)))), 200),
93
- );
94
-
95
- router.openapi(
96
- createRoute({
97
- method: "post",
98
- path: "/roles",
99
- tags,
100
- summary: "Create a custom role",
101
- middleware: [requirePermission("role:create")] as const,
102
- security: [{ bearerAuth: ["role:create"] }],
103
- request: { body: jsonBody(createRoleSchema, "The role") },
104
- responses: {
105
- 201: jsonContent(successSchema(roleSchema), "Created"),
106
- ...errorResponses(400, 409),
107
- ...errorResponses(401, 403),
108
- },
109
- }),
110
- async (c) => {
111
- const principal = currentPrincipal(c);
112
- const role = await createRole(principal.userId, orgOf(principal), c.req.valid("json"));
113
- return c.json(success(role), 201);
114
- },
115
- );
116
-
117
- router.openapi(
118
- createRoute({
119
- method: "patch",
120
- path: "/roles/{id}",
121
- tags,
122
- summary: "Update a custom role",
123
- middleware: [requirePermission("role:update")] as const,
124
- security: [{ bearerAuth: ["role:update"] }],
125
- request: { params: idParams, body: jsonBody(updateRoleSchema, "Fields to change") },
126
- responses: {
127
- 200: jsonContent(successSchema(roleSchema), "Updated"),
128
- ...errorResponses(400, 404),
129
- ...errorResponses(401, 403),
130
- },
131
- }),
132
- async (c) => {
133
- const principal = currentPrincipal(c);
134
- const role = await updateRole(principal.userId, orgOf(principal), c.req.valid("param").id, c.req.valid("json"));
135
- return c.json(success(role), 200);
136
- },
137
- );
138
-
139
- router.openapi(
140
- createRoute({
141
- method: "delete",
142
- path: "/roles/{id}",
143
- tags,
144
- summary: "Delete a custom role that no member holds",
145
- middleware: [requirePermission("role:delete")] as const,
146
- security: [{ bearerAuth: ["role:delete"] }],
147
- request: { params: idParams },
148
- responses: { 204: { description: "Deleted" }, ...errorResponses(400, 404, 409), ...errorResponses(401, 403) },
149
- }),
150
- async (c) => {
151
- const principal = currentPrincipal(c);
152
- await deleteRole(principal.userId, orgOf(principal), c.req.valid("param").id);
153
- return c.body(null, 204);
154
- },
155
- );
156
-
157
- router.openapi(
158
- createRoute({
159
- method: "get",
160
- path: "/members/{userId}",
161
- tags,
162
- summary: "A member's roles and overlays",
163
- middleware: [requirePermission("member:read")] as const,
164
- security: [{ bearerAuth: ["member:read"] }],
165
- request: { params: userParams },
166
- responses: { 200: jsonContent(successSchema(memberSchema), "Member"), ...errorResponses(401, 403) },
167
- }),
168
- async (c) => {
169
- const member = await getMember(orgOf(currentPrincipal(c)), c.req.valid("param").userId);
170
- return c.json(success({ ...member, overlays: member.overlays.map(toOverlay) }), 200);
171
- },
172
- );
173
-
174
- router.openapi(
175
- createRoute({
176
- method: "post",
177
- path: "/members/{userId}/roles",
178
- tags,
179
- summary: "Assign a role to a member",
180
- middleware: [requirePermission("member:update")] as const,
181
- security: [{ bearerAuth: ["member:update"] }],
182
- request: { params: userParams, body: jsonBody(z.object({ roleId: z.string() }), "Role") },
183
- responses: {
184
- 200: jsonContent(successSchema(z.object({ assigned: z.boolean() })), "Assigned"),
185
- ...errorResponses(404),
186
- ...errorResponses(401, 403),
187
- },
188
- }),
189
- async (c) => {
190
- const principal = currentPrincipal(c);
191
- const assigned = await assignRole(
192
- principal.userId,
193
- orgOf(principal),
194
- c.req.valid("param").userId,
195
- c.req.valid("json").roleId,
196
- );
197
- return c.json(success({ assigned }), 200);
198
- },
199
- );
200
-
201
- router.openapi(
202
- createRoute({
203
- method: "delete",
204
- path: "/members/{userId}/roles/{roleId}",
205
- tags,
206
- summary: "Revoke a role from a member",
207
- middleware: [requirePermission("member:update")] as const,
208
- security: [{ bearerAuth: ["member:update"] }],
209
- request: { params: userRoleParams },
210
- responses: { 204: { description: "Revoked" }, ...errorResponses(404), ...errorResponses(401, 403) },
211
- }),
212
- async (c) => {
213
- const principal = currentPrincipal(c);
214
- const { userId, roleId } = c.req.valid("param");
215
- await revokeRole(principal.userId, orgOf(principal), userId, roleId);
216
- return c.body(null, 204);
217
- },
218
- );
219
-
220
- router.openapi(
221
- createRoute({
222
- method: "post",
223
- path: "/members/{userId}/overlays",
224
- tags,
225
- summary: "Grant or deny one permission to a member",
226
- middleware: [requirePermission("member:update")] as const,
227
- security: [{ bearerAuth: ["member:update"] }],
228
- request: { params: userParams, body: jsonBody(overlayInputSchema, "Overlay") },
229
- responses: {
230
- 201: jsonContent(successSchema(z.object({ id: z.string() })), "Created"),
231
- ...errorResponses(400),
232
- ...errorResponses(401, 403),
233
- },
234
- }),
235
- async (c) => {
236
- const principal = currentPrincipal(c);
237
- const body = c.req.valid("json");
238
- const id = await addOverlay(principal.userId, orgOf(principal), c.req.valid("param").userId, {
239
- ...body,
240
- expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
241
- });
242
- return c.json(success({ id }), 201);
243
- },
244
- );
245
-
246
- router.openapi(
247
- createRoute({
248
- method: "delete",
249
- path: "/members/{userId}/overlays/{overlayId}",
250
- tags,
251
- summary: "Remove an overlay",
252
- middleware: [requirePermission("member:update")] as const,
253
- security: [{ bearerAuth: ["member:update"] }],
254
- request: { params: userOverlayParams },
255
- responses: { 204: { description: "Removed" }, ...errorResponses(404), ...errorResponses(401, 403) },
256
- }),
257
- async (c) => {
258
- const principal = currentPrincipal(c);
259
- const { userId, overlayId } = c.req.valid("param");
260
- await removeOverlay(principal.userId, orgOf(principal), userId, overlayId);
261
- return c.body(null, 204);
262
- },
263
- );
264
-
265
- router.openapi(
266
- createRoute({
267
- method: "get",
268
- path: "/audit",
269
- tags,
270
- summary: "Recent authorization changes in the org",
271
- middleware: [requirePermission("audit_log:read")] as const,
272
- security: [{ bearerAuth: ["audit_log:read"] }],
273
- responses: { 200: jsonContent(successSchema(z.array(auditEntrySchema)), "Audit log"), ...errorResponses(401, 403) },
274
- }),
275
- async (c) => {
276
- const rows = await listAudit(orgOf(currentPrincipal(c)));
277
- return c.json(success(rows.map((r) => ({ ...r, at: r.at.toISOString() }))), 200);
278
- },
279
- );
280
-
281
- export default defineRoutes({ app: "api", basePath: "/authorization", router });
@@ -5,5 +5,3 @@ export const SYSTEM_ROLES = [
5
5
  ] as const;
6
6
 
7
7
  export type SystemRoleKey = (typeof SYSTEM_ROLES)[number]["key"];
8
-
9
- export const SYSTEM_ROLE_KEYS: ReadonlySet<string> = new Set(SYSTEM_ROLES.map((r) => r.key));
@@ -1,6 +1,5 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
- import { allows } from "#core/authz/resolver";
3
- import type { EffectivePermissions } from "#core/authz/types";
2
+ import { allows, type EffectivePermissions } from "@tatara/authz";
4
3
  import { api } from "#tests/helpers/api";
5
4
  import { asUser, authenticate } from "#tests/helpers/auth";
6
5
  import { applyTestAccess } from "#tests/helpers/authz";
@@ -1,7 +1,5 @@
1
- import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "#core/authz/constants";
2
- import { store } from "#core/authz/db/store";
3
- import { addOverlay, assignRole } from "#core/authz/services/members";
4
- import { syncSystemRoles } from "#core/authz/services/sync";
1
+ import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "@tatara/authz";
2
+ import { authz } from "#core/authz/index";
5
3
  import type { SystemRoleKey } from "#core/authz/system-roles";
6
4
  import type { PermissionKey } from "#generated/permissions";
7
5
 
@@ -17,14 +15,14 @@ export async function applyTestAccess(userId: string, access: TestAccess): Promi
17
15
  const orgId = access.orgId ?? DEFAULT_ORG_ID;
18
16
  for (const key of access.permissions ?? []) {
19
17
  const [resource = "", action = ""] = key.split(":");
20
- await addOverlay(null, orgId, userId, { resource, action, effect: "grant" });
18
+ await authz.addOverlay(null, orgId, userId, { resource, action, effect: "grant" });
21
19
  }
22
20
  if (access.systemRoles?.length) {
23
- await syncSystemRoles();
21
+ await authz.syncSystemRoles();
24
22
  for (const roleKey of access.systemRoles) {
25
- const role = await store.findRoleByKey(SYSTEM_ORG_ID, roleKey);
23
+ const role = await authz.store.findRoleByKey(SYSTEM_ORG_ID, roleKey);
26
24
  if (!role) throw new Error(`Unknown built-in role ${roleKey}`);
27
- await assignRole(null, orgId, userId, role.id);
25
+ await authz.assignRole(null, orgId, userId, role.id);
28
26
  }
29
27
  }
30
28
  }
@@ -1,6 +1,6 @@
1
1
  import { and, count, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
2
- import type { OverlayEffect, Scope } from "#core/authz/constants";
3
- import { SYSTEM_ORG_ID } from "#core/authz/constants";
2
+ import type { OverlayEffect, Scope } from "@tatara/authz";
3
+ import { SYSTEM_ORG_ID } from "@tatara/authz";
4
4
  import {
5
5
  authzAuditLog,
6
6
  authzMemberRoles,
@@ -8,7 +8,7 @@ import {
8
8
  authzRolePermissions,
9
9
  authzRoles,
10
10
  } from "#core/authz/db/authz.schema";
11
- import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "#core/authz/types";
11
+ import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
12
12
  import { db } from "#core/db/client";
13
13
  import { newId } from "@tatara/db";
14
14
 
@@ -1,6 +1,6 @@
1
- import type { OverlayEffect, Scope } from "#core/authz/constants";
2
- import { SYSTEM_ORG_ID } from "#core/authz/constants";
3
- import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "#core/authz/types";
1
+ import type { OverlayEffect, Scope } from "@tatara/authz";
2
+ import { SYSTEM_ORG_ID } from "@tatara/authz";
3
+ import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
4
4
  import { db } from "#core/db/client";
5
5
  import { newId } from "@tatara/db";
6
6
 
@@ -1,36 +1,12 @@
1
+ import { createOutbox } from "@tatara/auth";
1
2
  import { smsSender } from "#core/auth/sms";
2
3
  import { env } from "#core/config/env";
3
4
  // tatara:slot imports
4
5
 
5
- export interface OutboxMessage {
6
- channel: "email" | "sms";
7
- to: string;
8
- subject?: string;
9
- text: string;
10
- /** one-time code, when the message carries one */
11
- code?: string;
12
- sentAt: string;
13
- }
14
-
15
- const outbox: OutboxMessage[] = [];
16
-
17
6
  /** Only with DEV_OUTBOX=true in development or test: the outbox exposes sign-in codes. */
18
- export function outboxEnabled(): boolean {
19
- return env.DEV_OUTBOX && (env.NODE_ENV === "development" || env.NODE_ENV === "test");
20
- }
21
-
22
- /** Messages are kept here so tests and local sign-in can read codes. */
23
- function record(message: Omit<OutboxMessage, "sentAt">) {
24
- if (!outboxEnabled()) return;
25
- outbox.push({ ...message, sentAt: new Date().toISOString() });
26
- if (outbox.length > 200) outbox.shift();
27
- }
28
-
29
- /** Newest first. Empty unless the outbox is enabled. */
30
- export function readOutbox(to?: string): OutboxMessage[] {
31
- if (!outboxEnabled()) return [];
32
- return outbox.filter((m) => !to || m.to === to).reverse();
33
- }
7
+ export const outbox = createOutbox({
8
+ enabled: env.DEV_OUTBOX && (env.NODE_ENV === "development" || env.NODE_ENV === "test"),
9
+ });
34
10
 
35
11
  export async function deliverEmail(message: {
36
12
  to: string;
@@ -38,11 +14,11 @@ export async function deliverEmail(message: {
38
14
  text: string;
39
15
  code?: string;
40
16
  }): Promise<void> {
41
- record({ channel: "email", ...message });
17
+ outbox.record({ channel: "email", ...message });
42
18
  // tatara:slot email-delivery
43
19
  }
44
20
 
45
21
  export async function deliverSms(message: { to: string; text: string; code?: string }): Promise<void> {
46
- record({ channel: "sms", ...message });
22
+ outbox.record({ channel: "sms", ...message });
47
23
  await smsSender.send(message.to, message.text);
48
24
  }
@@ -1,41 +1,10 @@
1
- import { createMiddleware } from "hono/factory";
1
+ import { principalMiddleware, sessionPrincipal } from "@tatara/auth";
2
2
  import { auth } from "#core/auth/auth";
3
3
  import type { AppEnv } from "#core/http/context";
4
+ // tatara:slot imports
4
5
 
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
-
22
- /** Resolves the session (cookie or bearer token) into `c.var.principal`. */
23
- export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
24
- const session = await auth.api.getSession({ headers: c.req.raw.headers });
25
- if (session) {
26
- const user = session.user as typeof session.user & { role?: string | null };
27
- const s = session.session as typeof session.session & { activeOrganizationId?: string | null };
28
- c.set("principal", {
29
- userId: user.id,
30
- orgId: await activeMembership(user.id, s.activeOrganizationId),
31
- roles: (user.role ?? "user")
32
- .split(",")
33
- .map((role) => role.trim())
34
- .filter(Boolean),
35
- kind: "user",
36
- claims: {},
37
- });
38
- }
39
- // tatara:slot no-session
40
- await next();
41
- });
6
+ /** Sets `c.var.principal` from the first resolver that recognises the request's credentials. */
7
+ export const attachPrincipal = principalMiddleware<AppEnv>([
8
+ (c) => sessionPrincipal(auth, c.req.raw.headers),
9
+ // tatara:slot resolvers
10
+ ]);