@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,99 +0,0 @@
|
|
|
1
|
-
import { z } from "@hono/zod-openapi";
|
|
2
|
-
import { OVERLAY_EFFECTS, SCOPES } from "#core/authz/constants";
|
|
3
|
-
|
|
4
|
-
export const grantSchema = z
|
|
5
|
-
.object({
|
|
6
|
-
resource: z.string().min(1),
|
|
7
|
-
action: z.string().min(1),
|
|
8
|
-
scope: z.enum(SCOPES).nullable().optional(),
|
|
9
|
-
})
|
|
10
|
-
.openapi("PermissionGrant");
|
|
11
|
-
|
|
12
|
-
export const roleSchema = z
|
|
13
|
-
.object({
|
|
14
|
-
id: z.string(),
|
|
15
|
-
key: z.string(),
|
|
16
|
-
name: z.string(),
|
|
17
|
-
description: z.string().nullable(),
|
|
18
|
-
isSystem: z.boolean(),
|
|
19
|
-
grants: z.array(grantSchema),
|
|
20
|
-
memberCount: z.number().int(),
|
|
21
|
-
})
|
|
22
|
-
.openapi("Role");
|
|
23
|
-
|
|
24
|
-
export const createRoleSchema = z
|
|
25
|
-
.object({
|
|
26
|
-
key: z
|
|
27
|
-
.string()
|
|
28
|
-
.regex(/^[a-z][a-z0-9_]{1,62}$/, "lowercase letters, digits and underscores"),
|
|
29
|
-
name: z.string().trim().min(1).max(120),
|
|
30
|
-
description: z.string().max(1000).nullable().optional(),
|
|
31
|
-
grants: z.array(grantSchema).default([]),
|
|
32
|
-
})
|
|
33
|
-
.openapi("CreateRole");
|
|
34
|
-
|
|
35
|
-
export const updateRoleSchema = createRoleSchema.omit({ key: true }).partial().openapi("UpdateRole");
|
|
36
|
-
|
|
37
|
-
export const overlayInputSchema = z
|
|
38
|
-
.object({
|
|
39
|
-
resource: z.string().min(1),
|
|
40
|
-
action: z.string().min(1),
|
|
41
|
-
scope: z.enum(SCOPES).nullable().optional(),
|
|
42
|
-
effect: z.enum(OVERLAY_EFFECTS),
|
|
43
|
-
reason: z.string().max(500).nullable().optional(),
|
|
44
|
-
expiresAt: z.iso.datetime().nullable().optional(),
|
|
45
|
-
})
|
|
46
|
-
.openapi("OverlayInput");
|
|
47
|
-
|
|
48
|
-
export const overlaySchema = z
|
|
49
|
-
.object({
|
|
50
|
-
id: z.string(),
|
|
51
|
-
resource: z.string(),
|
|
52
|
-
action: z.string(),
|
|
53
|
-
scope: z.enum(SCOPES).nullable(),
|
|
54
|
-
effect: z.enum(OVERLAY_EFFECTS),
|
|
55
|
-
reason: z.string().nullable(),
|
|
56
|
-
expiresAt: z.string().nullable(),
|
|
57
|
-
})
|
|
58
|
-
.openapi("Overlay");
|
|
59
|
-
|
|
60
|
-
export const memberSchema = z
|
|
61
|
-
.object({
|
|
62
|
-
userId: z.string(),
|
|
63
|
-
orgId: z.string(),
|
|
64
|
-
roles: z.array(roleSchema),
|
|
65
|
-
overlays: z.array(overlaySchema),
|
|
66
|
-
})
|
|
67
|
-
.openapi("Member");
|
|
68
|
-
|
|
69
|
-
export const myPermissionsSchema = z
|
|
70
|
-
.object({
|
|
71
|
-
orgId: z.string(),
|
|
72
|
-
roles: z.array(z.string()),
|
|
73
|
-
grants: z.array(grantSchema),
|
|
74
|
-
denies: z.array(z.string()),
|
|
75
|
-
})
|
|
76
|
-
.openapi("MyPermissions");
|
|
77
|
-
|
|
78
|
-
export const auditEntrySchema = z
|
|
79
|
-
.object({
|
|
80
|
-
id: z.string(),
|
|
81
|
-
actorId: z.string(),
|
|
82
|
-
targetUserId: z.string().nullable(),
|
|
83
|
-
changeType: z.string(),
|
|
84
|
-
before: z.unknown(),
|
|
85
|
-
after: z.unknown(),
|
|
86
|
-
at: z.string(),
|
|
87
|
-
})
|
|
88
|
-
.openapi("AuditEntry");
|
|
89
|
-
|
|
90
|
-
export const idParams = z.object({ id: z.string().openapi({ param: { name: "id", in: "path" } }) });
|
|
91
|
-
export const userParams = z.object({
|
|
92
|
-
userId: z.string().openapi({ param: { name: "userId", in: "path" } }),
|
|
93
|
-
});
|
|
94
|
-
export const userRoleParams = userParams.extend({
|
|
95
|
-
roleId: z.string().openapi({ param: { name: "roleId", in: "path" } }),
|
|
96
|
-
});
|
|
97
|
-
export const userOverlayParams = userParams.extend({
|
|
98
|
-
overlayId: z.string().openapi({ param: { name: "overlayId", in: "path" } }),
|
|
99
|
-
});
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import type { z } from "zod";
|
|
2
|
-
import type { Logger } from "#core/logging/logger";
|
|
3
|
-
|
|
4
|
-
export interface JobContext {
|
|
5
|
-
logger: Logger;
|
|
6
|
-
jobId: string;
|
|
7
|
-
attempt: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface ConsumerDefinition<S extends z.ZodType = z.ZodType> {
|
|
11
|
-
/** job name, e.g. "billing.invoice-paid" */
|
|
12
|
-
topic: string;
|
|
13
|
-
description: string;
|
|
14
|
-
schema: S;
|
|
15
|
-
/** retries before the job is marked failed (default 3) */
|
|
16
|
-
attempts?: number;
|
|
17
|
-
handle(data: z.infer<S>, ctx: JobContext): Promise<void>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Default export of every `consumers/*.ts`; run by the worker app. */
|
|
21
|
-
export function defineConsumer<S extends z.ZodType>(definition: ConsumerDefinition<S>) {
|
|
22
|
-
return definition;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface ScheduleDefinition {
|
|
26
|
-
name: string;
|
|
27
|
-
/** cron expression (seconds optional), evaluated in UTC */
|
|
28
|
-
cron: string;
|
|
29
|
-
/** how long the run holds its lock; one run per tick across all cron processes */
|
|
30
|
-
lockTtlMs?: number;
|
|
31
|
-
run(ctx: { logger: Logger }): Promise<void>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Default export of every `schedules/*.ts`; run by the cron app. */
|
|
35
|
-
export function defineSchedule(definition: ScheduleDefinition): ScheduleDefinition {
|
|
36
|
-
return definition;
|
|
37
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { redis } from "#core/jobs/redis";
|
|
3
|
-
|
|
4
|
-
const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
|
|
5
|
-
|
|
6
|
-
/** Runs `fn` only if this process wins the lock; returns false when another holder has it. */
|
|
7
|
-
export async function withLock(key: string, ttlMs: number, fn: () => Promise<void>): Promise<boolean> {
|
|
8
|
-
const token = randomUUID();
|
|
9
|
-
const acquired = await redis().set(`lock:${key}`, token, "PX", ttlMs, "NX");
|
|
10
|
-
if (acquired !== "OK") return false;
|
|
11
|
-
try {
|
|
12
|
-
await fn();
|
|
13
|
-
} finally {
|
|
14
|
-
await redis().eval(RELEASE, 1, `lock:${key}`, token);
|
|
15
|
-
}
|
|
16
|
-
return true;
|
|
17
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import type { ConsumerDefinition, JobContext } from "#core/jobs/define";
|
|
2
|
-
|
|
3
|
-
export class UnknownTopicError extends Error {}
|
|
4
|
-
|
|
5
|
-
/** Validates and runs one job; shared by the worker and by tests. */
|
|
6
|
-
export async function processJob(
|
|
7
|
-
consumers: ConsumerDefinition[],
|
|
8
|
-
topic: string,
|
|
9
|
-
data: unknown,
|
|
10
|
-
ctx: JobContext,
|
|
11
|
-
): Promise<void> {
|
|
12
|
-
const consumer = consumers.find((c) => c.topic === topic);
|
|
13
|
-
if (!consumer) throw new UnknownTopicError(`No consumer for topic "${topic}"`);
|
|
14
|
-
const parsed = consumer.schema.safeParse(data);
|
|
15
|
-
if (!parsed.success) {
|
|
16
|
-
throw new Error(`Invalid payload for ${topic}: ${parsed.error.message}`);
|
|
17
|
-
}
|
|
18
|
-
await consumer.handle(parsed.data, ctx);
|
|
19
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { type JobsOptions, Queue } from "bullmq";
|
|
2
|
-
import { env } from "#core/config/env";
|
|
3
|
-
import { redis } from "#core/jobs/redis";
|
|
4
|
-
import { onShutdown } from "@tatara/core";
|
|
5
|
-
|
|
6
|
-
export const QUEUE_NAME = "__APP_NAME__-jobs";
|
|
7
|
-
|
|
8
|
-
let queue: Queue | undefined;
|
|
9
|
-
|
|
10
|
-
function getQueue(): Queue {
|
|
11
|
-
if (!queue) {
|
|
12
|
-
const q = new Queue(QUEUE_NAME, { connection: redis(), prefix: env.JOBS_PREFIX });
|
|
13
|
-
queue = q;
|
|
14
|
-
onShutdown("queue", () => q.close());
|
|
15
|
-
}
|
|
16
|
-
return queue;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Enqueues a job for the consumer with this topic. The consumer validates the payload. */
|
|
20
|
-
export async function enqueue(topic: string, data: unknown, options: JobsOptions = {}): Promise<string> {
|
|
21
|
-
const job = await getQueue().add(topic, data, {
|
|
22
|
-
attempts: 3,
|
|
23
|
-
backoff: { type: "exponential", delay: 1000 },
|
|
24
|
-
removeOnComplete: 1000,
|
|
25
|
-
removeOnFail: 5000,
|
|
26
|
-
...options,
|
|
27
|
-
});
|
|
28
|
-
return job.id ?? "";
|
|
29
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { Redis } from "ioredis";
|
|
2
|
-
import { env } from "#core/config/env";
|
|
3
|
-
import { onShutdown } from "@tatara/core";
|
|
4
|
-
|
|
5
|
-
let client: Redis | undefined;
|
|
6
|
-
|
|
7
|
-
/** Shared connection (BullMQ requires maxRetriesPerRequest: null). */
|
|
8
|
-
export function redis(): Redis {
|
|
9
|
-
if (!client) {
|
|
10
|
-
const connection = new Redis(env.REDIS_URL, { maxRetriesPerRequest: null });
|
|
11
|
-
client = connection;
|
|
12
|
-
onShutdown("redis", async () => {
|
|
13
|
-
await connection.quit();
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
return client;
|
|
17
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname, resolve, sep } from "node:path";
|
|
3
|
-
import { env } from "#core/config/env";
|
|
4
|
-
import { assertKey, type Storage } from "#core/storage/storage";
|
|
5
|
-
|
|
6
|
-
/** Files on disk; for development and tests. */
|
|
7
|
-
export function localStorage(root = env.STORAGE_LOCAL_DIR): Storage {
|
|
8
|
-
const base = resolve(root);
|
|
9
|
-
const path = (key: string) => {
|
|
10
|
-
const full = resolve(base, assertKey(key));
|
|
11
|
-
if (!full.startsWith(base + sep)) throw new Error(`Invalid storage key "${key}"`);
|
|
12
|
-
return full;
|
|
13
|
-
};
|
|
14
|
-
return {
|
|
15
|
-
async put(key, body) {
|
|
16
|
-
await mkdir(dirname(path(key)), { recursive: true });
|
|
17
|
-
await writeFile(path(key), body);
|
|
18
|
-
},
|
|
19
|
-
async get(key) {
|
|
20
|
-
try {
|
|
21
|
-
return new Uint8Array(await readFile(path(key)));
|
|
22
|
-
} catch (error) {
|
|
23
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
24
|
-
throw error;
|
|
25
|
-
}
|
|
26
|
-
},
|
|
27
|
-
async delete(key) {
|
|
28
|
-
await rm(path(key), { force: true });
|
|
29
|
-
},
|
|
30
|
-
async url(key) {
|
|
31
|
-
return `file://${path(key)}`;
|
|
32
|
-
},
|
|
33
|
-
};
|
|
34
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CreateBucketCommand,
|
|
3
|
-
DeleteObjectCommand,
|
|
4
|
-
GetObjectCommand,
|
|
5
|
-
HeadBucketCommand,
|
|
6
|
-
NoSuchKey,
|
|
7
|
-
PutObjectCommand,
|
|
8
|
-
S3Client,
|
|
9
|
-
} from "@aws-sdk/client-s3";
|
|
10
|
-
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
11
|
-
import { env } from "#core/config/env";
|
|
12
|
-
import { assertKey, type Storage } from "#core/storage/storage";
|
|
13
|
-
|
|
14
|
-
export const s3 = new S3Client({
|
|
15
|
-
region: env.S3_REGION,
|
|
16
|
-
...(env.S3_ENDPOINT ? { endpoint: env.S3_ENDPOINT } : {}),
|
|
17
|
-
forcePathStyle: env.S3_FORCE_PATH_STYLE,
|
|
18
|
-
...(env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
|
|
19
|
-
? { credentials: { accessKeyId: env.S3_ACCESS_KEY_ID, secretAccessKey: env.S3_SECRET_ACCESS_KEY } }
|
|
20
|
-
: {}),
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
/** Any S3-compatible store (AWS S3, R2, RustFS locally). */
|
|
24
|
-
export function s3Storage(bucket = env.S3_BUCKET): Storage {
|
|
25
|
-
let bucketReady: Promise<boolean> | undefined;
|
|
26
|
-
return {
|
|
27
|
-
async put(key, body, contentType) {
|
|
28
|
-
// local development: create the bucket on first write
|
|
29
|
-
if (env.NODE_ENV === "development") {
|
|
30
|
-
bucketReady ??= ensureBucket(bucket);
|
|
31
|
-
await bucketReady;
|
|
32
|
-
}
|
|
33
|
-
await s3.send(
|
|
34
|
-
new PutObjectCommand({ Bucket: bucket, Key: assertKey(key), Body: body, ContentType: contentType }),
|
|
35
|
-
);
|
|
36
|
-
},
|
|
37
|
-
async get(key) {
|
|
38
|
-
try {
|
|
39
|
-
const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: assertKey(key) }));
|
|
40
|
-
return res.Body ? await res.Body.transformToByteArray() : null;
|
|
41
|
-
} catch (error) {
|
|
42
|
-
if (error instanceof NoSuchKey) return null;
|
|
43
|
-
throw error;
|
|
44
|
-
}
|
|
45
|
-
},
|
|
46
|
-
async delete(key) {
|
|
47
|
-
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: assertKey(key) }));
|
|
48
|
-
},
|
|
49
|
-
async url(key, expiresInSeconds = 900) {
|
|
50
|
-
return getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: assertKey(key) }), {
|
|
51
|
-
expiresIn: expiresInSeconds,
|
|
52
|
-
});
|
|
53
|
-
},
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Creates the bucket when it does not exist (local development). */
|
|
58
|
-
export async function ensureBucket(bucket = env.S3_BUCKET): Promise<boolean> {
|
|
59
|
-
try {
|
|
60
|
-
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
|
61
|
-
return false;
|
|
62
|
-
} catch {
|
|
63
|
-
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/** Object storage used by modules; pick the driver with STORAGE_DRIVER. */
|
|
2
|
-
export interface Storage {
|
|
3
|
-
put(key: string, body: Uint8Array | string, contentType?: string): Promise<void>;
|
|
4
|
-
get(key: string): Promise<Uint8Array | null>;
|
|
5
|
-
delete(key: string): Promise<void>;
|
|
6
|
-
/** a URL clients can fetch the object from (signed for S3) */
|
|
7
|
-
url(key: string, expiresInSeconds?: number): Promise<string>;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Keys are relative, forward-slash paths without "..", backslashes, NUL bytes or drive prefixes. */
|
|
11
|
-
export function assertKey(key: string): string {
|
|
12
|
-
const invalid =
|
|
13
|
-
!key ||
|
|
14
|
-
key.startsWith("/") ||
|
|
15
|
-
key.includes("\\") ||
|
|
16
|
-
key.includes("\0") ||
|
|
17
|
-
/^[A-Za-z]:/.test(key) ||
|
|
18
|
-
key.split("/").some((segment) => segment === ".." || segment === ".");
|
|
19
|
-
if (invalid) throw new Error(`Invalid storage key "${key}"`);
|
|
20
|
-
return key;
|
|
21
|
-
}
|