@tatara/cli 0.5.0 → 0.7.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.
- package/dist/bin.mjs +370 -30
- package/package.json +1 -1
- package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +2 -2
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +5 -5
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +2 -2
- package/templates/features/authz/files/src/core/authz/db/seed.ts +2 -2
- package/templates/features/authz/files/src/core/authz/index.ts +9 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +9 -280
- package/templates/features/authz/files/src/core/authz/system-roles.ts +0 -2
- package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +1 -2
- package/templates/features/authz/files/tests/helpers/authz.ts +6 -8
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +3 -3
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +3 -3
- package/templates/features/email/files/src/core/email/send.ts +8 -17
- package/templates/features/i18n/files/src/core/i18n/index.ts +5 -35
- package/templates/features/jobs/files/src/apps/cron/index.ts +4 -21
- package/templates/features/jobs/files/src/apps/worker/index.ts +11 -19
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
- package/templates/features/jobs/files/src/core/jobs/index.ts +10 -0
- package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +1 -1
- package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +1 -1
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +4 -4
- package/templates/features/storage/files/src/core/storage/commands/check.ts +3 -3
- package/templates/features/storage/files/src/core/storage/index.ts +19 -5
- package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +1 -1
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +1 -1
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +1 -1
- package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +1 -1
- package/templates/versions.json +6 -1
- package/templates/features/authz/files/src/core/authz/cache.ts +0 -31
- package/templates/features/authz/files/src/core/authz/catalog.ts +0 -85
- package/templates/features/authz/files/src/core/authz/constants.ts +0 -32
- package/templates/features/authz/files/src/core/authz/middleware.ts +0 -21
- package/templates/features/authz/files/src/core/authz/permissions.ts +0 -31
- package/templates/features/authz/files/src/core/authz/resolver.ts +0 -98
- package/templates/features/authz/files/src/core/authz/services/audit.ts +0 -24
- package/templates/features/authz/files/src/core/authz/services/grants.ts +0 -29
- package/templates/features/authz/files/src/core/authz/services/members.ts +0 -84
- package/templates/features/authz/files/src/core/authz/services/roles.ts +0 -73
- package/templates/features/authz/files/src/core/authz/services/sync.ts +0 -34
- package/templates/features/authz/files/src/core/authz/types.ts +0 -87
- package/templates/features/authz/files/src/core/authz/validators.ts +0 -99
- package/templates/features/jobs/files/src/core/jobs/define.ts +0 -37
- package/templates/features/jobs/files/src/core/jobs/lock.ts +0 -17
- package/templates/features/jobs/files/src/core/jobs/process.ts +0 -19
- package/templates/features/jobs/files/src/core/jobs/queue.ts +0 -29
- package/templates/features/jobs/files/src/core/jobs/redis.ts +0 -17
- package/templates/features/storage/files/src/core/storage/local.ts +0 -34
- package/templates/features/storage/files/src/core/storage/s3.ts +0 -66
- package/templates/features/storage/files/src/core/storage/storage.ts +0 -21
|
@@ -1,281 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 });
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { allows } from "
|
|
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 "
|
|
2
|
-
import {
|
|
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 "
|
|
3
|
-
import { SYSTEM_ORG_ID } from "
|
|
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 "
|
|
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 "
|
|
2
|
-
import { SYSTEM_ORG_ID } from "
|
|
3
|
-
import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "
|
|
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,22 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createMailer } from "@tatara/email";
|
|
2
2
|
import { env } from "#core/config/env";
|
|
3
3
|
import { logger } from "#core/logging/logger";
|
|
4
4
|
|
|
5
|
-
export
|
|
6
|
-
to: string;
|
|
7
|
-
subject: string;
|
|
8
|
-
text: string;
|
|
9
|
-
html?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
let transport: Transporter | undefined;
|
|
5
|
+
export type { EmailMessage } from "@tatara/email";
|
|
13
6
|
|
|
14
7
|
/** Sends through SMTP (Mailpit locally) or logs it when EMAIL_DRIVER=console. */
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
await transport.sendMail({ from: env.EMAIL_FROM, ...message });
|
|
22
|
-
}
|
|
8
|
+
export const { send: sendEmail } = createMailer({
|
|
9
|
+
driver: env.EMAIL_DRIVER,
|
|
10
|
+
smtpUrl: env.SMTP_URL,
|
|
11
|
+
from: env.EMAIL_FROM,
|
|
12
|
+
logger,
|
|
13
|
+
});
|
|
@@ -1,41 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createI18n } from "@tatara/i18n";
|
|
2
2
|
import { env } from "#core/config/env";
|
|
3
|
-
import type { AppEnv } from "#core/http/context";
|
|
4
3
|
import ar from "#locales/ar" with { type: "json" };
|
|
5
4
|
import en from "#locales/en" with { type: "json" };
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
const i18n = createI18n({ dictionaries: { en, ar }, defaultLanguage: env.DEFAULT_LANGUAGE });
|
|
8
7
|
|
|
9
|
-
const
|
|
8
|
+
export const SUPPORTED_LANGUAGES = i18n.languages;
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function lookup(dict: Dictionary | undefined, key: string): string | undefined {
|
|
14
|
-
let node: string | Dictionary | undefined = dict;
|
|
15
|
-
for (const part of key.split(".")) {
|
|
16
|
-
if (!node || typeof node === "string") return undefined;
|
|
17
|
-
node = node[part];
|
|
18
|
-
}
|
|
19
|
-
return typeof node === "string" ? node : undefined;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Translates `key` ("errors.NOT_FOUND"), falling back to the default language, then `fallback`. */
|
|
23
|
-
export function t(language: string, key: string, fallback = key, vars: Record<string, string> = {}): string {
|
|
24
|
-
const text = lookup(dictionaries[language], key) ?? lookup(dictionaries[env.DEFAULT_LANGUAGE], key) ?? fallback;
|
|
25
|
-
return text.replace(/\{(\w+)\}/g, (_, name: string) => vars[name] ?? `{${name}}`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** ?lang, then the `lang` cookie, then Accept-Language; sets `c.var.language`. */
|
|
29
|
-
export const detectLanguage = createMiddleware<AppEnv>(async (c, next) => {
|
|
30
|
-
const candidates = [
|
|
31
|
-
c.req.query("lang"),
|
|
32
|
-
/(?:^|;\s*)lang=([^;]+)/.exec(c.req.header("cookie") ?? "")?.[1],
|
|
33
|
-
...(c.req.header("accept-language") ?? "")
|
|
34
|
-
.split(",")
|
|
35
|
-
.map((part) => part.split(";")[0]?.trim().toLowerCase().split("-")[0]),
|
|
36
|
-
];
|
|
37
|
-
const language = candidates.find((l) => l && SUPPORTED_LANGUAGES.includes(l)) ?? env.DEFAULT_LANGUAGE;
|
|
38
|
-
c.set("language", language);
|
|
39
|
-
c.header("content-language", language);
|
|
40
|
-
await next();
|
|
41
|
-
});
|
|
10
|
+
/** `t(language, "errors.NOT_FOUND")`, and the middleware that sets `c.var.language`. */
|
|
11
|
+
export const { t, detectLanguage } = i18n;
|
|
@@ -1,25 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { handleSignals } from "@tatara/core";
|
|
2
|
+
import { startCron } from "@tatara/jobs";
|
|
3
|
+
import { redis } from "#core/jobs/index";
|
|
3
4
|
import { logger } from "#core/logging/logger";
|
|
4
|
-
import { handleSignals, onShutdown } from "@tatara/core";
|
|
5
5
|
import { schedules } from "#generated/schedules";
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
const log = logger.child({ schedule: schedule.name });
|
|
9
|
-
return new Cron(schedule.cron, { timezone: "UTC", protect: true, name: schedule.name }, async (self) => {
|
|
10
|
-
const tick = self.currentRun()?.toISOString() ?? new Date().toISOString();
|
|
11
|
-
const ran = await withLock(`schedule:${schedule.name}:${tick}`, schedule.lockTtlMs ?? 60_000, () =>
|
|
12
|
-
schedule.run({ logger: log }),
|
|
13
|
-
).catch((error: unknown) => {
|
|
14
|
-
log.error({ err: error }, "schedule failed");
|
|
15
|
-
return true;
|
|
16
|
-
});
|
|
17
|
-
if (!ran) log.debug("skipped: another process holds this tick");
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
onShutdown("cron", () => {
|
|
22
|
-
for (const job of jobs) job.stop();
|
|
23
|
-
});
|
|
7
|
+
startCron({ schedules, redis, logger });
|
|
24
8
|
handleSignals(logger);
|
|
25
|
-
logger.info({ schedules: schedules.map((s) => `${s.name} (${s.cron})`) }, "cron started");
|
|
@@ -1,24 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { handleSignals } from "@tatara/core";
|
|
2
|
+
import { startWorker } from "@tatara/jobs";
|
|
2
3
|
import { env } from "#core/config/env";
|
|
3
|
-
import {
|
|
4
|
-
import { QUEUE_NAME } from "#core/jobs/queue";
|
|
5
|
-
import { redis } from "#core/jobs/redis";
|
|
4
|
+
import { QUEUE_NAME, redis } from "#core/jobs/index";
|
|
6
5
|
import { logger } from "#core/logging/logger";
|
|
7
|
-
import { handleSignals, onShutdown } from "@tatara/core";
|
|
8
6
|
import { consumers } from "#generated/consumers";
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
QUEUE_NAME,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
{ connection: redis(), prefix: env.JOBS_PREFIX, concurrency: env.WORKER_CONCURRENCY },
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
worker.on("failed", (job, err) => logger.error({ err, jobId: job?.id, topic: job?.name }, "job failed"));
|
|
22
|
-
onShutdown("worker", () => worker.close());
|
|
8
|
+
startWorker({
|
|
9
|
+
queue: QUEUE_NAME,
|
|
10
|
+
prefix: env.JOBS_PREFIX,
|
|
11
|
+
redis: redis(),
|
|
12
|
+
concurrency: env.WORKER_CONCURRENCY,
|
|
13
|
+
consumers,
|
|
14
|
+
logger,
|
|
15
|
+
});
|
|
23
16
|
handleSignals(logger);
|
|
24
|
-
logger.info({ topics: consumers.map((c) => c.topic) }, "worker started");
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createQueue, lazyRedis } from "@tatara/jobs";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
|
|
4
|
+
export const QUEUE_NAME = "__APP_NAME__-jobs";
|
|
5
|
+
|
|
6
|
+
/** The shared Redis connection, opened on first use. */
|
|
7
|
+
export const redis = lazyRedis(env.REDIS_URL);
|
|
8
|
+
|
|
9
|
+
/** `enqueue(topic, data)`: the consumer with that topic validates the payload. */
|
|
10
|
+
export const { enqueue } = createQueue({ name: QUEUE_NAME, prefix: env.JOBS_PREFIX, redis });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { withLock } from "
|
|
3
|
-
import {
|
|
2
|
+
import { processJob, withLock } from "@tatara/jobs";
|
|
3
|
+
import { redis } from "#core/jobs/index";
|
|
4
4
|
import { logger } from "#core/logging/logger";
|
|
5
5
|
import { consumers } from "#generated/consumers";
|
|
6
6
|
|
|
@@ -20,8 +20,8 @@ describe("jobs", () => {
|
|
|
20
20
|
it("lets only one holder run a locked section", async () => {
|
|
21
21
|
const key = `test-${Date.now()}-${Math.random()}`;
|
|
22
22
|
let inner: boolean | undefined;
|
|
23
|
-
const outer = await withLock(key, 5000, async () => {
|
|
24
|
-
inner = await withLock(key, 5000, async () => {});
|
|
23
|
+
const outer = await withLock(redis(), key, 5000, async () => {
|
|
24
|
+
inner = await withLock(redis(), key, 5000, async () => {});
|
|
25
25
|
});
|
|
26
26
|
expect(outer).toBe(true);
|
|
27
27
|
expect(inner).toBe(false);
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { defineCommand } from "@tatara/core";
|
|
2
2
|
import { env } from "#core/config/env";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { ensureBucket } from "@tatara/storage/s3";
|
|
4
|
+
import { s3, storage } from "#core/storage/index";
|
|
5
5
|
|
|
6
6
|
export default defineCommand({
|
|
7
7
|
name: "storage:check",
|
|
8
8
|
description: "Create the bucket if needed, then write, read and delete a probe object",
|
|
9
9
|
async run(_args, { logger }) {
|
|
10
|
-
if (env.STORAGE_DRIVER === "s3" && (await ensureBucket())) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
|
|
10
|
+
if (env.STORAGE_DRIVER === "s3" && (await ensureBucket(s3, env.S3_BUCKET))) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
|
|
11
11
|
const key = `tatara-check/${Date.now()}.txt`;
|
|
12
12
|
await storage.put(key, "ok", "text/plain");
|
|
13
13
|
const body = await storage.get(key);
|
|
@@ -1,8 +1,22 @@
|
|
|
1
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import { localStorage, type Storage } from "@tatara/storage";
|
|
3
|
+
import { s3Storage } from "@tatara/storage/s3";
|
|
1
4
|
import { env } from "#core/config/env";
|
|
2
|
-
import { localStorage } from "#core/storage/local";
|
|
3
|
-
import { s3Storage } from "#core/storage/s3";
|
|
4
|
-
import type { Storage } from "#core/storage/storage";
|
|
5
5
|
|
|
6
|
-
export type { Storage } from "
|
|
6
|
+
export type { Storage } from "@tatara/storage";
|
|
7
7
|
|
|
8
|
-
export const
|
|
8
|
+
export const s3 = new S3Client({
|
|
9
|
+
region: env.S3_REGION,
|
|
10
|
+
...(env.S3_ENDPOINT ? { endpoint: env.S3_ENDPOINT } : {}),
|
|
11
|
+
forcePathStyle: env.S3_FORCE_PATH_STYLE,
|
|
12
|
+
...(env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
|
|
13
|
+
? { credentials: { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY } }
|
|
14
|
+
: {}),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Object storage for modules; pick the driver with STORAGE_DRIVER. */
|
|
18
|
+
export const storage: Storage =
|
|
19
|
+
env.STORAGE_DRIVER === "s3"
|
|
20
|
+
? // local development: the bucket is created on first write
|
|
21
|
+
s3Storage({ client: s3, bucket: env.S3_BUCKET, createBucket: env.NODE_ENV === "development" })
|
|
22
|
+
: localStorage(env.STORAGE_LOCAL_DIR);
|
|
@@ -2,7 +2,7 @@ import { mkdtemp } from "node:fs/promises";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { localStorage } from "
|
|
5
|
+
import { localStorage } from "@tatara/storage";
|
|
6
6
|
|
|
7
7
|
describe("local storage", () => {
|
|
8
8
|
it("round-trips an object", async () => {
|