@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.
- package/LICENSE +21 -0
- package/dist/bin.mjs +2910 -0
- package/package.json +32 -3
- package/templates/features/admin/files/src/apps/admin/app.ts +4 -0
- package/templates/features/admin/files/src/apps/admin/server.ts +5 -0
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +24 -0
- package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +20 -0
- package/templates/features/auth/files/src/core/auth/middleware.ts +29 -0
- package/templates/features/auth/files/src/core/auth/routes/me.routes.ts +36 -0
- package/templates/features/auth/files/src/core/auth/tests/me.test.ts +25 -0
- package/templates/features/auth-orgs/files+drizzle+mysql/src/core/auth/db/organization.schema.ts +38 -0
- package/templates/features/auth-orgs/files+drizzle+postgres/src/core/auth/db/organization.schema.ts +38 -0
- package/templates/features/auth-orgs/files+prisma/prisma/models/organization.prisma +41 -0
- package/templates/features/authz/files/src/core/authz/cache.ts +31 -0
- package/templates/features/authz/files/src/core/authz/catalog.ts +85 -0
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +21 -0
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +11 -0
- package/templates/features/authz/files/src/core/authz/constants.ts +28 -0
- package/templates/features/authz/files/src/core/authz/db/seed.ts +10 -0
- package/templates/features/authz/files/src/core/authz/middleware.ts +21 -0
- package/templates/features/authz/files/src/core/authz/permissions.ts +31 -0
- package/templates/features/authz/files/src/core/authz/resolver.ts +98 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +281 -0
- package/templates/features/authz/files/src/core/authz/services/audit.ts +24 -0
- package/templates/features/authz/files/src/core/authz/services/grants.ts +29 -0
- package/templates/features/authz/files/src/core/authz/services/members.ts +68 -0
- package/templates/features/authz/files/src/core/authz/services/roles.ts +73 -0
- package/templates/features/authz/files/src/core/authz/services/sync.ts +34 -0
- package/templates/features/authz/files/src/core/authz/system-roles.ts +9 -0
- package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +146 -0
- package/templates/features/authz/files/src/core/authz/types.ts +85 -0
- package/templates/features/authz/files/src/core/authz/validators.ts +99 -0
- package/templates/features/authz/files/tests/helpers/authz.ts +30 -0
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +262 -0
- package/templates/features/authz/files+drizzle+mysql/src/core/authz/db/authz.schema.ts +90 -0
- package/templates/features/authz/files+drizzle+postgres/src/core/authz/db/authz.schema.ts +88 -0
- package/templates/features/authz/files+prisma/prisma/models/authz.prisma +75 -0
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +229 -0
- package/templates/features/base/files/AGENTS.md +69 -0
- package/templates/features/base/files/CLAUDE.md +1 -0
- package/templates/features/base/files/Dockerfile +15 -0
- package/templates/features/base/files/README.md +31 -0
- package/templates/features/base/files/_github/workflows/ci.yml +16 -0
- package/templates/features/base/files/biome.template.json +20 -0
- package/templates/features/base/files/dockerignore +8 -0
- package/templates/features/base/files/gitignore +9 -0
- package/templates/features/base/files/src/apps/api/app.ts +3 -0
- package/templates/features/base/files/src/apps/api/server.ts +5 -0
- package/templates/features/base/files/src/apps/cli/index.ts +6 -0
- package/templates/features/base/files/src/core/commands/define.ts +25 -0
- package/templates/features/base/files/src/core/commands/runner.ts +54 -0
- package/templates/features/base/files/src/core/http/app.ts +52 -0
- package/templates/features/base/files/src/core/http/context.ts +18 -0
- package/templates/features/base/files/src/core/http/errors.ts +51 -0
- package/templates/features/base/files/src/core/http/health.ts +25 -0
- package/templates/features/base/files/src/core/http/middleware/error-handler.ts +36 -0
- package/templates/features/base/files/src/core/http/middleware/request-context.ts +24 -0
- package/templates/features/base/files/src/core/http/middleware/validation.ts +17 -0
- package/templates/features/base/files/src/core/http/response.ts +67 -0
- package/templates/features/base/files/src/core/http/routes.ts +28 -0
- package/templates/features/base/files/src/core/http/server.ts +15 -0
- package/templates/features/base/files/src/core/logging/logger.ts +17 -0
- package/templates/features/base/files/src/core/process/lifecycle.ts +34 -0
- package/templates/features/base/files/src/modules/example/routes/greeting.routes.ts +27 -0
- package/templates/features/base/files/src/modules/example/services/greeting.service.ts +3 -0
- package/templates/features/base/files/src/modules/example/tests/greeting.test.ts +21 -0
- package/templates/features/base/files/src/modules/example/validators/greeting.validators.ts +9 -0
- package/templates/features/base/files/tests/helpers/api.ts +54 -0
- package/templates/features/base/files/tests/setup.ts +2 -0
- package/templates/features/base/files/tsconfig.json +20 -0
- package/templates/features/base/files/tsdown.config.ts +15 -0
- package/templates/features/base/files/vitest.config.ts +16 -0
- package/templates/features/better-auth/files/src/core/auth/auth.ts +26 -0
- package/templates/features/better-auth/files/src/core/auth/db/seed.ts +24 -0
- package/templates/features/better-auth/files/src/core/auth/delivery.ts +48 -0
- package/templates/features/better-auth/files/src/core/auth/principal.ts +23 -0
- package/templates/features/better-auth/files/src/core/auth/sms.ts +14 -0
- package/templates/features/better-auth/files/tests/helpers/auth.ts +40 -0
- package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/adapter.ts +15 -0
- package/templates/features/better-auth/files+drizzle+mysql/src/core/auth/db/auth.schema.ts +57 -0
- package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/adapter.ts +15 -0
- package/templates/features/better-auth/files+drizzle+postgres/src/core/auth/db/auth.schema.ts +56 -0
- package/templates/features/better-auth/files+prisma/prisma/models/auth.prisma +62 -0
- package/templates/features/better-auth/files+prisma+mysql/src/core/auth/adapter.ts +4 -0
- package/templates/features/better-auth/files+prisma+postgres/src/core/auth/adapter.ts +4 -0
- package/templates/features/db/files/src/core/db/commands/migrate.ts +12 -0
- package/templates/features/db/files/src/core/db/commands/reset.ts +19 -0
- package/templates/features/db/files/src/core/db/commands/seed.ts +13 -0
- package/templates/features/db/files/src/core/db/id.ts +13 -0
- package/templates/features/db/files/src/core/db/seed.ts +14 -0
- package/templates/features/db/files/tests/global-setup.ts +27 -0
- package/templates/features/db/files/tests/helpers/database-url.ts +9 -0
- package/templates/features/db/files/tests/helpers/db.ts +7 -0
- package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +53 -0
- package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +78 -0
- package/templates/features/db/files+postgres/src/core/db/admin.ts +50 -0
- package/templates/features/drizzle/files+mysql/drizzle.config.ts +12 -0
- package/templates/features/drizzle/files+mysql/src/core/db/client.ts +20 -0
- package/templates/features/drizzle/files+mysql/src/core/db/migrate.ts +13 -0
- package/templates/features/drizzle/files+postgres/drizzle.config.ts +12 -0
- package/templates/features/drizzle/files+postgres/src/core/db/client.ts +20 -0
- package/templates/features/drizzle/files+postgres/src/core/db/migrate.ts +13 -0
- package/templates/features/email/files/src/core/email/commands/send-test.ts +13 -0
- package/templates/features/email/files/src/core/email/send.ts +22 -0
- package/templates/features/email/files/src/core/email/tests/email.test.ts +8 -0
- package/templates/features/i18n/files/src/core/i18n/index.ts +41 -0
- package/templates/features/i18n/files/src/core/i18n/tests/i18n.test.ts +21 -0
- package/templates/features/i18n/files/src/locales/ar.json +10 -0
- package/templates/features/i18n/files/src/locales/en.json +10 -0
- package/templates/features/jobs/files/src/apps/cron/index.ts +25 -0
- package/templates/features/jobs/files/src/apps/worker/index.ts +24 -0
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +13 -0
- package/templates/features/jobs/files/src/core/jobs/define.ts +37 -0
- package/templates/features/jobs/files/src/core/jobs/lock.ts +17 -0
- package/templates/features/jobs/files/src/core/jobs/process.ts +19 -0
- package/templates/features/jobs/files/src/core/jobs/queue.ts +29 -0
- package/templates/features/jobs/files/src/core/jobs/redis.ts +17 -0
- package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +11 -0
- package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +9 -0
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +29 -0
- package/templates/features/keycloak/files/keycloak/realm.json +83 -0
- package/templates/features/keycloak/files/src/core/auth/commands/keycloak-token.ts +32 -0
- package/templates/features/keycloak/files/src/core/auth/keycloak.ts +58 -0
- package/templates/features/keycloak/files/src/core/auth/principal.ts +27 -0
- package/templates/features/keycloak/files/tests/helpers/auth.ts +52 -0
- package/templates/features/keycloak/files+drizzle/src/core/auth/users.ts +18 -0
- package/templates/features/keycloak/files+drizzle+mysql/src/core/auth/db/user.schema.ts +10 -0
- package/templates/features/keycloak/files+drizzle+postgres/src/core/auth/db/user.schema.ts +10 -0
- package/templates/features/keycloak/files+prisma/prisma/models/user.prisma +10 -0
- package/templates/features/keycloak/files+prisma/src/core/auth/users.ts +13 -0
- package/templates/features/prisma/files/prisma/models/.gitkeep +0 -0
- package/templates/features/prisma/files/prisma.config.ts +10 -0
- package/templates/features/prisma/files/src/core/db/migrate.ts +13 -0
- package/templates/features/prisma/files+mysql/prisma/schema.prisma +11 -0
- package/templates/features/prisma/files+mysql/src/core/db/client.ts +23 -0
- package/templates/features/prisma/files+postgres/prisma/schema.prisma +11 -0
- package/templates/features/prisma/files+postgres/src/core/db/client.ts +19 -0
- package/templates/features/storage/files/src/core/storage/commands/check.ts +18 -0
- package/templates/features/storage/files/src/core/storage/index.ts +8 -0
- package/templates/features/storage/files/src/core/storage/local.ts +34 -0
- package/templates/features/storage/files/src/core/storage/s3.ts +66 -0
- package/templates/features/storage/files/src/core/storage/storage.ts +21 -0
- package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +22 -0
- package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts +11 -0
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +14 -0
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +12 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/routes/__moduleKebab__.routes.ts +20 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/services/__moduleKebab__.service.ts +4 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts +10 -0
- package/templates/generators/module/files/src/modules/__moduleKebab__/validators/__moduleKebab__.validators.ts +5 -0
- package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +18 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +126 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/tests/__nameKebab__.routes.test.ts +59 -0
- package/templates/generators/resource/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +30 -0
- package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +21 -0
- package/templates/generators/resource/files+drizzle/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +78 -0
- package/templates/generators/resource/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/resource/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/resource/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
- package/templates/generators/resource/files+prisma/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +73 -0
- package/templates/generators/route/files/src/modules/__moduleKebab__/routes/__nameKebab__.routes.ts +29 -0
- package/templates/generators/route/files/src/modules/__moduleKebab__/validators/__nameKebab__.validators.ts +9 -0
- package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +13 -0
- package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +11 -0
- package/templates/generators/schema/files+drizzle+mysql/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/schema/files+drizzle+postgres/src/modules/__moduleKebab__/db/__nameKebab__.schema.ts +9 -0
- package/templates/generators/schema/files+prisma/prisma/models/__nameKebab__.prisma +9 -0
- package/templates/generators/service/files/src/modules/__moduleKebab__/services/__nameKebab__.service.ts +4 -0
- package/templates/generators/service/files/src/modules/__moduleKebab__/tests/__nameKebab__.service.test.ts +8 -0
- package/templates/versions.json +34 -0
- package/README.md +0 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { KeycloakClaims } from "#core/auth/keycloak";
|
|
2
|
+
import { users } from "#core/auth/db/user.schema";
|
|
3
|
+
import { db } from "#core/db/client";
|
|
4
|
+
|
|
5
|
+
const known = new Set<string>();
|
|
6
|
+
|
|
7
|
+
/** Creates the local user row for a token subject the first time it is seen. */
|
|
8
|
+
export async function ensureLocalUser(claims: KeycloakClaims): Promise<void> {
|
|
9
|
+
if (known.has(claims.sub)) return;
|
|
10
|
+
const values = {
|
|
11
|
+
id: claims.sub,
|
|
12
|
+
email: claims.email ?? null,
|
|
13
|
+
name: claims.name ?? claims.preferred_username ?? null,
|
|
14
|
+
};
|
|
15
|
+
// tatara:slot upsert
|
|
16
|
+
if (known.size > 10_000) known.clear();
|
|
17
|
+
known.add(claims.sub);
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { mysqlTable, timestamp, varchar } from "drizzle-orm/mysql-core";
|
|
2
|
+
|
|
3
|
+
/** Local copy of Keycloak users (id = token subject), created on first request. */
|
|
4
|
+
export const users = mysqlTable("users", {
|
|
5
|
+
id: varchar({ length: 36 }).primaryKey(),
|
|
6
|
+
email: varchar({ length: 255 }),
|
|
7
|
+
name: varchar({ length: 255 }),
|
|
8
|
+
createdAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
|
|
9
|
+
updatedAt: timestamp({ fsp: 3, mode: "date" }).notNull().defaultNow(),
|
|
10
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
/** Local copy of Keycloak users (id = token subject), created on first request. */
|
|
4
|
+
export const users = pgTable("users", {
|
|
5
|
+
id: text().primaryKey(),
|
|
6
|
+
email: text(),
|
|
7
|
+
name: text(),
|
|
8
|
+
createdAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
|
|
9
|
+
updatedAt: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
|
|
10
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/// Local copy of Keycloak users (id = token subject), created on first request.
|
|
2
|
+
model User {
|
|
3
|
+
id String @id @db.VarChar(36)
|
|
4
|
+
email String? @db.VarChar(255)
|
|
5
|
+
name String? @db.VarChar(255)
|
|
6
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
7
|
+
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
|
8
|
+
|
|
9
|
+
@@map("users")
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { KeycloakClaims } from "#core/auth/keycloak";
|
|
2
|
+
import { db } from "#core/db/client";
|
|
3
|
+
|
|
4
|
+
const known = new Set<string>();
|
|
5
|
+
|
|
6
|
+
/** Creates the local user row for a token subject the first time it is seen. */
|
|
7
|
+
export async function ensureLocalUser(claims: KeycloakClaims): Promise<void> {
|
|
8
|
+
if (known.has(claims.sub)) return;
|
|
9
|
+
const data = { email: claims.email ?? null, name: claims.name ?? claims.preferred_username ?? null };
|
|
10
|
+
await db.user.upsert({ where: { id: claims.sub }, create: { id: claims.sub, ...data }, update: data });
|
|
11
|
+
if (known.size > 10_000) known.clear();
|
|
12
|
+
known.add(claims.sub);
|
|
13
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { defineConfig } from "prisma/config";
|
|
3
|
+
|
|
4
|
+
if (existsSync(".env")) process.loadEnvFile(".env");
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
schema: "prisma",
|
|
8
|
+
migrations: { path: "prisma/migrations" },
|
|
9
|
+
datasource: { url: process.env.DATABASE_URL ?? "" },
|
|
10
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
const run = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
/** Applies prisma/migrations to the database at `url` (`prisma migrate deploy`). */
|
|
8
|
+
export async function migrateDatabase(url: string): Promise<void> {
|
|
9
|
+
await run(resolve("node_modules/.bin/prisma"), ["migrate", "deploy"], {
|
|
10
|
+
// tatara-check-ignore env-access: the Prisma CLI needs the full environment
|
|
11
|
+
env: { ...process.env, DATABASE_URL: url },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { connectionConfig } from "#core/db/admin";
|
|
4
|
+
import { registerReadinessCheck } from "#core/http/health";
|
|
5
|
+
import { onShutdown } from "#core/process/lifecycle";
|
|
6
|
+
import { type Prisma, PrismaClient } from "#generated/prisma/client";
|
|
7
|
+
|
|
8
|
+
export const db = new PrismaClient({
|
|
9
|
+
adapter: new PrismaMariaDb({
|
|
10
|
+
...connectionConfig(env.DATABASE_URL),
|
|
11
|
+
connectionLimit: env.DATABASE_POOL_MAX,
|
|
12
|
+
}),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export type Database = typeof db;
|
|
16
|
+
export type Transaction = Prisma.TransactionClient;
|
|
17
|
+
/** `db` or an open transaction; services take an Executor so callers can compose them. */
|
|
18
|
+
export type Executor = Database | Transaction;
|
|
19
|
+
|
|
20
|
+
registerReadinessCheck("database", async () => {
|
|
21
|
+
await db.$queryRaw`SELECT 1`;
|
|
22
|
+
});
|
|
23
|
+
onShutdown("database", () => db.$disconnect());
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { registerReadinessCheck } from "#core/http/health";
|
|
4
|
+
import { onShutdown } from "#core/process/lifecycle";
|
|
5
|
+
import { type Prisma, PrismaClient } from "#generated/prisma/client";
|
|
6
|
+
|
|
7
|
+
export const db = new PrismaClient({
|
|
8
|
+
adapter: new PrismaPg({ connectionString: env.DATABASE_URL, max: env.DATABASE_POOL_MAX }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type Database = typeof db;
|
|
12
|
+
export type Transaction = Prisma.TransactionClient;
|
|
13
|
+
/** `db` or an open transaction; services take an Executor so callers can compose them. */
|
|
14
|
+
export type Executor = Database | Transaction;
|
|
15
|
+
|
|
16
|
+
registerReadinessCheck("database", async () => {
|
|
17
|
+
await db.$queryRaw`SELECT 1`;
|
|
18
|
+
});
|
|
19
|
+
onShutdown("database", () => db.$disconnect());
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineCommand } from "#core/commands/define";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { storage } from "#core/storage/index";
|
|
4
|
+
import { ensureBucket } from "#core/storage/s3";
|
|
5
|
+
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
name: "storage:check",
|
|
8
|
+
description: "Create the bucket if needed, then write, read and delete a probe object",
|
|
9
|
+
async run(_args, { logger }) {
|
|
10
|
+
if (env.STORAGE_DRIVER === "s3" && (await ensureBucket())) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
|
|
11
|
+
const key = `tatara-check/${Date.now()}.txt`;
|
|
12
|
+
await storage.put(key, "ok", "text/plain");
|
|
13
|
+
const body = await storage.get(key);
|
|
14
|
+
await storage.delete(key);
|
|
15
|
+
if (new TextDecoder().decode(body ?? new Uint8Array()) !== "ok") throw new Error("storage round-trip failed");
|
|
16
|
+
logger.info({ driver: env.STORAGE_DRIVER }, "storage OK");
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
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
|
+
|
|
6
|
+
export type { Storage } from "#core/storage/storage";
|
|
7
|
+
|
|
8
|
+
export const storage: Storage = env.STORAGE_DRIVER === "s3" ? s3Storage() : localStorage();
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdtemp } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { localStorage } from "#core/storage/local";
|
|
6
|
+
|
|
7
|
+
describe("local storage", () => {
|
|
8
|
+
it("round-trips an object", async () => {
|
|
9
|
+
const store = localStorage(await mkdtemp(join(tmpdir(), "storage-")));
|
|
10
|
+
await store.put("a/b.txt", "hello");
|
|
11
|
+
expect(new TextDecoder().decode((await store.get("a/b.txt")) ?? new Uint8Array())).toBe("hello");
|
|
12
|
+
await store.delete("a/b.txt");
|
|
13
|
+
expect(await store.get("a/b.txt")).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("rejects keys that escape the root", async () => {
|
|
17
|
+
const store = localStorage(await mkdtemp(join(tmpdir(), "storage-")));
|
|
18
|
+
for (const key of ["../x", "a/../../x", "a\\..\\x", "/etc/passwd", "C:/x", "a/\0b"]) {
|
|
19
|
+
await expect(store.put(key, "no")).rejects.toThrow(/Invalid storage key/);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
});
|
package/templates/generators/command/files/src/modules/__moduleKebab__/commands/__nameKebab__.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineCommand } from "#core/commands/define";
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
name: "__moduleKebab__:__nameKebab__",
|
|
6
|
+
description: "__nameTitle__",
|
|
7
|
+
args: z.object({ dryRun: z.coerce.boolean().default(false) }),
|
|
8
|
+
async run({ dryRun }, { logger }) {
|
|
9
|
+
logger.info({ dryRun }, "__moduleKebab__:__nameKebab__ ran");
|
|
10
|
+
},
|
|
11
|
+
});
|
package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineConsumer } from "#core/jobs/define";
|
|
3
|
+
|
|
4
|
+
export const __nameCamel__Payload = z.object({ id: z.string().min(1) });
|
|
5
|
+
|
|
6
|
+
export default defineConsumer({
|
|
7
|
+
topic: "__moduleKebab__.__nameKebab__",
|
|
8
|
+
description: "__nameTitle__",
|
|
9
|
+
schema: __nameCamel__Payload,
|
|
10
|
+
async handle({ id }, { logger }) {
|
|
11
|
+
// must be idempotent: a job can run more than once
|
|
12
|
+
logger.info({ id }, "__moduleKebab__.__nameKebab__ handled");
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { processJob } from "#core/jobs/process";
|
|
3
|
+
import { logger } from "#core/logging/logger";
|
|
4
|
+
import { consumers } from "#generated/consumers";
|
|
5
|
+
|
|
6
|
+
describe("__moduleKebab__.__nameKebab__", () => {
|
|
7
|
+
it("handles a valid payload", async () => {
|
|
8
|
+
await expect(
|
|
9
|
+
processJob(consumers, "__moduleKebab__.__nameKebab__", { id: "1" }, { logger, jobId: "t", attempt: 1 }),
|
|
10
|
+
).resolves.toBeUndefined();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { jsonContent, success, successSchema } from "#core/http/response";
|
|
3
|
+
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
4
|
+
import { __moduleCamel__Status } from "#modules/__moduleKebab__/services/__moduleKebab__.service";
|
|
5
|
+
import { __moduleCamel__StatusSchema } from "#modules/__moduleKebab__/validators/__moduleKebab__.validators";
|
|
6
|
+
|
|
7
|
+
const router = createRouter();
|
|
8
|
+
|
|
9
|
+
router.openapi(
|
|
10
|
+
createRoute({
|
|
11
|
+
method: "get",
|
|
12
|
+
path: "/status",
|
|
13
|
+
tags: ["__modulePascal__"],
|
|
14
|
+
summary: "__moduleTitle__ module status",
|
|
15
|
+
responses: { 200: jsonContent(successSchema(__moduleCamel__StatusSchema), "Status") },
|
|
16
|
+
}),
|
|
17
|
+
(c) => c.json(success(__moduleCamel__Status()), 200),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export default defineRoutes({ app: "api", basePath: "/__moduleKebab__", router });
|
package/templates/generators/module/files/src/modules/__moduleKebab__/tests/__moduleKebab__.test.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { api } from "#tests/helpers/api";
|
|
3
|
+
|
|
4
|
+
describe("__moduleKebab__", () => {
|
|
5
|
+
it("reports status", async () => {
|
|
6
|
+
const res = await api.get("/api/__moduleKebab__/status");
|
|
7
|
+
expect(res.status).toBe(200);
|
|
8
|
+
expect(res.body).toEqual({ success: true, data: { module: "__moduleKebab__", status: "ok" } });
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { definePermissions } from "#core/authz/catalog";
|
|
2
|
+
|
|
3
|
+
export default definePermissions({
|
|
4
|
+
resources: {
|
|
5
|
+
"__nameKebab__": {
|
|
6
|
+
// scoped actions are checked at own | team | org; use resolveScope() in the service
|
|
7
|
+
read: { scoped: true },
|
|
8
|
+
manage: { scoped: false },
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
roleGrants: {
|
|
12
|
+
admin: [
|
|
13
|
+
["__nameKebab__", "read", "org"],
|
|
14
|
+
["__nameKebab__", "manage"],
|
|
15
|
+
],
|
|
16
|
+
member: [["__nameKebab__", "read", "own"]],
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import {
|
|
3
|
+
errorResponses,
|
|
4
|
+
jsonContent,
|
|
5
|
+
paginated,
|
|
6
|
+
paginatedSchema,
|
|
7
|
+
paginationQuerySchema,
|
|
8
|
+
success,
|
|
9
|
+
successSchema,
|
|
10
|
+
} from "#core/http/response";
|
|
11
|
+
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
12
|
+
// tatara:slot imports
|
|
13
|
+
import {
|
|
14
|
+
create__namePascal__,
|
|
15
|
+
get__namePascal__,
|
|
16
|
+
list__namePluralPascal__,
|
|
17
|
+
remove__namePascal__,
|
|
18
|
+
update__namePascal__,
|
|
19
|
+
} from "#modules/__moduleKebab__/services/__nameKebab__.service";
|
|
20
|
+
import {
|
|
21
|
+
__nameCamel__ParamsSchema,
|
|
22
|
+
__nameCamel__Schema,
|
|
23
|
+
create__namePascal__Schema,
|
|
24
|
+
update__namePascal__Schema,
|
|
25
|
+
} from "#modules/__moduleKebab__/validators/__nameKebab__.validators";
|
|
26
|
+
|
|
27
|
+
const router = createRouter();
|
|
28
|
+
const tags = ["__namePluralPascal__"];
|
|
29
|
+
|
|
30
|
+
router.openapi(
|
|
31
|
+
createRoute({
|
|
32
|
+
method: "get",
|
|
33
|
+
path: "/",
|
|
34
|
+
tags,
|
|
35
|
+
summary: "List __namePluralKebab__",
|
|
36
|
+
// tatara:slot guard-list
|
|
37
|
+
request: { query: paginationQuerySchema },
|
|
38
|
+
responses: {
|
|
39
|
+
200: jsonContent(paginatedSchema(__nameCamel__Schema), "A page of __namePluralKebab__"),
|
|
40
|
+
...errorResponses(400),
|
|
41
|
+
// tatara:slot auth-errors
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
async (c) => {
|
|
45
|
+
const query = c.req.valid("query");
|
|
46
|
+
const { items, total } = await list__namePluralPascal__(query);
|
|
47
|
+
return c.json(paginated(items, query.page, query.pageSize, total), 200);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
router.openapi(
|
|
52
|
+
createRoute({
|
|
53
|
+
method: "get",
|
|
54
|
+
path: "/{id}",
|
|
55
|
+
tags,
|
|
56
|
+
summary: "Get a __nameKebab__",
|
|
57
|
+
// tatara:slot guard-read
|
|
58
|
+
request: { params: __nameCamel__ParamsSchema },
|
|
59
|
+
responses: {
|
|
60
|
+
200: jsonContent(successSchema(__nameCamel__Schema), "The __nameKebab__"),
|
|
61
|
+
...errorResponses(400, 404),
|
|
62
|
+
// tatara:slot auth-errors
|
|
63
|
+
},
|
|
64
|
+
}),
|
|
65
|
+
async (c) => c.json(success(await get__namePascal__(c.req.valid("param").id)), 200),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
router.openapi(
|
|
69
|
+
createRoute({
|
|
70
|
+
method: "post",
|
|
71
|
+
path: "/",
|
|
72
|
+
tags,
|
|
73
|
+
summary: "Create a __nameKebab__",
|
|
74
|
+
// tatara:slot guard-create
|
|
75
|
+
request: { body: jsonContent(create__namePascal__Schema, "The new __nameKebab__") },
|
|
76
|
+
responses: {
|
|
77
|
+
201: jsonContent(successSchema(__nameCamel__Schema), "Created"),
|
|
78
|
+
...errorResponses(400),
|
|
79
|
+
// tatara:slot auth-errors
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
async (c) => c.json(success(await create__namePascal__(c.req.valid("json"))), 201),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
router.openapi(
|
|
86
|
+
createRoute({
|
|
87
|
+
method: "patch",
|
|
88
|
+
path: "/{id}",
|
|
89
|
+
tags,
|
|
90
|
+
summary: "Update a __nameKebab__",
|
|
91
|
+
// tatara:slot guard-update
|
|
92
|
+
request: {
|
|
93
|
+
params: __nameCamel__ParamsSchema,
|
|
94
|
+
body: jsonContent(update__namePascal__Schema, "Fields to change"),
|
|
95
|
+
},
|
|
96
|
+
responses: {
|
|
97
|
+
200: jsonContent(successSchema(__nameCamel__Schema), "Updated"),
|
|
98
|
+
...errorResponses(400, 404),
|
|
99
|
+
// tatara:slot auth-errors
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
async (c) =>
|
|
103
|
+
c.json(success(await update__namePascal__(c.req.valid("param").id, c.req.valid("json"))), 200),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
router.openapi(
|
|
107
|
+
createRoute({
|
|
108
|
+
method: "delete",
|
|
109
|
+
path: "/{id}",
|
|
110
|
+
tags,
|
|
111
|
+
summary: "Delete a __nameKebab__",
|
|
112
|
+
// tatara:slot guard-delete
|
|
113
|
+
request: { params: __nameCamel__ParamsSchema },
|
|
114
|
+
responses: {
|
|
115
|
+
204: { description: "Deleted" },
|
|
116
|
+
...errorResponses(400, 404),
|
|
117
|
+
// tatara:slot auth-errors
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
async (c) => {
|
|
121
|
+
await remove__namePascal__(c.req.valid("param").id);
|
|
122
|
+
return c.body(null, 204);
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
export default defineRoutes({ app: "api", basePath: "/__namePluralKebab__", router });
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { api } from "#tests/helpers/api";
|
|
3
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
4
|
+
// tatara:slot test-imports
|
|
5
|
+
|
|
6
|
+
const path = "/api/__namePluralKebab__";
|
|
7
|
+
const headers: Record<string, string> = {};
|
|
8
|
+
|
|
9
|
+
interface __namePascal__Body {
|
|
10
|
+
data: { id: string; name: string; description: string | null };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
await resetDatabase();
|
|
15
|
+
// tatara:slot test-auth
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("__namePluralKebab__", () => {
|
|
19
|
+
it("creates, reads, updates, lists and deletes", async () => {
|
|
20
|
+
const created = await api.post<__namePascal__Body>(path, { name: "First" }, { headers });
|
|
21
|
+
expect(created.status).toBe(201);
|
|
22
|
+
const { id } = created.body.data;
|
|
23
|
+
expect(created.body.data).toMatchObject({ name: "First", description: null });
|
|
24
|
+
|
|
25
|
+
const read = await api.get<__namePascal__Body>(`${path}/${id}`, { headers });
|
|
26
|
+
expect(read.status).toBe(200);
|
|
27
|
+
expect(read.body.data.id).toBe(id);
|
|
28
|
+
|
|
29
|
+
const updated = await api.patch<__namePascal__Body>(
|
|
30
|
+
`${path}/${id}`,
|
|
31
|
+
{ description: "Changed" },
|
|
32
|
+
{ headers },
|
|
33
|
+
);
|
|
34
|
+
expect(updated.status).toBe(200);
|
|
35
|
+
expect(updated.body.data).toMatchObject({ name: "First", description: "Changed" });
|
|
36
|
+
|
|
37
|
+
await api.post(path, { name: "Second" }, { headers });
|
|
38
|
+
const list = await api.get<{ data: { name: string }[]; meta: { total: number } }>(
|
|
39
|
+
`${path}?pageSize=10`,
|
|
40
|
+
{ headers },
|
|
41
|
+
);
|
|
42
|
+
expect(list.status).toBe(200);
|
|
43
|
+
expect(list.body.meta.total).toBe(2);
|
|
44
|
+
expect(list.body.data.map((item) => item.name)).toEqual(["Second", "First"]);
|
|
45
|
+
|
|
46
|
+
const removed = await api.delete(`${path}/${id}`, { headers });
|
|
47
|
+
expect(removed.status).toBe(204);
|
|
48
|
+
const gone = await api.get(`${path}/${id}`, { headers });
|
|
49
|
+
expect(gone.status).toBe(404);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects invalid input", async () => {
|
|
53
|
+
const res = await api.post(path, { name: "" }, { headers });
|
|
54
|
+
expect(res.status).toBe(400);
|
|
55
|
+
expect(res.body).toMatchObject({ success: false, code: "VALIDATION_ERROR" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// tatara:slot test-cases
|
|
59
|
+
});
|