@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,13 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
3
|
+
import pg from "pg";
|
|
4
|
+
|
|
5
|
+
/** Applies ./drizzle migrations to the database at `url`. */
|
|
6
|
+
export async function migrateDatabase(url: string): Promise<void> {
|
|
7
|
+
const pool = new pg.Pool({ connectionString: url, max: 1 });
|
|
8
|
+
try {
|
|
9
|
+
await migrate(drizzle({ client: pool }), { migrationsFolder: "drizzle" });
|
|
10
|
+
} finally {
|
|
11
|
+
await pool.end();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineCommand } from "#core/commands/define";
|
|
3
|
+
import { sendEmail } from "#core/email/send";
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
name: "email:send-test",
|
|
7
|
+
description: "Send a test email (see it in Mailpit locally)",
|
|
8
|
+
args: z.object({ to: z.email().default("dev@example.com") }),
|
|
9
|
+
async run({ to }, { logger }) {
|
|
10
|
+
await sendEmail({ to, subject: "Test email", text: "It works." });
|
|
11
|
+
logger.info({ to }, "test email sent");
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createTransport, type Transporter } from "nodemailer";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { logger } from "#core/logging/logger";
|
|
4
|
+
|
|
5
|
+
export interface EmailMessage {
|
|
6
|
+
to: string;
|
|
7
|
+
subject: string;
|
|
8
|
+
text: string;
|
|
9
|
+
html?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let transport: Transporter | undefined;
|
|
13
|
+
|
|
14
|
+
/** Sends through SMTP (Mailpit locally) or logs it when EMAIL_DRIVER=console. */
|
|
15
|
+
export async function sendEmail(message: EmailMessage): Promise<void> {
|
|
16
|
+
if (env.EMAIL_DRIVER === "console") {
|
|
17
|
+
logger.info({ to: message.to, subject: message.subject }, "email (console driver)");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
transport ??= createTransport(env.SMTP_URL);
|
|
21
|
+
await transport.sendMail({ from: env.EMAIL_FROM, ...message });
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sendEmail } from "#core/email/send";
|
|
3
|
+
|
|
4
|
+
describe("email", () => {
|
|
5
|
+
it("accepts a message with the test (console) driver", async () => {
|
|
6
|
+
await expect(sendEmail({ to: "a@example.com", subject: "Hi", text: "Hello" })).resolves.toBeUndefined();
|
|
7
|
+
});
|
|
8
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import type { AppEnv } from "#core/http/context";
|
|
4
|
+
import ar from "#locales/ar" with { type: "json" };
|
|
5
|
+
import en from "#locales/en" with { type: "json" };
|
|
6
|
+
|
|
7
|
+
type Dictionary = { [key: string]: string | Dictionary };
|
|
8
|
+
|
|
9
|
+
const dictionaries: Record<string, Dictionary> = { en, ar };
|
|
10
|
+
|
|
11
|
+
export const SUPPORTED_LANGUAGES = Object.keys(dictionaries);
|
|
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
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { t } from "#core/i18n/index";
|
|
3
|
+
import { api } from "#tests/helpers/api";
|
|
4
|
+
|
|
5
|
+
describe("i18n", () => {
|
|
6
|
+
it("translates with a fallback to the default language", () => {
|
|
7
|
+
expect(t("ar", "errors.NOT_FOUND")).toBe("غير موجود");
|
|
8
|
+
expect(t("fr", "errors.NOT_FOUND")).toBe("Not found");
|
|
9
|
+
expect(t("en", "missing.key", "Fallback")).toBe("Fallback");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("localizes error messages from Accept-Language", async () => {
|
|
13
|
+
const res = await api.get("/api/nope", { headers: { "accept-language": "ar-SA,ar;q=0.9" } });
|
|
14
|
+
expect(res.status).toBe(404);
|
|
15
|
+
const invalid = await api.get(`/api/greetings?name=${"x".repeat(60)}`, {
|
|
16
|
+
headers: { "accept-language": "ar" },
|
|
17
|
+
});
|
|
18
|
+
expect(invalid.body).toMatchObject({ code: "VALIDATION_ERROR", error: "الطلب غير صالح" });
|
|
19
|
+
expect(invalid.headers.get("content-language")).toBe("ar");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"errors": {
|
|
3
|
+
"VALIDATION_ERROR": "Invalid request",
|
|
4
|
+
"UNAUTHORIZED": "Authentication required",
|
|
5
|
+
"FORBIDDEN": "You do not have permission to do this",
|
|
6
|
+
"NOT_FOUND": "Not found",
|
|
7
|
+
"CONFLICT": "Already exists",
|
|
8
|
+
"INTERNAL_ERROR": "Something went wrong"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
import { withLock } from "#core/jobs/lock";
|
|
3
|
+
import { logger } from "#core/logging/logger";
|
|
4
|
+
import { handleSignals, onShutdown } from "#core/process/lifecycle";
|
|
5
|
+
import { schedules } from "#generated/schedules";
|
|
6
|
+
|
|
7
|
+
const jobs = schedules.map((schedule) => {
|
|
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
|
+
});
|
|
24
|
+
handleSignals();
|
|
25
|
+
logger.info({ schedules: schedules.map((s) => `${s.name} (${s.cron})`) }, "cron started");
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Worker } from "bullmq";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { processJob } from "#core/jobs/process";
|
|
4
|
+
import { QUEUE_NAME } from "#core/jobs/queue";
|
|
5
|
+
import { redis } from "#core/jobs/redis";
|
|
6
|
+
import { logger } from "#core/logging/logger";
|
|
7
|
+
import { handleSignals, onShutdown } from "#core/process/lifecycle";
|
|
8
|
+
import { consumers } from "#generated/consumers";
|
|
9
|
+
|
|
10
|
+
const worker = new Worker(
|
|
11
|
+
QUEUE_NAME,
|
|
12
|
+
(job) =>
|
|
13
|
+
processJob(consumers, job.name, job.data, {
|
|
14
|
+
logger: logger.child({ jobId: job.id, topic: job.name }),
|
|
15
|
+
jobId: job.id ?? "",
|
|
16
|
+
attempt: job.attemptsMade + 1,
|
|
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());
|
|
23
|
+
handleSignals();
|
|
24
|
+
logger.info({ topics: consumers.map((c) => c.topic) }, "worker started");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineCommand } from "#core/commands/define";
|
|
3
|
+
import { enqueue } from "#core/jobs/queue";
|
|
4
|
+
|
|
5
|
+
export default defineCommand({
|
|
6
|
+
name: "jobs:enqueue",
|
|
7
|
+
description: "Enqueue a job by topic with a JSON payload (for development and debugging)",
|
|
8
|
+
args: z.object({ topic: z.string().min(1), data: z.string().default("{}") }),
|
|
9
|
+
async run({ topic, data }, { logger }) {
|
|
10
|
+
const id = await enqueue(topic, JSON.parse(data) as unknown);
|
|
11
|
+
logger.info({ topic, id }, "job enqueued");
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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 "#core/process/lifecycle";
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Redis } from "ioredis";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
3
|
+
import { onShutdown } from "#core/process/lifecycle";
|
|
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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineConsumer } from "#core/jobs/define";
|
|
3
|
+
|
|
4
|
+
export default defineConsumer({
|
|
5
|
+
topic: "example.send-welcome",
|
|
6
|
+
description: "Example consumer: greets a new user",
|
|
7
|
+
schema: z.object({ name: z.string().min(1) }),
|
|
8
|
+
async handle({ name }, { logger }) {
|
|
9
|
+
logger.info({ name }, "welcome sent");
|
|
10
|
+
},
|
|
11
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { withLock } from "#core/jobs/lock";
|
|
3
|
+
import { processJob } from "#core/jobs/process";
|
|
4
|
+
import { logger } from "#core/logging/logger";
|
|
5
|
+
import { consumers } from "#generated/consumers";
|
|
6
|
+
|
|
7
|
+
const ctx = { logger, jobId: "test", attempt: 1 };
|
|
8
|
+
|
|
9
|
+
describe("jobs", () => {
|
|
10
|
+
it("runs the consumer for a topic with a valid payload", async () => {
|
|
11
|
+
await expect(processJob(consumers, "example.send-welcome", { name: "Ada" }, ctx)).resolves.toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("rejects an invalid payload", async () => {
|
|
15
|
+
await expect(processJob(consumers, "example.send-welcome", { name: "" }, ctx)).rejects.toThrow(
|
|
16
|
+
/Invalid payload/,
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("lets only one holder run a locked section", async () => {
|
|
21
|
+
const key = `test-${Date.now()}-${Math.random()}`;
|
|
22
|
+
let inner: boolean | undefined;
|
|
23
|
+
const outer = await withLock(key, 5000, async () => {
|
|
24
|
+
inner = await withLock(key, 5000, async () => {});
|
|
25
|
+
});
|
|
26
|
+
expect(outer).toBe(true);
|
|
27
|
+
expect(inner).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"realm": "__APP_NAME__",
|
|
3
|
+
"enabled": true,
|
|
4
|
+
"displayName": "__APP_NAME__ (development)",
|
|
5
|
+
"sslRequired": "none",
|
|
6
|
+
"accessTokenLifespan": 900,
|
|
7
|
+
"registrationAllowed": false,
|
|
8
|
+
"roles": {
|
|
9
|
+
"realm": [
|
|
10
|
+
{ "name": "user", "description": "Signed-in user" },
|
|
11
|
+
{ "name": "admin", "description": "Administrator" }
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"clients": [
|
|
15
|
+
{
|
|
16
|
+
"clientId": "__APP_NAME__-api",
|
|
17
|
+
"name": "__APP_NAME__ API (token audience)",
|
|
18
|
+
"enabled": true,
|
|
19
|
+
"publicClient": false,
|
|
20
|
+
"bearerOnly": true,
|
|
21
|
+
"standardFlowEnabled": false,
|
|
22
|
+
"directAccessGrantsEnabled": false
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"clientId": "__APP_NAME__-web",
|
|
26
|
+
"name": "__APP_NAME__ web (public, PKCE)",
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"publicClient": true,
|
|
29
|
+
"standardFlowEnabled": true,
|
|
30
|
+
"directAccessGrantsEnabled": true,
|
|
31
|
+
"redirectUris": ["http://localhost:5173/*", "http://localhost:3000/*"],
|
|
32
|
+
"webOrigins": ["+"],
|
|
33
|
+
"attributes": { "pkce.code.challenge.method": "S256", "post.logout.redirect.uris": "+" },
|
|
34
|
+
"protocolMappers": [
|
|
35
|
+
{
|
|
36
|
+
"name": "api-audience",
|
|
37
|
+
"protocol": "openid-connect",
|
|
38
|
+
"protocolMapper": "oidc-audience-mapper",
|
|
39
|
+
"config": {
|
|
40
|
+
"included.client.audience": "__APP_NAME__-api",
|
|
41
|
+
"access.token.claim": "true",
|
|
42
|
+
"id.token.claim": "false"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"name": "realm-roles",
|
|
47
|
+
"protocol": "openid-connect",
|
|
48
|
+
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
|
49
|
+
"config": {
|
|
50
|
+
"claim.name": "roles",
|
|
51
|
+
"multivalued": "true",
|
|
52
|
+
"jsonType.label": "String",
|
|
53
|
+
"access.token.claim": "true",
|
|
54
|
+
"id.token.claim": "false",
|
|
55
|
+
"userinfo.token.claim": "false"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"users": [
|
|
62
|
+
{
|
|
63
|
+
"username": "alice",
|
|
64
|
+
"email": "alice@example.com",
|
|
65
|
+
"emailVerified": true,
|
|
66
|
+
"firstName": "Alice",
|
|
67
|
+
"lastName": "Admin",
|
|
68
|
+
"enabled": true,
|
|
69
|
+
"credentials": [{ "type": "password", "value": "alice", "temporary": false }],
|
|
70
|
+
"realmRoles": ["user", "admin"]
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"username": "bob",
|
|
74
|
+
"email": "bob@example.com",
|
|
75
|
+
"emailVerified": true,
|
|
76
|
+
"firstName": "Bob",
|
|
77
|
+
"lastName": "User",
|
|
78
|
+
"enabled": true,
|
|
79
|
+
"credentials": [{ "type": "password", "value": "bob", "temporary": false }],
|
|
80
|
+
"realmRoles": ["user"]
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { issuer } from "#core/auth/keycloak";
|
|
3
|
+
import { defineCommand } from "#core/commands/define";
|
|
4
|
+
import { env } from "#core/config/env";
|
|
5
|
+
|
|
6
|
+
export default defineCommand({
|
|
7
|
+
name: "keycloak:token",
|
|
8
|
+
description: "Print an access token for a dev-realm user (development only)",
|
|
9
|
+
args: z.object({
|
|
10
|
+
user: z.string().default("alice"),
|
|
11
|
+
password: z.string().optional(),
|
|
12
|
+
client: z.string().default("__APP_NAME__-web"),
|
|
13
|
+
}),
|
|
14
|
+
async run({ user, password, client }) {
|
|
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`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
19
|
+
body: new URLSearchParams({
|
|
20
|
+
grant_type: "password",
|
|
21
|
+
client_id: client,
|
|
22
|
+
username: user,
|
|
23
|
+
password: password ?? user,
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
const body = (await res.json()) as { access_token?: string; error_description?: string };
|
|
27
|
+
if (!res.ok || !body.access_token) {
|
|
28
|
+
throw new Error(`Keycloak refused: ${body.error_description ?? res.statusText}`);
|
|
29
|
+
}
|
|
30
|
+
console.log(body.access_token);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createRemoteJWKSet, type JWTPayload, type JWTVerifyGetKey, jwtVerify } from "jose";
|
|
2
|
+
import { env } from "#core/config/env";
|
|
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import { organizationOf, rolesOf, verifyAccessToken } from "#core/auth/keycloak";
|
|
3
|
+
import { ensureLocalUser } from "#core/auth/users";
|
|
4
|
+
import type { AppEnv } from "#core/http/context";
|
|
5
|
+
|
|
6
|
+
/** Verifies `Authorization: Bearer <Keycloak access token>` into `c.var.principal`. */
|
|
7
|
+
export const attachPrincipal = createMiddleware<AppEnv>(async (c, next) => {
|
|
8
|
+
const header = c.req.header("authorization");
|
|
9
|
+
const token = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
|
10
|
+
if (token) {
|
|
11
|
+
try {
|
|
12
|
+
const claims = await verifyAccessToken(token);
|
|
13
|
+
const isService = claims.preferred_username?.startsWith("service-account-") ?? false;
|
|
14
|
+
if (!isService) await ensureLocalUser(claims);
|
|
15
|
+
c.set("principal", {
|
|
16
|
+
userId: claims.sub,
|
|
17
|
+
orgId: organizationOf(claims),
|
|
18
|
+
roles: rolesOf(claims),
|
|
19
|
+
kind: isService ? "service" : "user",
|
|
20
|
+
claims,
|
|
21
|
+
});
|
|
22
|
+
} catch (error) {
|
|
23
|
+
c.get("logger").debug({ err: error }, "rejected bearer token");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
await next();
|
|
27
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { generateKeyPair, SignJWT } from "jose";
|
|
2
|
+
import { issuer, setKeySource } from "#core/auth/keycloak";
|
|
3
|
+
import { env } from "#core/config/env";
|
|
4
|
+
import { newId } from "#core/db/id";
|
|
5
|
+
// tatara:slot imports
|
|
6
|
+
|
|
7
|
+
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
|
8
|
+
setKeySource(async (header) => {
|
|
9
|
+
if (header.kid !== "test") throw new Error("unknown key");
|
|
10
|
+
return publicKey;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export interface AsUserOptions {
|
|
14
|
+
sub?: string;
|
|
15
|
+
email?: string;
|
|
16
|
+
roles?: string[];
|
|
17
|
+
orgId?: string;
|
|
18
|
+
// tatara:slot options
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Signs a token the API accepts, as if Keycloak issued it for a new user. */
|
|
22
|
+
export async function tokenFor(options: AsUserOptions = {}): Promise<string> {
|
|
23
|
+
const sub = options.sub ?? newId();
|
|
24
|
+
return new SignJWT({
|
|
25
|
+
email: options.email ?? `user-${sub}@example.com`,
|
|
26
|
+
preferred_username: `user-${sub.slice(0, 8)}`,
|
|
27
|
+
roles: options.roles ?? ["user"],
|
|
28
|
+
...(options.orgId ? { organization: [options.orgId] } : {}),
|
|
29
|
+
})
|
|
30
|
+
.setProtectedHeader({ alg: "RS256", kid: "test" })
|
|
31
|
+
.setSubject(sub)
|
|
32
|
+
.setIssuer(issuer)
|
|
33
|
+
.setAudience(env.KEYCLOAK_AUDIENCE)
|
|
34
|
+
.setIssuedAt()
|
|
35
|
+
.setExpirationTime("5m")
|
|
36
|
+
.sign(privateKey);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A new user (random subject) with a signed access token. */
|
|
40
|
+
export async function authenticate(
|
|
41
|
+
options: AsUserOptions = {},
|
|
42
|
+
): Promise<{ userId: string; headers: Record<string, string> }> {
|
|
43
|
+
const sub = options.sub ?? newId();
|
|
44
|
+
return { userId: sub, headers: { authorization: `Bearer ${await tokenFor({ ...options, sub })}` } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Headers that authenticate as a new user. */
|
|
48
|
+
export async function asUser(options: AsUserOptions = {}): Promise<Record<string, string>> {
|
|
49
|
+
const user = await authenticate(options);
|
|
50
|
+
// tatara:slot as-user
|
|
51
|
+
return user.headers;
|
|
52
|
+
}
|