@tatara/cli 0.9.2 → 0.11.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 +1436 -319
- package/package.json +1 -1
- package/templates/features/admin/files/src/apps/admin/app.ts +1 -1
- package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +2 -2
- package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +11 -4
- package/templates/features/admin/files/src/modules/staff/db/seed.ts +27 -0
- package/templates/features/admin/files/src/modules/staff/services/auth.ts +46 -0
- package/templates/features/admin/files/tests/helpers/staff.ts +8 -0
- package/templates/features/admin/files+drizzle+mysql/src/modules/staff/db/staff.schema.ts +58 -0
- package/templates/features/admin/files+drizzle+mysql/src/modules/staff/services/adapter.ts +13 -0
- package/templates/features/admin/files+drizzle+postgres/src/modules/staff/db/staff.schema.ts +57 -0
- package/templates/features/admin/files+drizzle+postgres/src/modules/staff/services/adapter.ts +13 -0
- package/templates/features/admin/files+prisma/prisma/models/staff.prisma +61 -0
- package/templates/features/admin/files+prisma+mysql/src/modules/staff/services/adapter.ts +5 -0
- package/templates/features/admin/files+prisma+postgres/src/modules/staff/services/adapter.ts +5 -0
- package/templates/features/auth-keycloak/files/keycloak/realm.json +70 -9
- package/templates/features/auth-keycloak/files/src/{core/auth → modules/users}/commands/keycloak-token.ts +3 -1
- package/templates/features/auth-keycloak/files/{src/core/auth/tests/keycloak-tokens.test.ts → tests/tatara/auth-keycloak-tokens.test.ts} +2 -2
- package/templates/features/auth-keycloak/files+auth-orgs/{src/core/auth/tests/keycloak-organizations.test.ts → tests/tatara/auth-keycloak-organizations.test.ts} +1 -1
- package/templates/features/auth-keycloak/files+authz/{src/core/auth/tests/keycloak-authz.test.ts → tests/tatara/auth-keycloak-authz.test.ts} +3 -2
- package/templates/features/auth-orgs/files+drizzle+mysql/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
- package/templates/features/auth-orgs/files+drizzle+postgres/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
- package/templates/features/authz/files/src/core/authz/system-roles.ts +1 -0
- package/templates/features/authz/files/src/{core/authz → modules/users}/routes/authorization.routes.ts +4 -1
- package/templates/features/authz/files/{src/core/authz/tests → tests/tatara}/authz.test.ts +4 -2
- package/templates/features/authz/files+drizzle/src/core/authz/index.ts +1 -0
- package/templates/features/authz/files+prisma/src/core/authz/index.ts +1 -0
- package/templates/features/base/files/AGENTS.md +17 -0
- package/templates/features/base/files/biome.template.json +1 -1
- package/templates/features/base/files/src/apps/api/app.ts +1 -1
- package/templates/features/base/files/src/apps/api/server.ts +7 -1
- package/templates/features/base/files/src/apps/cli/index.ts +4 -1
- package/templates/features/base/files/src/{core/http/app.ts → apps/http.ts} +6 -0
- package/templates/features/base/files/src/core/http/context.ts +2 -0
- package/templates/features/base/files/src/core/logging/logger.ts +5 -3
- package/templates/features/better-auth/files/src/{core/auth → modules/users}/db/seed.ts +1 -1
- package/templates/features/better-auth/files/src/modules/users/services/auth.ts +37 -0
- package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/delivery.ts +1 -1
- package/templates/features/better-auth/files/tests/helpers/auth.ts +4 -3
- package/templates/features/better-auth/files+admin/tests/tatara/auth-openapi-admin.test.ts +10 -0
- package/templates/features/better-auth/files+auth-orgs/{src/core/auth/tests/principal.test.ts → tests/tatara/auth-principal.test.ts} +1 -1
- package/templates/features/better-auth/files+auth-orgs+authz/{src/core/auth/tests/principal-authz.test.ts → tests/tatara/auth-principal-authz.test.ts} +1 -1
- package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users/services}/adapter.ts +1 -1
- package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users/services}/adapter.ts +1 -1
- package/templates/features/cache/files/src/core/cache/index.ts +23 -0
- package/templates/features/cache/files/src/core/http/rate-limit.ts +15 -0
- package/templates/features/cache/files/tests/tatara/cache.test.ts +22 -0
- package/templates/features/cache/files/tests/tatara/rate-limit.test.ts +27 -0
- package/templates/features/cache/files+postgres/src/core/cache/index.ts +36 -0
- package/templates/features/db/files+mysql+drizzle/src/core/commands/db/command-runs.schema.ts +10 -0
- package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +6 -1
- package/templates/features/db/files+mysql+prisma/prisma/models/command-runs.prisma +9 -0
- package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +1 -0
- package/templates/features/db/files+postgres/src/core/db/admin.ts +6 -1
- package/templates/features/email/files/src/core/email/send.ts +22 -2
- package/templates/features/email/files/src/modules/example/emails/welcome.ts +11 -0
- package/templates/features/jobs/files/src/apps/cron/index.ts +2 -3
- package/templates/features/jobs/files/src/apps/cron/runner.ts +11 -0
- package/templates/features/jobs/files/src/apps/worker/index.ts +2 -9
- package/templates/features/jobs/files/src/core/jobs/index.ts +37 -6
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +7 -10
- package/templates/features/jobs/files+drizzle+postgres/src/core/jobs/outbox.ts +23 -0
- package/templates/features/jobs/files+drizzle+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
- package/templates/features/jobs/files+postgres/src/core/jobs/index.ts +47 -0
- package/templates/features/jobs/files+prisma+postgres/src/core/jobs/outbox.ts +17 -0
- package/templates/features/jobs/files+prisma+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
- package/templates/features/notifications/files/src/modules/example/notifications/welcome.ts +14 -0
- package/templates/features/notifications/files/src/modules/notifications/consumers/deliver.ts +6 -0
- package/templates/features/notifications/files/src/modules/notifications/consumers/fanout.ts +6 -0
- package/templates/features/notifications/files/src/modules/notifications/routes/notifications.routes.ts +168 -0
- package/templates/features/notifications/files/src/modules/notifications/services/notifier.ts +59 -0
- package/templates/features/notifications/files/src/modules/notifications/tests/notifications.test.ts +72 -0
- package/templates/features/notifications/files+drizzle/src/modules/notifications/services/store.ts +5 -0
- package/templates/features/notifications/files+drizzle+mysql/src/modules/notifications/db/notifications.schema.ts +40 -0
- package/templates/features/notifications/files+drizzle+postgres/src/modules/notifications/db/notifications.schema.ts +41 -0
- package/templates/features/notifications/files+prisma/prisma/models/notifications.prisma +34 -0
- package/templates/features/notifications/files+prisma/src/modules/notifications/services/store.ts +4 -0
- package/templates/features/observability/files/src/apps/instrument.ts +15 -0
- package/templates/features/realtime/files/src/core/realtime/index.ts +29 -0
- package/templates/features/realtime/files/src/modules/realtime/routes/realtime.routes.ts +38 -0
- package/templates/features/realtime/files/tests/tatara/realtime.test.ts +37 -0
- package/templates/versions.json +28 -8
- package/templates/features/better-auth/files/src/core/auth/auth.ts +0 -26
- package/templates/features/better-auth/files/src/core/auth/principal.ts +0 -10
- package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +0 -8
- /package/templates/features/auth/files/src/{core/auth → modules/users}/routes/me.routes.ts +0 -0
- /package/templates/features/auth/files/{src/core/auth/tests/me.test.ts → tests/tatara/auth-me.test.ts} +0 -0
- /package/templates/features/auth-keycloak/files/src/{core/auth → modules/users/services}/keycloak.ts +0 -0
- /package/templates/features/authz/files/{src/core/authz/tests/error-messages.test.ts → tests/tatara/authz-error-messages.test.ts} +0 -0
- /package/templates/features/authz/files/{src/core/authz/tests/request-body.test.ts → tests/tatara/authz-request-body.test.ts} +0 -0
- /package/templates/features/base/files/{src/core/config/tests/env.test.ts → tests/tatara/config-env.test.ts} +0 -0
- /package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/sms.ts +0 -0
- /package/templates/features/better-auth/files/{src/core/auth/tests/openapi.test.ts → tests/tatara/auth-openapi.test.ts} +0 -0
- /package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
- /package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
- /package/templates/features/better-auth/files+prisma+mysql/src/{core/auth → modules/users/services}/adapter.ts +0 -0
- /package/templates/features/better-auth/files+prisma+postgres/src/{core/auth → modules/users/services}/adapter.ts +0 -0
- /package/templates/features/email/files/{src/core/email/tests → tests/tatara}/email.test.ts +0 -0
- /package/templates/features/i18n/files/{src/core/i18n/tests → tests/tatara}/i18n.test.ts +0 -0
- /package/templates/features/storage/files/{src/core/storage/tests → tests/tatara}/storage.test.ts +0 -0
package/dist/bin.mjs
CHANGED
|
@@ -6,12 +6,12 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/pr
|
|
|
6
6
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import { glob } from "tinyglobby";
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, watch } from "node:fs";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
9
10
|
import { x } from "tinyexec";
|
|
10
11
|
import { parseEnv } from "node:util";
|
|
11
12
|
import * as p from "@clack/prompts";
|
|
12
13
|
import { createHash, randomBytes } from "node:crypto";
|
|
13
14
|
import { stringify } from "yaml";
|
|
14
|
-
import { homedir, tmpdir } from "node:os";
|
|
15
15
|
//#region \0rolldown/runtime.js
|
|
16
16
|
var __defProp = Object.defineProperty;
|
|
17
17
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -190,6 +190,61 @@ function ignored(v, text) {
|
|
|
190
190
|
return new RegExp(`tatara-check-ignore\\s+${v.rule}:\\s*\\S`).test(previous);
|
|
191
191
|
}
|
|
192
192
|
//#endregion
|
|
193
|
+
//#region src/paths.ts
|
|
194
|
+
/** Package root: one level above both src/ (dev) and dist/ (published, bundled). */
|
|
195
|
+
const PACKAGE_ROOT = resolve(import.meta.dirname, "..");
|
|
196
|
+
const TEMPLATES_DIR = resolve(PACKAGE_ROOT, "templates");
|
|
197
|
+
function cliVersion() {
|
|
198
|
+
return JSON.parse(readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
199
|
+
}
|
|
200
|
+
let versions;
|
|
201
|
+
/** Exact versions used in generated apps (templates/versions.json). */
|
|
202
|
+
function templateVersions() {
|
|
203
|
+
versions ??= JSON.parse(readFileSync(resolve(TEMPLATES_DIR, "versions.json"), "utf8"));
|
|
204
|
+
return versions;
|
|
205
|
+
}
|
|
206
|
+
/** `TATARA_PACKAGE_SPECS='{"@tatara/core":"file:…"}'` installs local builds (e2e, bench). */
|
|
207
|
+
function packageSpecOverride(pkg) {
|
|
208
|
+
const raw = process.env.TATARA_PACKAGE_SPECS;
|
|
209
|
+
if (!raw) return void 0;
|
|
210
|
+
return JSON.parse(raw)[pkg];
|
|
211
|
+
}
|
|
212
|
+
function versionOf(pkg) {
|
|
213
|
+
const override = packageSpecOverride(pkg);
|
|
214
|
+
if (override) return override;
|
|
215
|
+
if (pkg === "@tatara/cli") return process.env.TATARA_CLI_SPEC ?? `^${cliVersion()}`;
|
|
216
|
+
const version = templateVersions()[pkg];
|
|
217
|
+
if (!version) throw new Error(`templates/versions.json has no version for "${pkg}"`);
|
|
218
|
+
return pkg.startsWith("@tatara/") ? `^${version}` : version;
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/pm.ts
|
|
222
|
+
function pmCommands(pm) {
|
|
223
|
+
switch (pm) {
|
|
224
|
+
case "pnpm": return {
|
|
225
|
+
install: ["pnpm", "install"],
|
|
226
|
+
run: "pnpm",
|
|
227
|
+
exec: "pnpm exec",
|
|
228
|
+
dlx: ["pnpm", "dlx"],
|
|
229
|
+
lockfile: "pnpm-lock.yaml"
|
|
230
|
+
};
|
|
231
|
+
case "npm": return {
|
|
232
|
+
install: ["npm", "install"],
|
|
233
|
+
run: "npm run",
|
|
234
|
+
exec: "npx",
|
|
235
|
+
dlx: ["npx", "-y"],
|
|
236
|
+
lockfile: "package-lock.json"
|
|
237
|
+
};
|
|
238
|
+
case "bun": return {
|
|
239
|
+
install: ["bun", "install"],
|
|
240
|
+
run: "bun run",
|
|
241
|
+
exec: "bunx",
|
|
242
|
+
dlx: ["bunx"],
|
|
243
|
+
lockfile: "bun.lock"
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
193
248
|
//#region src/project/ejected.ts
|
|
194
249
|
/** Packages the app ejected (`tatara eject`); their code lives in src/ejected/<name>. */
|
|
195
250
|
function ejectedPackages(config) {
|
|
@@ -219,7 +274,10 @@ function identifier(path, prefix) {
|
|
|
219
274
|
"consumers",
|
|
220
275
|
"schedules",
|
|
221
276
|
"permissions",
|
|
222
|
-
"db"
|
|
277
|
+
"db",
|
|
278
|
+
"emails",
|
|
279
|
+
"notifications",
|
|
280
|
+
"channels"
|
|
223
281
|
].includes(p)).join("_")}`.replace(/[^A-Za-z0-9_]/g, "_").replace(/_+/g, "_");
|
|
224
282
|
}
|
|
225
283
|
async function find(root, patterns) {
|
|
@@ -239,6 +297,26 @@ ${imports.join("\n")}${imports.length ? "\n" : ""}
|
|
|
239
297
|
export const ${options.exportName}: ${options.typeName}[] = [${names.join(", ")}];
|
|
240
298
|
`;
|
|
241
299
|
}
|
|
300
|
+
/** The consumers keep their literal topics and schemas, so `TopicsOf<typeof consumers>` types enqueue. */
|
|
301
|
+
function consumerList(files) {
|
|
302
|
+
const imports = files.map((f) => `import ${identifier(f, "consumer")} from "${specifier(f)}";`);
|
|
303
|
+
const names = files.map((f) => identifier(f, "consumer"));
|
|
304
|
+
return `${HEADER}import type { ConsumerDefinition } from "@tatara/jobs";
|
|
305
|
+
${imports.join("\n")}${imports.length ? "\n" : ""}
|
|
306
|
+
// biome-ignore lint/suspicious/noExplicitAny: every consumer, whatever its payload
|
|
307
|
+
export const consumers = [${names.join(", ")}] as const satisfies readonly ConsumerDefinition<string, any>[];
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
/** A convention folder's default exports, with their literal names kept for typed lookups. */
|
|
311
|
+
function definitionList(files, prefix, pkg, typeName, exportName) {
|
|
312
|
+
const imports = files.map((f) => `import ${identifier(f, prefix)} from "${specifier(f)}";`);
|
|
313
|
+
const names = files.map((f) => identifier(f, prefix));
|
|
314
|
+
return `${HEADER}import type { ${typeName} } from "${pkg}";
|
|
315
|
+
${imports.join("\n")}${imports.length ? "\n" : ""}
|
|
316
|
+
// biome-ignore lint/suspicious/noExplicitAny: every definition, whatever its data
|
|
317
|
+
export const ${exportName} = [${names.join(", ")}] as const satisfies readonly ${typeName}<string, any>[];
|
|
318
|
+
`;
|
|
319
|
+
}
|
|
242
320
|
/** What the app's installed (or ejected) @tatara/* packages contribute, by package name. */
|
|
243
321
|
async function contributions(project, kind) {
|
|
244
322
|
const pkg = JSON.parse(await readFile(join(project.root, "package.json"), "utf8"));
|
|
@@ -318,12 +396,7 @@ async function sync(project = loadProject()) {
|
|
|
318
396
|
}), written);
|
|
319
397
|
if (project.has("jobs")) {
|
|
320
398
|
const consumers = await find(root, dirs.map((d) => `${d}/consumers/*.ts`));
|
|
321
|
-
await write(join(out, "consumers.ts"),
|
|
322
|
-
prefix: "consumer",
|
|
323
|
-
typeImport: "import type { ConsumerDefinition } from \"@tatara/jobs\";",
|
|
324
|
-
typeName: "ConsumerDefinition",
|
|
325
|
-
exportName: "consumers"
|
|
326
|
-
}), written);
|
|
399
|
+
await write(join(out, "consumers.ts"), consumerList(consumers), written);
|
|
327
400
|
const schedules = await find(root, dirs.map((d) => `${d}/schedules/*.ts`));
|
|
328
401
|
await write(join(out, "schedules.ts"), listModule(schedules, {
|
|
329
402
|
prefix: "schedule",
|
|
@@ -332,6 +405,18 @@ async function sync(project = loadProject()) {
|
|
|
332
405
|
exportName: "schedules"
|
|
333
406
|
}), written);
|
|
334
407
|
}
|
|
408
|
+
if (project.has("email")) {
|
|
409
|
+
const emails = await find(root, ["src/modules/*/emails/*.ts"]);
|
|
410
|
+
await write(join(out, "emails.ts"), definitionList(emails, "email", "@tatara/email", "EmailDefinition", "emails"), written);
|
|
411
|
+
}
|
|
412
|
+
if (project.has("notifications")) {
|
|
413
|
+
const notifications = await find(root, ["src/modules/*/notifications/*.ts"]);
|
|
414
|
+
await write(join(out, "notifications.ts"), definitionList(notifications, "notification", "@tatara/notifications", "NotificationDefinition", "notifications"), written);
|
|
415
|
+
}
|
|
416
|
+
if (project.has("realtime")) {
|
|
417
|
+
const channels = await find(root, ["src/modules/*/channels/*.ts"]);
|
|
418
|
+
await write(join(out, "channels.ts"), definitionList(channels, "channel", "@tatara/realtime", "ChannelDefinition", "channels"), written);
|
|
419
|
+
}
|
|
335
420
|
if (project.has("authz")) {
|
|
336
421
|
const permissions = await find(root, ["src/modules/*/permissions/*.permissions.ts"]);
|
|
337
422
|
const imports = permissions.map((f) => `import ${identifier(f, "permissions")} from "${specifier(f)}";`);
|
|
@@ -371,6 +456,154 @@ export type PermissionKey =
|
|
|
371
456
|
return { written };
|
|
372
457
|
}
|
|
373
458
|
//#endregion
|
|
459
|
+
//#region src/commands/routes.ts
|
|
460
|
+
/** Computes the access label for a route based on its security requirements. */
|
|
461
|
+
function parseAccessLabel(security, _securitySchemes, tags = []) {
|
|
462
|
+
if (tags.includes("Auth")) return "better-auth";
|
|
463
|
+
if (!security || security.length === 0) return "public";
|
|
464
|
+
const labels = [];
|
|
465
|
+
for (const requirement of security) {
|
|
466
|
+
const schemes = Object.keys(requirement);
|
|
467
|
+
if (schemes.length === 0) continue;
|
|
468
|
+
const schemeName = schemes[0] ?? "";
|
|
469
|
+
const roles = requirement[schemeName] ?? [];
|
|
470
|
+
if (schemeName === "bearerAuth" && roles.length > 0) labels.push(roles.join(", "));
|
|
471
|
+
else if (schemeName === "bearerAuth") labels.push("authenticated");
|
|
472
|
+
else labels.push(schemeName);
|
|
473
|
+
}
|
|
474
|
+
return labels.length > 1 ? labels.join(" OR ") : labels[0] ?? "authenticated";
|
|
475
|
+
}
|
|
476
|
+
/** Every app's OpenAPI document, as served at /openapi.json (with better-auth's merged in). */
|
|
477
|
+
async function loadDocuments(project) {
|
|
478
|
+
await sync(project);
|
|
479
|
+
const apps = [["api", "#apps/api/app"], ...project.has("admin") ? [["admin", "#apps/admin/app"]] : []];
|
|
480
|
+
const code = `
|
|
481
|
+
const out = {};
|
|
482
|
+
for (const [name, spec] of ${JSON.stringify(apps)}) {
|
|
483
|
+
const { app } = await import(spec);
|
|
484
|
+
out[name] = await (await app.request("/openapi.json")).json();
|
|
485
|
+
}
|
|
486
|
+
// exit only once a large document has gone through the pipe
|
|
487
|
+
process.stdout.write(JSON.stringify(out), () => process.exit(0));
|
|
488
|
+
`;
|
|
489
|
+
return new Promise((resolve, reject) => {
|
|
490
|
+
const child = spawn(process.execPath, [
|
|
491
|
+
...NODE_FLAGS,
|
|
492
|
+
"--input-type=module",
|
|
493
|
+
"-e",
|
|
494
|
+
code
|
|
495
|
+
], {
|
|
496
|
+
cwd: project.root,
|
|
497
|
+
env: {
|
|
498
|
+
...process.env,
|
|
499
|
+
LOG_LEVEL: "silent"
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
let stdout = "";
|
|
503
|
+
let stderr = "";
|
|
504
|
+
child.stdout.on("data", (d) => {
|
|
505
|
+
stdout += d.toString();
|
|
506
|
+
});
|
|
507
|
+
child.stderr.on("data", (d) => {
|
|
508
|
+
stderr += d.toString();
|
|
509
|
+
});
|
|
510
|
+
child.on("exit", (code) => {
|
|
511
|
+
if (code !== 0) return reject(/* @__PURE__ */ new Error(`Could not load the apps:\n${stderr}`));
|
|
512
|
+
resolve(JSON.parse(stdout));
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
/** Lists every app's routes with what protects them. */
|
|
517
|
+
async function listRoutes(project) {
|
|
518
|
+
const out = [];
|
|
519
|
+
for (const [app, doc] of Object.entries(await loadDocuments(project))) {
|
|
520
|
+
const schemes = doc.components?.securitySchemes ?? {};
|
|
521
|
+
for (const [path, item] of Object.entries(doc.paths ?? {})) for (const [method, op] of Object.entries(item)) out.push({
|
|
522
|
+
app,
|
|
523
|
+
method: method.toUpperCase(),
|
|
524
|
+
path,
|
|
525
|
+
summary: op.summary || op.description || "",
|
|
526
|
+
access: parseAccessLabel(op.security, schemes, op.tags)
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
function printRoutes(routes) {
|
|
532
|
+
const rows = routes.map((r) => [
|
|
533
|
+
r.app,
|
|
534
|
+
r.method,
|
|
535
|
+
r.path,
|
|
536
|
+
r.access,
|
|
537
|
+
r.summary
|
|
538
|
+
]);
|
|
539
|
+
const widths = [
|
|
540
|
+
0,
|
|
541
|
+
1,
|
|
542
|
+
2,
|
|
543
|
+
3
|
|
544
|
+
].map((i) => Math.max(...rows.map((r) => (r[i] ?? "").length), 4));
|
|
545
|
+
for (const r of rows) {
|
|
546
|
+
const [app = "", method = "", path = "", access = "", summary = ""] = r;
|
|
547
|
+
console.log(`${pc.dim(app.padEnd(widths[0] ?? 0))} ${pc.bold(method.padEnd(widths[1] ?? 0))} ${path.padEnd(widths[2] ?? 0)} ${(access === "public" ? pc.yellow : pc.green)(access.padEnd(widths[3] ?? 0))} ${pc.dim(summary)}`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/commands/contracts.ts
|
|
552
|
+
async function openapiTypescript(project, input, output) {
|
|
553
|
+
const [cmd = "npx", ...args] = pmCommands(project.config.packageManager).dlx;
|
|
554
|
+
const spec = `openapi-typescript@${versionOf("openapi-typescript")}`;
|
|
555
|
+
const res = await x(cmd, [
|
|
556
|
+
...args,
|
|
557
|
+
spec,
|
|
558
|
+
input,
|
|
559
|
+
"-o",
|
|
560
|
+
output
|
|
561
|
+
], {
|
|
562
|
+
nodeOptions: { cwd: project.root },
|
|
563
|
+
throwOnError: false
|
|
564
|
+
});
|
|
565
|
+
if (res.exitCode !== 0) throw new Error(`openapi-typescript failed:\n${res.stderr || res.stdout}`);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Writes each app's OpenAPI document and its TypeScript types, for typed clients:
|
|
569
|
+
* `createClient<paths>({ baseUrl })` from openapi-fetch, with `paths` from `<app>.ts`.
|
|
570
|
+
*/
|
|
571
|
+
async function writeContracts(project, options = {}) {
|
|
572
|
+
const out = options.out ?? "contracts";
|
|
573
|
+
const generate = options.generateTypes ?? ((input, output) => openapiTypescript(project, input, output));
|
|
574
|
+
const docs = await loadDocuments(project);
|
|
575
|
+
const dir = options.check ? await mkdtemp(join(tmpdir(), "tatara-contracts-")) : join(project.root, out);
|
|
576
|
+
await mkdir(dir, { recursive: true });
|
|
577
|
+
const written = [];
|
|
578
|
+
try {
|
|
579
|
+
for (const [app, doc] of Object.entries(docs)) {
|
|
580
|
+
const json = join(dir, `${app}.openapi.json`);
|
|
581
|
+
await writeFile(json, `${JSON.stringify(doc, null, 2)}\n`);
|
|
582
|
+
await generate(json, join(dir, `${app}.ts`), project.root);
|
|
583
|
+
written.push(`${out}/${app}.openapi.json`, `${out}/${app}.ts`);
|
|
584
|
+
}
|
|
585
|
+
if (!options.check) return {
|
|
586
|
+
written,
|
|
587
|
+
stale: []
|
|
588
|
+
};
|
|
589
|
+
const stale = [];
|
|
590
|
+
for (const file of written) {
|
|
591
|
+
const name = file.slice(out.length + 1);
|
|
592
|
+
const fresh = await readFile(join(dir, name), "utf8");
|
|
593
|
+
if (await readFile(join(project.root, file), "utf8").catch(() => void 0) !== fresh) stale.push(file);
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
written: [],
|
|
597
|
+
stale
|
|
598
|
+
};
|
|
599
|
+
} finally {
|
|
600
|
+
if (options.check) await rm(dir, {
|
|
601
|
+
recursive: true,
|
|
602
|
+
force: true
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
374
607
|
//#region src/commands/db.ts
|
|
375
608
|
var DbError = class extends Error {};
|
|
376
609
|
function requireDb(project) {
|
|
@@ -415,30 +648,6 @@ async function dbStudio(project) {
|
|
|
415
648
|
return runBin(project, "prisma", ["studio"]);
|
|
416
649
|
}
|
|
417
650
|
//#endregion
|
|
418
|
-
//#region src/pm.ts
|
|
419
|
-
function pmCommands(pm) {
|
|
420
|
-
switch (pm) {
|
|
421
|
-
case "pnpm": return {
|
|
422
|
-
install: ["pnpm", "install"],
|
|
423
|
-
run: "pnpm",
|
|
424
|
-
exec: "pnpm exec",
|
|
425
|
-
lockfile: "pnpm-lock.yaml"
|
|
426
|
-
};
|
|
427
|
-
case "npm": return {
|
|
428
|
-
install: ["npm", "install"],
|
|
429
|
-
run: "npm run",
|
|
430
|
-
exec: "npx",
|
|
431
|
-
lockfile: "package-lock.json"
|
|
432
|
-
};
|
|
433
|
-
case "bun": return {
|
|
434
|
-
install: ["bun", "install"],
|
|
435
|
-
run: "bun run",
|
|
436
|
-
exec: "bunx",
|
|
437
|
-
lockfile: "bun.lock"
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
//#endregion
|
|
442
651
|
//#region src/commands/verify.ts
|
|
443
652
|
const VERIFY_STEPS = [
|
|
444
653
|
"lint",
|
|
@@ -519,7 +728,7 @@ function prefixed(child, label) {
|
|
|
519
728
|
});
|
|
520
729
|
}
|
|
521
730
|
}
|
|
522
|
-
const CONVENTION = /\/(routes|commands|consumers|schedules|permissions|db)\/[^/]+\.ts$/;
|
|
731
|
+
const CONVENTION = /\/(routes|commands|consumers|schedules|permissions|db|emails|notifications|channels)\/[^/]+\.ts$/;
|
|
523
732
|
/** Re-runs `tatara sync` when convention files appear or disappear, so --watch picks them up. */
|
|
524
733
|
function watchConventions(project) {
|
|
525
734
|
const watchers = [];
|
|
@@ -831,40 +1040,30 @@ async function eject(project, name, opts = {}) {
|
|
|
831
1040
|
]) await opts.formatFile(file, project);
|
|
832
1041
|
return report;
|
|
833
1042
|
}
|
|
834
|
-
//#endregion
|
|
835
|
-
//#region src/paths.ts
|
|
836
|
-
/** Package root: one level above both src/ (dev) and dist/ (published, bundled). */
|
|
837
|
-
const PACKAGE_ROOT = resolve(import.meta.dirname, "..");
|
|
838
|
-
const TEMPLATES_DIR = resolve(PACKAGE_ROOT, "templates");
|
|
839
|
-
function cliVersion() {
|
|
840
|
-
return JSON.parse(readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf8")).version;
|
|
841
|
-
}
|
|
842
|
-
let versions;
|
|
843
|
-
/** Exact versions used in generated apps (templates/versions.json). */
|
|
844
|
-
function templateVersions() {
|
|
845
|
-
versions ??= JSON.parse(readFileSync(resolve(TEMPLATES_DIR, "versions.json"), "utf8"));
|
|
846
|
-
return versions;
|
|
847
|
-
}
|
|
848
|
-
/** `TATARA_PACKAGE_SPECS='{"@tatara/core":"file:…"}'` installs local builds (e2e, bench). */
|
|
849
|
-
function packageSpecOverride(pkg) {
|
|
850
|
-
const raw = process.env.TATARA_PACKAGE_SPECS;
|
|
851
|
-
if (!raw) return void 0;
|
|
852
|
-
return JSON.parse(raw)[pkg];
|
|
853
|
-
}
|
|
854
|
-
function versionOf(pkg) {
|
|
855
|
-
const override = packageSpecOverride(pkg);
|
|
856
|
-
if (override) return override;
|
|
857
|
-
if (pkg === "@tatara/cli") return process.env.TATARA_CLI_SPEC ?? `^${cliVersion()}`;
|
|
858
|
-
const version = templateVersions()[pkg];
|
|
859
|
-
if (!version) throw new Error(`templates/versions.json has no version for "${pkg}"`);
|
|
860
|
-
return pkg.startsWith("@tatara/") ? `^${version}` : version;
|
|
861
|
-
}
|
|
862
1043
|
const authz = {
|
|
863
1044
|
id: "authz",
|
|
864
1045
|
kind: "addon",
|
|
865
1046
|
label: "Permission system",
|
|
866
1047
|
dependencies: ["@tatara/authz"],
|
|
867
1048
|
slots: [
|
|
1049
|
+
{
|
|
1050
|
+
file: "src/core/authz/index.ts",
|
|
1051
|
+
slot: "options",
|
|
1052
|
+
unless: ["auth-orgs"],
|
|
1053
|
+
content: "singleTenant: \"default\","
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
file: "src/modules/users/routes/authorization.routes.ts",
|
|
1057
|
+
slot: "route-options",
|
|
1058
|
+
when: ["auth-orgs"],
|
|
1059
|
+
content: "use: [requireOrg()],"
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
file: "src/modules/users/routes/authorization.routes.ts",
|
|
1063
|
+
slot: "imports",
|
|
1064
|
+
when: ["auth-orgs"],
|
|
1065
|
+
content: "import { requireOrg } from \"#modules/users/services/auth\";"
|
|
1066
|
+
},
|
|
868
1067
|
{
|
|
869
1068
|
file: "tests/helpers/auth.ts",
|
|
870
1069
|
slot: "imports",
|
|
@@ -877,14 +1076,15 @@ const authz = {
|
|
|
877
1076
|
},
|
|
878
1077
|
{
|
|
879
1078
|
file: "tests/helpers/auth.ts",
|
|
880
|
-
slot: "
|
|
881
|
-
|
|
882
|
-
content: "
|
|
1079
|
+
slot: "as-user",
|
|
1080
|
+
unless: ["auth-orgs"],
|
|
1081
|
+
content: "await applyTestAccess(user.userId, options);"
|
|
883
1082
|
},
|
|
884
1083
|
{
|
|
885
1084
|
file: "tests/helpers/auth.ts",
|
|
886
1085
|
slot: "as-user",
|
|
887
|
-
|
|
1086
|
+
when: ["auth-orgs"],
|
|
1087
|
+
content: "await applyTestAccess(user.userId, { ...options, orgId: user.orgId });"
|
|
888
1088
|
},
|
|
889
1089
|
{
|
|
890
1090
|
file: "AGENTS.md",
|
|
@@ -901,22 +1101,44 @@ const authz = {
|
|
|
901
1101
|
- Effective permissions = role grants (assigned roles + built-in roles whose key matches an
|
|
902
1102
|
identity-provider role) + grant overlays − deny overlays. Deny always wins.
|
|
903
1103
|
- Custom roles and grant overlays can never grant \`role\`, \`member\` or \`audit_log\`.
|
|
904
|
-
- Built-in roles live in \`src/core/authz/system-roles.ts\`.
|
|
905
|
-
|
|
1104
|
+
- Built-in roles live in \`src/core/authz/system-roles.ts\`. \`authz:sync-permissions\` brings the database
|
|
1105
|
+
in line with them; \`deploy\` runs it every deploy, \`db:seed\` locally.
|
|
906
1106
|
- In tests: \`asUser({ permissions: ["project:read"] })\` or \`asUser({ systemRoles: ["admin"] })\`.
|
|
907
1107
|
`
|
|
908
1108
|
}
|
|
909
1109
|
]
|
|
910
1110
|
};
|
|
1111
|
+
const REDIS_SERVICE = {
|
|
1112
|
+
image: "redis:8-alpine",
|
|
1113
|
+
ports: ["${REDIS_PORT:-6379}:6379"],
|
|
1114
|
+
healthcheck: {
|
|
1115
|
+
test: [
|
|
1116
|
+
"CMD",
|
|
1117
|
+
"redis-cli",
|
|
1118
|
+
"ping"
|
|
1119
|
+
],
|
|
1120
|
+
interval: "2s",
|
|
1121
|
+
timeout: "3s",
|
|
1122
|
+
retries: 30
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
const jobsDriver = (drivers) => ({
|
|
1126
|
+
key: "JOBS_DRIVER",
|
|
1127
|
+
schema: `localDefault(z.enum(${JSON.stringify(drivers)}), "bullmq")`,
|
|
1128
|
+
example: "bullmq",
|
|
1129
|
+
test: "inline",
|
|
1130
|
+
comment: `Where jobs wait: ${drivers.join(", ")} (inline runs them where they're sent, no worker)`
|
|
1131
|
+
});
|
|
911
1132
|
const jobs = {
|
|
912
1133
|
id: "jobs",
|
|
913
1134
|
kind: "addon",
|
|
914
|
-
label: "Jobs + cron (BullMQ)",
|
|
1135
|
+
label: "Jobs + cron (BullMQ, Postgres or Kafka)",
|
|
915
1136
|
dependencies: [
|
|
916
1137
|
"@tatara/jobs",
|
|
917
1138
|
"bullmq",
|
|
918
1139
|
"ioredis",
|
|
919
|
-
"croner"
|
|
1140
|
+
"croner",
|
|
1141
|
+
"kafkajs"
|
|
920
1142
|
],
|
|
921
1143
|
allowBuilds: ["msgpackr-extract"],
|
|
922
1144
|
scripts: {
|
|
@@ -946,28 +1168,117 @@ const jobs = {
|
|
|
946
1168
|
key: "WORKER_CONCURRENCY",
|
|
947
1169
|
schema: "z.coerce.number().int().positive().default(5)",
|
|
948
1170
|
example: "5"
|
|
1171
|
+
},
|
|
1172
|
+
{
|
|
1173
|
+
key: "KAFKA_BROKERS",
|
|
1174
|
+
schema: "z.string().optional()",
|
|
1175
|
+
example: "",
|
|
1176
|
+
comment: "Comma-separated brokers, for JOBS_DRIVER=kafka"
|
|
1177
|
+
},
|
|
1178
|
+
{
|
|
1179
|
+
key: "KAFKA_CLIENT_ID",
|
|
1180
|
+
schema: "z.string().default(\"__APP_NAME__\")",
|
|
1181
|
+
example: "__APP_NAME__"
|
|
1182
|
+
},
|
|
1183
|
+
{
|
|
1184
|
+
key: "KAFKA_SSL",
|
|
1185
|
+
schema: "z.stringbool().default(false)",
|
|
1186
|
+
example: "false"
|
|
1187
|
+
},
|
|
1188
|
+
{
|
|
1189
|
+
key: "KAFKA_SASL_USERNAME",
|
|
1190
|
+
schema: "z.string().optional()",
|
|
1191
|
+
example: ""
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
key: "KAFKA_SASL_PASSWORD",
|
|
1195
|
+
schema: "z.string().optional()",
|
|
1196
|
+
example: ""
|
|
1197
|
+
},
|
|
1198
|
+
{
|
|
1199
|
+
key: "SCHEDULE_TRIGGER_SECRET",
|
|
1200
|
+
schema: "z.string().min(16).optional()",
|
|
1201
|
+
example: "",
|
|
1202
|
+
comment: "Lets a platform scheduler POST /internal/schedules/<name> with Authorization: Bearer <secret>; unset, the route doesn't exist"
|
|
949
1203
|
}
|
|
950
1204
|
],
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1205
|
+
variants: [{
|
|
1206
|
+
when: ["postgres"],
|
|
1207
|
+
env: [jobsDriver([
|
|
1208
|
+
"bullmq",
|
|
1209
|
+
"postgres",
|
|
1210
|
+
"kafka",
|
|
1211
|
+
"inline"
|
|
1212
|
+
])]
|
|
1213
|
+
}, {
|
|
1214
|
+
unless: ["postgres"],
|
|
1215
|
+
env: [jobsDriver([
|
|
1216
|
+
"bullmq",
|
|
1217
|
+
"kafka",
|
|
1218
|
+
"inline"
|
|
1219
|
+
])]
|
|
1220
|
+
}],
|
|
1221
|
+
compose: { redis: REDIS_SERVICE },
|
|
965
1222
|
slots: [
|
|
966
1223
|
{
|
|
967
1224
|
file: "tsdown.config.ts",
|
|
968
1225
|
slot: "entries",
|
|
969
1226
|
content: "\"apps/worker/index\": \"src/apps/worker/index.ts\",\n\"apps/cron/index\": \"src/apps/cron/index.ts\","
|
|
970
1227
|
},
|
|
1228
|
+
{
|
|
1229
|
+
file: "src/apps/http.ts",
|
|
1230
|
+
slot: "imports",
|
|
1231
|
+
content: "import { triggerSchedule } from \"@tatara/jobs\";\nimport { scheduleRunner } from \"#apps/cron/runner\";"
|
|
1232
|
+
},
|
|
1233
|
+
{
|
|
1234
|
+
file: "src/apps/http.ts",
|
|
1235
|
+
slot: "timeout-paths",
|
|
1236
|
+
content: "\"/internal/schedules/*\": 15 * 60 * 1000,"
|
|
1237
|
+
},
|
|
1238
|
+
{
|
|
1239
|
+
file: "src/apps/http.ts",
|
|
1240
|
+
slot: "routes",
|
|
1241
|
+
order: 3,
|
|
1242
|
+
content: [
|
|
1243
|
+
"if (name === \"api\" && env.SCHEDULE_TRIGGER_SECRET) {",
|
|
1244
|
+
" const secret = env.SCHEDULE_TRIGGER_SECRET;",
|
|
1245
|
+
" app.post(\"/internal/schedules/:name\", async (c) => {",
|
|
1246
|
+
" const { status, body } = await triggerSchedule(c.req.param(\"name\"), c.req.header(\"authorization\"), {",
|
|
1247
|
+
" ...scheduleRunner,",
|
|
1248
|
+
" logger: c.get(\"logger\"),",
|
|
1249
|
+
" secret,",
|
|
1250
|
+
" });",
|
|
1251
|
+
" return c.json(body, status);",
|
|
1252
|
+
" });",
|
|
1253
|
+
"}"
|
|
1254
|
+
].join("\n")
|
|
1255
|
+
},
|
|
1256
|
+
{
|
|
1257
|
+
file: "src/apps/cron/runner.ts",
|
|
1258
|
+
slot: "imports",
|
|
1259
|
+
when: ["auth-orgs"],
|
|
1260
|
+
content: "import { auth } from \"#modules/users/services/auth\";"
|
|
1261
|
+
},
|
|
1262
|
+
{
|
|
1263
|
+
file: "src/apps/cron/runner.ts",
|
|
1264
|
+
slot: "options",
|
|
1265
|
+
when: ["auth-orgs"],
|
|
1266
|
+
content: [
|
|
1267
|
+
"async *orgs() {",
|
|
1268
|
+
" const { adapter } = await auth.$context;",
|
|
1269
|
+
" for (let offset = 0; ; offset += 100) {",
|
|
1270
|
+
" const page = await adapter.findMany<{ id: string }>({",
|
|
1271
|
+
" model: \"organization\",",
|
|
1272
|
+
" limit: 100,",
|
|
1273
|
+
" offset,",
|
|
1274
|
+
" sortBy: { field: \"id\", direction: \"asc\" },",
|
|
1275
|
+
" });",
|
|
1276
|
+
" for (const org of page) yield org.id;",
|
|
1277
|
+
" if (page.length < 100) return;",
|
|
1278
|
+
" }",
|
|
1279
|
+
"},"
|
|
1280
|
+
].join("\n")
|
|
1281
|
+
},
|
|
971
1282
|
{
|
|
972
1283
|
file: "tests/setup.ts",
|
|
973
1284
|
slot: "setup",
|
|
@@ -984,68 +1295,578 @@ const jobs = {
|
|
|
984
1295
|
{
|
|
985
1296
|
file: "AGENTS.md",
|
|
986
1297
|
slot: "sections",
|
|
987
|
-
content: `## Jobs and schedules
|
|
1298
|
+
content: `## Jobs and schedules
|
|
1299
|
+
|
|
1300
|
+
- Background work: \`enqueue(topic, payload, { orgId })\` from \`#core/jobs/index\`, typed by the app's
|
|
1301
|
+
consumers. A module handles a topic in \`consumers/<name>.ts\`
|
|
1302
|
+
(\`defineConsumer({ topic, schema, attempts?, backoffMs?, handle, onDeadLetter? })\` from \`@tatara/jobs\`).
|
|
1303
|
+
The consumer validates the payload; a payload that never validates goes straight to dead.
|
|
1304
|
+
Handlers must be idempotent: a failure retries with doubling backoff, and \`onDeadLetter\` runs once,
|
|
1305
|
+
after the last attempt.
|
|
1306
|
+
- \`JOBS_DRIVER\` picks where jobs wait: \`bullmq\` (Redis, default), \`postgres\` (a \`tatara.jobs\` table;
|
|
1307
|
+
pass \`{ executor: inTransaction(tx) }\` and the job commits with your writes, a transactional
|
|
1308
|
+
outbox), \`kafka\`, or \`inline\` (tests: \`(jobs.driver as InlineDriver).drain()\`).
|
|
1309
|
+
- Recurring work: \`schedules/<name>.ts\` (\`defineSchedule({ name, cron, run })\`); \`run({ forEachOrg })\`
|
|
1310
|
+
visits every org, one failure at a time. A lock (the cache) makes each tick run once however it
|
|
1311
|
+
was started: \`apps/cron\`, \`tatara run schedule:run --name <name>\`, or
|
|
1312
|
+
\`POST /internal/schedules/<name>\` with \`SCHEDULE_TRIGGER_SECRET\` (for a platform scheduler).
|
|
1313
|
+
- Processes: \`apps/worker\` runs consumers, \`apps/cron\` runs schedules. \`dev\` starts both.
|
|
1314
|
+
`
|
|
1315
|
+
}
|
|
1316
|
+
]
|
|
1317
|
+
};
|
|
1318
|
+
const cacheDriver = (drivers) => ({
|
|
1319
|
+
key: "CACHE_DRIVER",
|
|
1320
|
+
schema: `localDefault(z.enum(${JSON.stringify(drivers)}), "redis")`,
|
|
1321
|
+
example: "redis",
|
|
1322
|
+
test: "memory",
|
|
1323
|
+
comment: `Where the cache lives: ${drivers.join(", ")} (memory is per process)`
|
|
1324
|
+
});
|
|
1325
|
+
const cache = {
|
|
1326
|
+
id: "cache",
|
|
1327
|
+
kind: "addon",
|
|
1328
|
+
label: "Cache (Redis, Postgres or memory)",
|
|
1329
|
+
dependencies: ["@tatara/cache", "ioredis"],
|
|
1330
|
+
env: [{
|
|
1331
|
+
key: "REDIS_PORT",
|
|
1332
|
+
schema: "",
|
|
1333
|
+
example: "6379",
|
|
1334
|
+
composeOnly: true,
|
|
1335
|
+
comment: "Host port for local Redis"
|
|
1336
|
+
}, {
|
|
1337
|
+
key: "REDIS_URL",
|
|
1338
|
+
schema: "z.url()",
|
|
1339
|
+
example: "redis://localhost:6379/0"
|
|
1340
|
+
}],
|
|
1341
|
+
variants: [{
|
|
1342
|
+
when: ["postgres"],
|
|
1343
|
+
env: [cacheDriver([
|
|
1344
|
+
"redis",
|
|
1345
|
+
"postgres",
|
|
1346
|
+
"memory"
|
|
1347
|
+
])]
|
|
1348
|
+
}, {
|
|
1349
|
+
unless: ["postgres"],
|
|
1350
|
+
env: [cacheDriver(["redis", "memory"])]
|
|
1351
|
+
}],
|
|
1352
|
+
compose: { redis: REDIS_SERVICE },
|
|
1353
|
+
slots: [{
|
|
1354
|
+
file: "AGENTS.md",
|
|
1355
|
+
slot: "sections",
|
|
1356
|
+
content: `## Cache
|
|
1357
|
+
|
|
1358
|
+
- \`cache\` from \`#core/cache/index\`: \`get/set/del\`, \`wrap(key, ttlMs, fn)\` (cache-aside; a failing
|
|
1359
|
+
store falls back to \`fn\`), \`increment\` (fixed-window counters), \`add\`/\`take\` (single-use values)
|
|
1360
|
+
and \`lock(key, ttlMs, fn)\` (one holder across processes).
|
|
1361
|
+
- Scope keys with \`orgKey(orgId, …)\` / \`userKey(userId, …)\` from \`@tatara/cache\`, and drop them all
|
|
1362
|
+
with \`cache.delPrefix(orgKey(orgId))\`.
|
|
1363
|
+
- Rate-limit a route with \`middleware: [limitRequests({ name, windowMs, max })]\` from
|
|
1364
|
+
\`#core/http/rate-limit\` (per caller IP, or per \`key\`); past the limit it answers 429 RATE_LIMITED.
|
|
1365
|
+
- \`CACHE_DRIVER\` picks the store (Redis by default; tests use memory). Values must be JSON.
|
|
1366
|
+
`
|
|
1367
|
+
}]
|
|
1368
|
+
};
|
|
1369
|
+
const admin = {
|
|
1370
|
+
id: "admin",
|
|
1371
|
+
kind: "addon",
|
|
1372
|
+
label: "Admin API app",
|
|
1373
|
+
scripts: { "start:admin": "node dist/apps/admin/server.mjs" },
|
|
1374
|
+
env: [
|
|
1375
|
+
{
|
|
1376
|
+
key: "ADMIN_PORT",
|
|
1377
|
+
schema: "z.coerce.number().int().default(3001)",
|
|
1378
|
+
example: "3001"
|
|
1379
|
+
},
|
|
1380
|
+
{
|
|
1381
|
+
key: "ADMIN_URL",
|
|
1382
|
+
schema: "localDefault(z.url(), \"http://localhost:3001\")",
|
|
1383
|
+
example: "http://localhost:3001",
|
|
1384
|
+
comment: "Where the admin app is served; staff sign in there"
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
key: "SEED_STAFF_EMAIL",
|
|
1388
|
+
schema: "z.email().default(\"staff@example.com\")",
|
|
1389
|
+
example: "staff@example.com",
|
|
1390
|
+
comment: "Development staff account created by `db:seed` (never in production)"
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
key: "SEED_STAFF_PASSWORD",
|
|
1394
|
+
schema: "z.string().min(10).optional()",
|
|
1395
|
+
example: "",
|
|
1396
|
+
secret: true,
|
|
1397
|
+
comment: "Password for the development staff account (generated by tatara new)"
|
|
1398
|
+
}
|
|
1399
|
+
],
|
|
1400
|
+
slots: [
|
|
1401
|
+
{
|
|
1402
|
+
file: "src/core/http/routes.ts",
|
|
1403
|
+
slot: "app-names",
|
|
1404
|
+
content: "\"admin\","
|
|
1405
|
+
},
|
|
1406
|
+
{
|
|
1407
|
+
file: "tsdown.config.ts",
|
|
1408
|
+
slot: "entries",
|
|
1409
|
+
content: "\"apps/admin/server\": \"src/apps/admin/server.ts\","
|
|
1410
|
+
},
|
|
1411
|
+
{
|
|
1412
|
+
file: "src/apps/http.ts",
|
|
1413
|
+
slot: "imports",
|
|
1414
|
+
content: [
|
|
1415
|
+
"import { requireRole } from \"@tatara/auth\";",
|
|
1416
|
+
"import { except } from \"hono/combine\";",
|
|
1417
|
+
"import { attachStaffPrincipal, staffAuth } from \"#modules/staff/services/auth\";"
|
|
1418
|
+
].join("\n")
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
file: "src/apps/http.ts",
|
|
1422
|
+
slot: "middleware",
|
|
1423
|
+
order: 50,
|
|
1424
|
+
content: "app.use(name === \"admin\" ? attachStaffPrincipal : attachPrincipal);"
|
|
1425
|
+
},
|
|
1426
|
+
{
|
|
1427
|
+
file: "src/apps/http.ts",
|
|
1428
|
+
slot: "routes",
|
|
1429
|
+
order: 2,
|
|
1430
|
+
content: "if (name === \"admin\") app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c) =>\n staffAuth.handler(withClientIp(c.req.raw, clientIp(c, { trustedHops: env.TRUSTED_PROXY_HOPS }))),\n);"
|
|
1431
|
+
},
|
|
1432
|
+
{
|
|
1433
|
+
file: "src/apps/http.ts",
|
|
1434
|
+
slot: "routes",
|
|
1435
|
+
order: 5,
|
|
1436
|
+
content: "if (name === \"admin\") app.use(`${API_PREFIX}/*`, except(`${API_PREFIX}/auth/*`, requireRole(\"staff\")));"
|
|
1437
|
+
},
|
|
1438
|
+
{
|
|
1439
|
+
file: "src/core/authz/system-roles.ts",
|
|
1440
|
+
slot: "roles",
|
|
1441
|
+
content: "{ key: \"staff\", name: \"Staff\", description: \"Internal staff: platform permissions on the admin app.\" },"
|
|
1442
|
+
},
|
|
1443
|
+
{
|
|
1444
|
+
file: "src/modules/staff/services/auth.ts",
|
|
1445
|
+
slot: "resolvers",
|
|
1446
|
+
when: ["auth-keycloak"],
|
|
1447
|
+
content: "() => (c) => keycloakPrincipal(auth, c.req.header(\"authorization\")),"
|
|
1448
|
+
},
|
|
1449
|
+
{
|
|
1450
|
+
file: "src/modules/staff/services/auth.ts",
|
|
1451
|
+
slot: "imports",
|
|
1452
|
+
when: ["auth-keycloak"],
|
|
1453
|
+
content: "import { keycloakPrincipal } from \"@tatara/keycloak\";\nimport { auth } from \"#modules/users/services/auth\";"
|
|
1454
|
+
},
|
|
1455
|
+
{
|
|
1456
|
+
file: "tests/helpers/api.ts",
|
|
1457
|
+
slot: "imports",
|
|
1458
|
+
content: "import { app as adminApp } from \"#apps/admin/app\";"
|
|
1459
|
+
},
|
|
1460
|
+
{
|
|
1461
|
+
file: "tests/helpers/api.ts",
|
|
1462
|
+
slot: "exports",
|
|
1463
|
+
content: "\n/** Calls the admin app in-process. */\nexport const adminApi = client(adminApp);"
|
|
1464
|
+
},
|
|
1465
|
+
{
|
|
1466
|
+
file: "AGENTS.md",
|
|
1467
|
+
slot: "sections",
|
|
1468
|
+
content: `## Admin app and staff
|
|
1469
|
+
|
|
1470
|
+
- \`apps/admin\` is a second HTTP app on \`ADMIN_PORT\`. Routes with \`defineRoutes({ app: "admin", … })\`
|
|
1471
|
+
are served only there, and every \`/api\` route on it needs a staff principal (role \`staff\`).
|
|
1472
|
+
- Staff are separate accounts (\`src/modules/staff\`, tables \`staff_*\`), with their own better-auth
|
|
1473
|
+
mounted at \`/api/auth\` on the admin app. They never share a table or a session with users.
|
|
1474
|
+
There's no staff sign-up; the dev account comes from \`SEED_STAFF_EMAIL\`/\`SEED_STAFF_PASSWORD\`.
|
|
1475
|
+
- Staff act in the \`platform\` org, so grant platform permissions to the built-in \`staff\` role.
|
|
1476
|
+
- Test it with \`adminApi\` from \`#tests/helpers/api\` and \`asStaff()\` from \`#tests/helpers/staff\`.
|
|
1477
|
+
`
|
|
1478
|
+
}
|
|
1479
|
+
]
|
|
1480
|
+
};
|
|
1481
|
+
const MEMBERS = `/** The org's members, a page at a time. */
|
|
1482
|
+
async function* members(orgId: string) {
|
|
1483
|
+
const { adapter } = await auth.$context;
|
|
1484
|
+
for (let offset = 0; ; offset += 100) {
|
|
1485
|
+
const page = await adapter.findMany<{ userId: string; role: string }>({
|
|
1486
|
+
model: "member",
|
|
1487
|
+
where: [{ field: "organizationId", value: orgId }],
|
|
1488
|
+
limit: 100,
|
|
1489
|
+
offset,
|
|
1490
|
+
sortBy: { field: "id", direction: "asc" },
|
|
1491
|
+
});
|
|
1492
|
+
yield* page;
|
|
1493
|
+
if (page.length < 100) return;
|
|
1494
|
+
}
|
|
1495
|
+
}`;
|
|
1496
|
+
const hasRole = "m.role.split(\",\").map((r) => r.trim()).includes(role)";
|
|
1497
|
+
const notifications = {
|
|
1498
|
+
id: "notifications",
|
|
1499
|
+
kind: "addon",
|
|
1500
|
+
label: "Notifications (in-app, email, SMS, push)",
|
|
1501
|
+
dependencies: ["@tatara/notifications"],
|
|
1502
|
+
env: [
|
|
1503
|
+
{
|
|
1504
|
+
key: "SMS_DRIVER",
|
|
1505
|
+
schema: "z.enum([\"console\", \"twilio\"]).default(\"console\")",
|
|
1506
|
+
example: "console",
|
|
1507
|
+
test: "console",
|
|
1508
|
+
comment: "How notifications send SMS; console logs them"
|
|
1509
|
+
},
|
|
1510
|
+
{
|
|
1511
|
+
key: "TWILIO_ACCOUNT_SID",
|
|
1512
|
+
schema: "z.string().optional()",
|
|
1513
|
+
example: ""
|
|
1514
|
+
},
|
|
1515
|
+
{
|
|
1516
|
+
key: "TWILIO_AUTH_TOKEN",
|
|
1517
|
+
schema: "z.string().optional()",
|
|
1518
|
+
example: ""
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
key: "TWILIO_FROM",
|
|
1522
|
+
schema: "z.string().optional()",
|
|
1523
|
+
example: "",
|
|
1524
|
+
comment: "A Twilio number, or a messaging service sid (MG…)"
|
|
1525
|
+
},
|
|
1526
|
+
{
|
|
1527
|
+
key: "PUSH_DRIVER",
|
|
1528
|
+
schema: "z.enum([\"console\", \"fcm\"]).default(\"console\")",
|
|
1529
|
+
example: "console",
|
|
1530
|
+
test: "console",
|
|
1531
|
+
comment: "How notifications push to devices; console logs them"
|
|
1532
|
+
},
|
|
1533
|
+
{
|
|
1534
|
+
key: "FCM_PROJECT_ID",
|
|
1535
|
+
schema: "z.string().optional()",
|
|
1536
|
+
example: ""
|
|
1537
|
+
},
|
|
1538
|
+
{
|
|
1539
|
+
key: "FCM_CLIENT_EMAIL",
|
|
1540
|
+
schema: "z.string().optional()",
|
|
1541
|
+
example: ""
|
|
1542
|
+
},
|
|
1543
|
+
{
|
|
1544
|
+
key: "FCM_PRIVATE_KEY",
|
|
1545
|
+
schema: "z.string().optional()",
|
|
1546
|
+
example: "",
|
|
1547
|
+
comment: "The Firebase service account's private key (\\n escapes are fine)"
|
|
1548
|
+
}
|
|
1549
|
+
],
|
|
1550
|
+
slots: [
|
|
1551
|
+
{
|
|
1552
|
+
file: "src/modules/notifications/services/notifier.ts",
|
|
1553
|
+
slot: "recipients",
|
|
1554
|
+
unless: ["auth-orgs"],
|
|
1555
|
+
content: [
|
|
1556
|
+
"// without organizations there is no org to look people up in: notify users by id",
|
|
1557
|
+
"recipients: {",
|
|
1558
|
+
" role() {",
|
|
1559
|
+
" throw new Error(\"Notifying by role needs organizations (the orgs auth method)\");",
|
|
1560
|
+
" },",
|
|
1561
|
+
" permission() {",
|
|
1562
|
+
" throw new Error(\"Notifying by permission needs organizations and the authz add-on\");",
|
|
1563
|
+
" },",
|
|
1564
|
+
"},"
|
|
1565
|
+
].join("\n")
|
|
1566
|
+
},
|
|
1567
|
+
{
|
|
1568
|
+
file: "src/modules/notifications/services/notifier.ts",
|
|
1569
|
+
slot: "helpers",
|
|
1570
|
+
when: ["auth-orgs"],
|
|
1571
|
+
content: MEMBERS
|
|
1572
|
+
},
|
|
1573
|
+
{
|
|
1574
|
+
file: "src/modules/notifications/services/notifier.ts",
|
|
1575
|
+
slot: "recipients",
|
|
1576
|
+
when: ["auth-orgs"],
|
|
1577
|
+
unless: ["authz"],
|
|
1578
|
+
content: [
|
|
1579
|
+
"recipients: {",
|
|
1580
|
+
" async *role(orgId, role) {",
|
|
1581
|
+
`\t\tfor await (const m of members(orgId)) if (${hasRole}) yield m.userId;`,
|
|
1582
|
+
" },",
|
|
1583
|
+
" permission() {",
|
|
1584
|
+
" throw new Error(\"Notifying by permission needs the authz add-on\");",
|
|
1585
|
+
" },",
|
|
1586
|
+
"},"
|
|
1587
|
+
].join("\n")
|
|
1588
|
+
},
|
|
1589
|
+
{
|
|
1590
|
+
file: "src/modules/notifications/services/notifier.ts",
|
|
1591
|
+
slot: "recipients",
|
|
1592
|
+
when: ["auth-orgs", "authz"],
|
|
1593
|
+
content: [
|
|
1594
|
+
"recipients: {",
|
|
1595
|
+
" async *role(orgId, role) {",
|
|
1596
|
+
`\t\tfor await (const m of members(orgId)) if (${hasRole}) yield m.userId;`,
|
|
1597
|
+
" },",
|
|
1598
|
+
" // the same check a request makes: built-in roles, assigned roles and overlays",
|
|
1599
|
+
" async *permission(orgId, permission) {",
|
|
1600
|
+
" for await (const m of members(orgId)) {",
|
|
1601
|
+
" const who = { userId: m.userId, orgId, orgRole: m.role, roles: [], kind: \"user\" as const, claims: {} };",
|
|
1602
|
+
" if (await checkPermission(who, permission as PermissionKey)) yield m.userId;",
|
|
1603
|
+
" }",
|
|
1604
|
+
" },",
|
|
1605
|
+
"},"
|
|
1606
|
+
].join("\n")
|
|
1607
|
+
},
|
|
1608
|
+
{
|
|
1609
|
+
file: "src/modules/notifications/services/notifier.ts",
|
|
1610
|
+
slot: "imports",
|
|
1611
|
+
when: ["auth-orgs", "authz"],
|
|
1612
|
+
content: "import { checkPermission } from \"#core/authz/index\";\nimport type { PermissionKey } from \"#generated/permissions\";"
|
|
1613
|
+
},
|
|
1614
|
+
{
|
|
1615
|
+
file: "src/modules/notifications/services/notifier.ts",
|
|
1616
|
+
slot: "senders",
|
|
1617
|
+
when: ["email"],
|
|
1618
|
+
content: "email: { send: (message) => sendEmail(message) },"
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
file: "src/modules/notifications/services/notifier.ts",
|
|
1622
|
+
slot: "imports",
|
|
1623
|
+
when: ["email"],
|
|
1624
|
+
content: "import { sendEmail } from \"#core/email/send\";"
|
|
1625
|
+
},
|
|
1626
|
+
{
|
|
1627
|
+
file: "src/modules/notifications/routes/notifications.routes.ts",
|
|
1628
|
+
slot: "guard",
|
|
1629
|
+
when: ["auth-orgs"],
|
|
1630
|
+
content: "requireOrg(),"
|
|
1631
|
+
},
|
|
1632
|
+
{
|
|
1633
|
+
file: "src/modules/notifications/routes/notifications.routes.ts",
|
|
1634
|
+
slot: "imports",
|
|
1635
|
+
when: ["auth-orgs"],
|
|
1636
|
+
content: "import { requireOrg } from \"#modules/users/services/auth\";"
|
|
1637
|
+
},
|
|
1638
|
+
{
|
|
1639
|
+
file: "AGENTS.md",
|
|
1640
|
+
slot: "sections",
|
|
1641
|
+
content: `## Notifications
|
|
1642
|
+
|
|
1643
|
+
- \`notify({ orgId, to, notification, data, channels?, idempotencyKey?, executor? })\` from
|
|
1644
|
+
\`#modules/notifications/services/notifier\`. \`to\` is \`{ user }\`, \`{ users }\`, \`{ role }\` or
|
|
1645
|
+
\`{ permission }\` (role and permission need organizations; permission needs authz).
|
|
1646
|
+
- A notification is \`notifications/<name>.ts\` in a module: \`defineNotification({ name, schema, channels,
|
|
1647
|
+
inApp?, email?, sms?, push? })\`, each renderer taking \`(data, language)\`.
|
|
1648
|
+
- Delivery runs through jobs (\`notifications.fanout\`, then one \`notifications.deliver\` per user); each
|
|
1649
|
+
channel goes out once per recipient, and the same \`idempotencyKey\` never notifies anyone twice.
|
|
1650
|
+
Pass \`executor\` (with the Postgres jobs driver) to send only if your transaction commits.
|
|
1651
|
+
- The inbox: \`GET /api/notifications\` (cursor), \`/unread-count\`, \`POST /read\`, and
|
|
1652
|
+
\`POST|DELETE /push-tokens\` for devices. \`SMS_DRIVER=twilio\` and \`PUSH_DRIVER=fcm\` send for real.
|
|
1653
|
+
- In tests, jobs run inline: \`await (jobs.driver as InlineDriver).drain()\` after \`notify\`.
|
|
1654
|
+
`
|
|
1655
|
+
}
|
|
1656
|
+
]
|
|
1657
|
+
};
|
|
1658
|
+
const realtime = {
|
|
1659
|
+
id: "realtime",
|
|
1660
|
+
kind: "addon",
|
|
1661
|
+
label: "Realtime (websockets)",
|
|
1662
|
+
dependencies: [
|
|
1663
|
+
"@tatara/realtime",
|
|
1664
|
+
"socket.io",
|
|
1665
|
+
"@socket.io/redis-adapter",
|
|
1666
|
+
"@socket.io/redis-emitter",
|
|
1667
|
+
"ioredis"
|
|
1668
|
+
],
|
|
1669
|
+
devDependencies: ["socket.io-client"],
|
|
1670
|
+
env: [
|
|
1671
|
+
{
|
|
1672
|
+
key: "REALTIME_DRIVER",
|
|
1673
|
+
schema: "z.enum([\"socketio\", \"noop\"]).default(\"socketio\")",
|
|
1674
|
+
example: "socketio",
|
|
1675
|
+
comment: "noop turns websockets off (publishes do nothing)"
|
|
1676
|
+
},
|
|
1677
|
+
{
|
|
1678
|
+
key: "REALTIME_ADAPTER",
|
|
1679
|
+
schema: "localDefault(z.enum([\"redis\", \"memory\"]), \"redis\")",
|
|
1680
|
+
example: "redis",
|
|
1681
|
+
test: "memory",
|
|
1682
|
+
comment: "redis shares rooms across api processes and lets workers publish"
|
|
1683
|
+
},
|
|
1684
|
+
{
|
|
1685
|
+
key: "REALTIME_PATH",
|
|
1686
|
+
schema: "z.string().default(\"/realtime\")",
|
|
1687
|
+
example: "/realtime"
|
|
1688
|
+
},
|
|
1689
|
+
{
|
|
1690
|
+
key: "REALTIME_TICKET_TTL_SECONDS",
|
|
1691
|
+
schema: "z.coerce.number().int().positive().default(60)",
|
|
1692
|
+
example: "60"
|
|
1693
|
+
},
|
|
1694
|
+
{
|
|
1695
|
+
key: "REDIS_PORT",
|
|
1696
|
+
schema: "",
|
|
1697
|
+
example: "6379",
|
|
1698
|
+
composeOnly: true,
|
|
1699
|
+
comment: "Host port for local Redis"
|
|
1700
|
+
},
|
|
1701
|
+
{
|
|
1702
|
+
key: "REDIS_URL",
|
|
1703
|
+
schema: "z.url()",
|
|
1704
|
+
example: "redis://localhost:6379/0"
|
|
1705
|
+
}
|
|
1706
|
+
],
|
|
1707
|
+
compose: { redis: REDIS_SERVICE },
|
|
1708
|
+
slots: [
|
|
1709
|
+
{
|
|
1710
|
+
file: "src/apps/api/server.ts",
|
|
1711
|
+
slot: "imports",
|
|
1712
|
+
content: "import { realtime } from \"#core/realtime/index\";\nimport { channels } from \"#generated/channels\";"
|
|
1713
|
+
},
|
|
1714
|
+
{
|
|
1715
|
+
file: "src/apps/api/server.ts",
|
|
1716
|
+
slot: "server-options",
|
|
1717
|
+
content: [
|
|
1718
|
+
"// websockets share the api's port; an api that can't serve them shouldn't run half-working",
|
|
1719
|
+
"onServer: (server) => {",
|
|
1720
|
+
" realtime.attach(server as never, { channels }).catch((err: unknown) => {",
|
|
1721
|
+
" logger.fatal({ err }, \"realtime could not attach\");",
|
|
1722
|
+
" process.exit(1);",
|
|
1723
|
+
" });",
|
|
1724
|
+
"},"
|
|
1725
|
+
].join("\n")
|
|
1726
|
+
},
|
|
1727
|
+
{
|
|
1728
|
+
file: "src/modules/realtime/routes/realtime.routes.ts",
|
|
1729
|
+
slot: "guard",
|
|
1730
|
+
when: ["auth-orgs"],
|
|
1731
|
+
content: "requireOrg(),"
|
|
1732
|
+
},
|
|
1733
|
+
{
|
|
1734
|
+
file: "src/modules/realtime/routes/realtime.routes.ts",
|
|
1735
|
+
slot: "imports",
|
|
1736
|
+
when: ["auth-orgs"],
|
|
1737
|
+
content: "import { requireOrg } from \"#modules/users/services/auth\";"
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
file: "src/modules/notifications/services/notifier.ts",
|
|
1741
|
+
slot: "senders",
|
|
1742
|
+
when: ["notifications"],
|
|
1743
|
+
content: [
|
|
1744
|
+
"onInApp: (row) =>",
|
|
1745
|
+
" publishToUser(row.orgId, row.userId, \"notification.created\", {",
|
|
1746
|
+
" id: row.id,",
|
|
1747
|
+
" title: row.title,",
|
|
1748
|
+
" body: row.body ?? null,",
|
|
1749
|
+
" url: row.url ?? null,",
|
|
1750
|
+
" }),"
|
|
1751
|
+
].join("\n")
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
file: "src/modules/notifications/services/notifier.ts",
|
|
1755
|
+
slot: "imports",
|
|
1756
|
+
when: ["notifications"],
|
|
1757
|
+
content: "import { publishToUser } from \"#core/realtime/index\";"
|
|
1758
|
+
},
|
|
1759
|
+
{
|
|
1760
|
+
file: "AGENTS.md",
|
|
1761
|
+
slot: "sections",
|
|
1762
|
+
content: `## Realtime
|
|
988
1763
|
|
|
989
|
-
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
-
|
|
993
|
-
|
|
994
|
-
|
|
1764
|
+
- A client asks \`POST /api/realtime/ticket\` (signed in) for a single-use ticket, then connects with
|
|
1765
|
+
socket.io: \`io(API_URL, { path: "/realtime", transports: ["websocket"], auth: { ticket } })\`.
|
|
1766
|
+
A socket belongs to one user in one org, and joins that user's and that org's rooms.
|
|
1767
|
+
- Publish from any process (api, worker, cron): \`publishToUser(orgId, userId, event, payload)\`,
|
|
1768
|
+
\`publishToOrg(orgId, event, payload)\` from \`#core/realtime/index\`. \`disconnectUser\` /
|
|
1769
|
+
\`disconnectOrg\` close sockets when access changes; clients reconnect with a new ticket.
|
|
1770
|
+
- Rooms of your own: \`channels/<name>.ts\` in a module, \`defineChannel({ name: "survey.{surveyId}",
|
|
1771
|
+
events: { "answer.created": schema }, authorize(who, params) })\`. Clients \`emit("subscribe", name)\`;
|
|
1772
|
+
publish with \`publishTo(channel, { orgId, params, event, payload })\` (the payload is checked).
|
|
1773
|
+
- With notifications, every inbox entry is also pushed as \`notification.created\`.
|
|
995
1774
|
`
|
|
996
1775
|
}
|
|
997
1776
|
]
|
|
998
1777
|
};
|
|
999
|
-
const
|
|
1000
|
-
id: "
|
|
1778
|
+
const observability = {
|
|
1779
|
+
id: "observability",
|
|
1001
1780
|
kind: "addon",
|
|
1002
|
-
label: "
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1781
|
+
label: "Observability (OpenTelemetry)",
|
|
1782
|
+
dependencies: [
|
|
1783
|
+
"@tatara/observability",
|
|
1784
|
+
"@opentelemetry/api",
|
|
1785
|
+
"@opentelemetry/sdk-node",
|
|
1786
|
+
"@opentelemetry/sdk-trace",
|
|
1787
|
+
"@opentelemetry/sdk-metrics",
|
|
1788
|
+
"@opentelemetry/resources",
|
|
1789
|
+
"@opentelemetry/exporter-trace-otlp-http",
|
|
1790
|
+
"@opentelemetry/exporter-metrics-otlp-http",
|
|
1791
|
+
"@opentelemetry/auto-instrumentations-node",
|
|
1792
|
+
"@opentelemetry/instrumentation",
|
|
1793
|
+
"@hono/otel"
|
|
1794
|
+
],
|
|
1795
|
+
allowBuilds: ["protobufjs"],
|
|
1796
|
+
scripts: { start: "node --import ./dist/apps/instrument.mjs dist/apps/api/server.mjs" },
|
|
1797
|
+
variants: [{
|
|
1798
|
+
when: ["jobs"],
|
|
1799
|
+
scripts: {
|
|
1800
|
+
"start:worker": "node --import ./dist/apps/instrument.mjs dist/apps/worker/index.mjs",
|
|
1801
|
+
"start:cron": "node --import ./dist/apps/instrument.mjs dist/apps/cron/index.mjs"
|
|
1802
|
+
}
|
|
1803
|
+
}, {
|
|
1804
|
+
when: ["admin"],
|
|
1805
|
+
scripts: { "start:admin": "node --import ./dist/apps/instrument.mjs dist/apps/admin/server.mjs" }
|
|
1008
1806
|
}],
|
|
1009
|
-
|
|
1807
|
+
env: [
|
|
1010
1808
|
{
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1809
|
+
key: "OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
1810
|
+
schema: "z.url().optional()",
|
|
1811
|
+
example: "",
|
|
1812
|
+
comment: "OTLP/HTTP collector, e.g. http://localhost:4318; empty sends nothing"
|
|
1813
|
+
},
|
|
1814
|
+
{
|
|
1815
|
+
key: "OTEL_SERVICE_NAME",
|
|
1816
|
+
schema: "z.string().default(\"__APP_NAME__\")",
|
|
1817
|
+
example: "__APP_NAME__"
|
|
1014
1818
|
},
|
|
1819
|
+
{
|
|
1820
|
+
key: "OTEL_TRACES_SAMPLE_RATIO",
|
|
1821
|
+
schema: "z.coerce.number().min(0).max(1).default(1)",
|
|
1822
|
+
example: "1",
|
|
1823
|
+
comment: "Share of new traces kept (0–1)"
|
|
1824
|
+
}
|
|
1825
|
+
],
|
|
1826
|
+
slots: [
|
|
1015
1827
|
{
|
|
1016
1828
|
file: "tsdown.config.ts",
|
|
1017
1829
|
slot: "entries",
|
|
1018
|
-
content: "\"apps/
|
|
1830
|
+
content: "\"apps/instrument\": \"src/apps/instrument.ts\","
|
|
1019
1831
|
},
|
|
1020
1832
|
{
|
|
1021
|
-
file: "
|
|
1833
|
+
file: "Dockerfile",
|
|
1834
|
+
slot: "docker-runtime",
|
|
1835
|
+
order: 60,
|
|
1836
|
+
content: "ENV NODE_OPTIONS=\"--import /app/dist/apps/instrument.mjs\""
|
|
1837
|
+
},
|
|
1838
|
+
{
|
|
1839
|
+
file: "src/apps/http.ts",
|
|
1022
1840
|
slot: "imports",
|
|
1023
|
-
content: "import {
|
|
1841
|
+
content: "import { httpTracing } from \"@tatara/observability\";"
|
|
1024
1842
|
},
|
|
1025
1843
|
{
|
|
1026
|
-
file: "src/
|
|
1027
|
-
slot: "
|
|
1844
|
+
file: "src/apps/http.ts",
|
|
1845
|
+
slot: "middleware",
|
|
1028
1846
|
order: 5,
|
|
1029
|
-
content: "
|
|
1847
|
+
content: "app.use(httpTracing(env.OTEL_SERVICE_NAME));"
|
|
1030
1848
|
},
|
|
1031
1849
|
{
|
|
1032
|
-
file: "
|
|
1850
|
+
file: "src/core/logging/logger.ts",
|
|
1033
1851
|
slot: "imports",
|
|
1034
|
-
content: "import {
|
|
1852
|
+
content: "import { traceIds } from \"@tatara/observability\";"
|
|
1035
1853
|
},
|
|
1036
1854
|
{
|
|
1037
|
-
file: "
|
|
1038
|
-
slot: "
|
|
1039
|
-
content: "
|
|
1855
|
+
file: "src/core/logging/logger.ts",
|
|
1856
|
+
slot: "logger-options",
|
|
1857
|
+
content: "mixin: traceIds,"
|
|
1040
1858
|
},
|
|
1041
1859
|
{
|
|
1042
1860
|
file: "AGENTS.md",
|
|
1043
1861
|
slot: "sections",
|
|
1044
|
-
content: `##
|
|
1862
|
+
content: `## Observability
|
|
1045
1863
|
|
|
1046
|
-
- \`
|
|
1047
|
-
|
|
1048
|
-
|
|
1864
|
+
- Set \`OTEL_EXPORTER_OTLP_ENDPOINT\` (any OTLP/HTTP collector: Grafana, Honeycomb, Datadog, Jaeger…)
|
|
1865
|
+
and every process sends traces and metrics. Requests, database, Redis and HTTP calls are traced
|
|
1866
|
+
for you; log lines carry \`trace_id\` and \`span_id\`.
|
|
1867
|
+
- Processes start with \`node --import ./dist/apps/instrument.mjs\` (the Dockerfile sets it in
|
|
1868
|
+
\`NODE_OPTIONS\`); without it nothing is instrumented.
|
|
1869
|
+
- Your own spans: \`trace.getTracer("billing").startActiveSpan(...)\` from \`@opentelemetry/api\`.
|
|
1049
1870
|
`
|
|
1050
1871
|
}
|
|
1051
1872
|
]
|
|
@@ -1165,10 +1986,27 @@ const email = {
|
|
|
1165
1986
|
env: [
|
|
1166
1987
|
{
|
|
1167
1988
|
key: "EMAIL_DRIVER",
|
|
1168
|
-
schema: "localDefault(z.enum([\"smtp\", \"console\"]), \"console\")",
|
|
1989
|
+
schema: "localDefault(z.enum([\"smtp\", \"mailgun\", \"console\"]), \"console\")",
|
|
1169
1990
|
example: "smtp",
|
|
1170
1991
|
test: "console"
|
|
1171
1992
|
},
|
|
1993
|
+
{
|
|
1994
|
+
key: "MAILGUN_API_KEY",
|
|
1995
|
+
schema: "z.string().optional()",
|
|
1996
|
+
example: "",
|
|
1997
|
+
comment: "For EMAIL_DRIVER=mailgun"
|
|
1998
|
+
},
|
|
1999
|
+
{
|
|
2000
|
+
key: "MAILGUN_DOMAIN",
|
|
2001
|
+
schema: "z.string().optional()",
|
|
2002
|
+
example: ""
|
|
2003
|
+
},
|
|
2004
|
+
{
|
|
2005
|
+
key: "MAILGUN_API_URL",
|
|
2006
|
+
schema: "z.url().default(\"https://api.mailgun.net\")",
|
|
2007
|
+
example: "https://api.mailgun.net",
|
|
2008
|
+
comment: "https://api.eu.mailgun.net for EU domains"
|
|
2009
|
+
},
|
|
1172
2010
|
{
|
|
1173
2011
|
key: "SMTP_URL",
|
|
1174
2012
|
schema: "localDefault(z.string(), \"smtp://localhost:1025\")",
|
|
@@ -1212,7 +2050,9 @@ const email = {
|
|
|
1212
2050
|
content: `## Email
|
|
1213
2051
|
|
|
1214
2052
|
- \`sendEmail({ to, subject, text, html? })\` from \`#core/email/send\`. Locally it goes to Mailpit
|
|
1215
|
-
(UI on \`MAILPIT_UI_PORT\`); tests use the console driver.
|
|
2053
|
+
(UI on \`MAILPIT_UI_PORT\`); tests use the console driver; \`EMAIL_DRIVER=mailgun\` sends through Mailgun.
|
|
2054
|
+
- Templates: \`emails/<name>.ts\` in a module (\`defineEmail({ name, schema, render(data, language) })\` from
|
|
2055
|
+
\`@tatara/email\`), sent with \`sendTemplate(name, to, data, language)\`.
|
|
1216
2056
|
`
|
|
1217
2057
|
}]
|
|
1218
2058
|
};
|
|
@@ -1228,23 +2068,23 @@ const i18n = {
|
|
|
1228
2068
|
}],
|
|
1229
2069
|
slots: [
|
|
1230
2070
|
{
|
|
1231
|
-
file: "src/
|
|
2071
|
+
file: "src/apps/http.ts",
|
|
1232
2072
|
slot: "imports",
|
|
1233
2073
|
content: "import { detectLanguage } from \"#core/i18n/index\";"
|
|
1234
2074
|
},
|
|
1235
2075
|
{
|
|
1236
|
-
file: "src/
|
|
2076
|
+
file: "src/apps/http.ts",
|
|
1237
2077
|
slot: "middleware",
|
|
1238
2078
|
order: 10,
|
|
1239
2079
|
content: "app.use(detectLanguage);"
|
|
1240
2080
|
},
|
|
1241
2081
|
{
|
|
1242
|
-
file: "src/
|
|
2082
|
+
file: "src/apps/http.ts",
|
|
1243
2083
|
slot: "imports",
|
|
1244
2084
|
content: "import { t } from \"#core/i18n/index\";"
|
|
1245
2085
|
},
|
|
1246
2086
|
{
|
|
1247
|
-
file: "src/
|
|
2087
|
+
file: "src/apps/http.ts",
|
|
1248
2088
|
slot: "error-options",
|
|
1249
2089
|
content: ["message: (err, c) =>", " err.translatable ? t(c.get(\"language\"), `errors.${err.code}`, err.message) : err.message,"].join("\n")
|
|
1250
2090
|
},
|
|
@@ -1268,18 +2108,19 @@ const auth = {
|
|
|
1268
2108
|
dependencies: ["@tatara/auth"],
|
|
1269
2109
|
slots: [
|
|
1270
2110
|
{
|
|
1271
|
-
file: "src/
|
|
2111
|
+
file: "src/apps/http.ts",
|
|
1272
2112
|
slot: "imports",
|
|
1273
|
-
content: "import { attachPrincipal } from \"#
|
|
2113
|
+
content: "import { clientIp, withClientIp } from \"@tatara/http\";\nimport { attachPrincipal } from \"#modules/users/services/auth\";"
|
|
1274
2114
|
},
|
|
1275
2115
|
{
|
|
1276
|
-
file: "src/
|
|
2116
|
+
file: "src/apps/http.ts",
|
|
1277
2117
|
slot: "middleware",
|
|
1278
2118
|
order: 50,
|
|
2119
|
+
unless: ["admin"],
|
|
1279
2120
|
content: "app.use(attachPrincipal);"
|
|
1280
2121
|
},
|
|
1281
2122
|
{
|
|
1282
|
-
file: "src/
|
|
2123
|
+
file: "src/apps/http.ts",
|
|
1283
2124
|
slot: "routes",
|
|
1284
2125
|
order: 0,
|
|
1285
2126
|
content: [
|
|
@@ -1310,7 +2151,7 @@ const BETTER_AUTH_AGENTS = `### better-auth
|
|
|
1310
2151
|
- Sessions work as cookies (browsers) and as \`Authorization: Bearer <session token>\` (mobile, APIs).
|
|
1311
2152
|
- Outside production, every email and SMS is kept in an in-memory outbox:
|
|
1312
2153
|
\`GET /api/dev/outbox?to=<address>\` returns the latest messages, including sign-in codes.
|
|
1313
|
-
- To send real SMS, implement \`SmsSender\` (from \`@tatara/auth\`) in \`src/
|
|
2154
|
+
- To send real SMS, implement \`SmsSender\` (from \`@tatara/auth\`) in \`src/modules/users/services/sms.ts\`.
|
|
1314
2155
|
`;
|
|
1315
2156
|
const drizzlePg = ["drizzle", "postgres"];
|
|
1316
2157
|
const drizzleMy = ["drizzle", "mysql"];
|
|
@@ -1343,41 +2184,53 @@ const betterAuth = {
|
|
|
1343
2184
|
],
|
|
1344
2185
|
slots: [
|
|
1345
2186
|
{
|
|
1346
|
-
file: "src/
|
|
2187
|
+
file: "src/apps/http.ts",
|
|
1347
2188
|
slot: "imports",
|
|
1348
|
-
content: "import { auth } from \"#
|
|
2189
|
+
content: "import { auth } from \"#modules/users/services/auth\";"
|
|
1349
2190
|
},
|
|
1350
2191
|
{
|
|
1351
|
-
file: "src/
|
|
2192
|
+
file: "src/apps/http.ts",
|
|
1352
2193
|
slot: "imports",
|
|
1353
|
-
content: "import { outbox } from \"#
|
|
2194
|
+
content: "import { outbox } from \"#modules/users/services/delivery\";"
|
|
1354
2195
|
},
|
|
1355
2196
|
{
|
|
1356
|
-
file: "src/
|
|
2197
|
+
file: "src/apps/http.ts",
|
|
1357
2198
|
slot: "imports",
|
|
1358
2199
|
content: "import { mergeAuthOpenApi } from \"@tatara/auth\";"
|
|
1359
2200
|
},
|
|
1360
2201
|
{
|
|
1361
|
-
file: "src/
|
|
2202
|
+
file: "src/apps/http.ts",
|
|
1362
2203
|
slot: "expose-headers",
|
|
1363
2204
|
content: "\"set-auth-token\","
|
|
1364
2205
|
},
|
|
1365
2206
|
{
|
|
1366
|
-
file: "src/
|
|
2207
|
+
file: "src/apps/http.ts",
|
|
2208
|
+
slot: "openapi-options",
|
|
2209
|
+
unless: ["admin"],
|
|
2210
|
+
content: [
|
|
2211
|
+
"async extend(document) {",
|
|
2212
|
+
" if (name === \"api\") await mergeAuthOpenApi(document, auth, `${API_PREFIX}/auth`);",
|
|
2213
|
+
"},"
|
|
2214
|
+
].join("\n")
|
|
2215
|
+
},
|
|
2216
|
+
{
|
|
2217
|
+
file: "src/apps/http.ts",
|
|
1367
2218
|
slot: "openapi-options",
|
|
2219
|
+
when: ["admin"],
|
|
1368
2220
|
content: [
|
|
1369
2221
|
"async extend(document) {",
|
|
1370
2222
|
" if (name === \"api\") await mergeAuthOpenApi(document, auth, `${API_PREFIX}/auth`);",
|
|
2223
|
+
" if (name === \"admin\") await mergeAuthOpenApi(document, staffAuth, `${API_PREFIX}/auth`);",
|
|
1371
2224
|
"},"
|
|
1372
2225
|
].join("\n")
|
|
1373
2226
|
},
|
|
1374
2227
|
{
|
|
1375
|
-
file: "src/
|
|
2228
|
+
file: "src/apps/http.ts",
|
|
1376
2229
|
slot: "routes",
|
|
1377
2230
|
order: 1,
|
|
1378
2231
|
content: [
|
|
1379
2232
|
"if (name === \"api\") {",
|
|
1380
|
-
" app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c)
|
|
2233
|
+
" app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c) =>\n auth.handler(withClientIp(c.req.raw, clientIp(c, { trustedHops: env.TRUSTED_PROXY_HOPS }))),\n );",
|
|
1381
2234
|
" if (outbox.enabled) {",
|
|
1382
2235
|
" app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages: outbox.read(c.req.query(\"to\")) }));",
|
|
1383
2236
|
" }",
|
|
@@ -1385,29 +2238,35 @@ const betterAuth = {
|
|
|
1385
2238
|
].join("\n")
|
|
1386
2239
|
},
|
|
1387
2240
|
{
|
|
1388
|
-
file: "src/
|
|
2241
|
+
file: "src/modules/users/services/delivery.ts",
|
|
1389
2242
|
slot: "email-delivery",
|
|
1390
2243
|
unless: ["email"],
|
|
1391
2244
|
content: "logger.info({ to: message.to, subject: message.subject }, \"email not sent: no email provider configured\");"
|
|
1392
2245
|
},
|
|
1393
2246
|
{
|
|
1394
|
-
file: "src/
|
|
2247
|
+
file: "src/modules/users/services/delivery.ts",
|
|
1395
2248
|
slot: "imports",
|
|
1396
2249
|
unless: ["email"],
|
|
1397
2250
|
content: "import { logger } from \"#core/logging/logger\";"
|
|
1398
2251
|
},
|
|
1399
2252
|
{
|
|
1400
|
-
file: "src/
|
|
2253
|
+
file: "src/modules/users/services/delivery.ts",
|
|
1401
2254
|
slot: "imports",
|
|
1402
2255
|
when: ["email"],
|
|
1403
2256
|
content: "import { sendEmail } from \"#core/email/send\";"
|
|
1404
2257
|
},
|
|
1405
2258
|
{
|
|
1406
|
-
file: "src/
|
|
2259
|
+
file: "src/modules/users/services/delivery.ts",
|
|
1407
2260
|
slot: "email-delivery",
|
|
1408
2261
|
when: ["email"],
|
|
1409
2262
|
content: "await sendEmail({ to: message.to, subject: message.subject, text: message.text });"
|
|
1410
2263
|
},
|
|
2264
|
+
{
|
|
2265
|
+
file: "tests/helpers/auth.ts",
|
|
2266
|
+
slot: "authenticated",
|
|
2267
|
+
unless: ["auth-orgs"],
|
|
2268
|
+
content: "return { userId, headers };"
|
|
2269
|
+
},
|
|
1411
2270
|
{
|
|
1412
2271
|
file: "AGENTS.md",
|
|
1413
2272
|
slot: "sections",
|
|
@@ -1421,17 +2280,17 @@ const authEmailOtp = {
|
|
|
1421
2280
|
label: "Email OTP",
|
|
1422
2281
|
slots: [
|
|
1423
2282
|
{
|
|
1424
|
-
file: "src/
|
|
2283
|
+
file: "src/modules/users/services/auth.ts",
|
|
1425
2284
|
slot: "imports",
|
|
1426
|
-
content: "import { deliverEmail } from \"#
|
|
2285
|
+
content: "import { deliverEmail } from \"#modules/users/services/delivery\";"
|
|
1427
2286
|
},
|
|
1428
2287
|
{
|
|
1429
|
-
file: "src/
|
|
2288
|
+
file: "src/modules/users/services/auth.ts",
|
|
1430
2289
|
slot: "imports",
|
|
1431
2290
|
content: "import { emailOTP } from \"better-auth/plugins\";"
|
|
1432
2291
|
},
|
|
1433
2292
|
{
|
|
1434
|
-
file: "src/
|
|
2293
|
+
file: "src/modules/users/services/auth.ts",
|
|
1435
2294
|
slot: "plugins",
|
|
1436
2295
|
content: [
|
|
1437
2296
|
"emailOTP({",
|
|
@@ -1450,13 +2309,13 @@ const authEmailOtp = {
|
|
|
1450
2309
|
};
|
|
1451
2310
|
const phoneFields = [
|
|
1452
2311
|
{
|
|
1453
|
-
file: "src/
|
|
2312
|
+
file: "src/modules/users/db/auth.schema.ts",
|
|
1454
2313
|
slot: "user-fields",
|
|
1455
2314
|
when: drizzlePg,
|
|
1456
2315
|
content: "phoneNumber: text().unique(),\nphoneNumberVerified: boolean(),"
|
|
1457
2316
|
},
|
|
1458
2317
|
{
|
|
1459
|
-
file: "src/
|
|
2318
|
+
file: "src/modules/users/db/auth.schema.ts",
|
|
1460
2319
|
slot: "user-fields",
|
|
1461
2320
|
when: drizzleMy,
|
|
1462
2321
|
content: "phoneNumber: varchar({ length: 32 }).unique(),\nphoneNumberVerified: boolean(),"
|
|
@@ -1473,17 +2332,17 @@ const authPhoneOtp = {
|
|
|
1473
2332
|
label: "Phone OTP",
|
|
1474
2333
|
slots: [
|
|
1475
2334
|
{
|
|
1476
|
-
file: "src/
|
|
2335
|
+
file: "src/modules/users/services/auth.ts",
|
|
1477
2336
|
slot: "imports",
|
|
1478
|
-
content: "import { deliverSms } from \"#
|
|
2337
|
+
content: "import { deliverSms } from \"#modules/users/services/delivery\";"
|
|
1479
2338
|
},
|
|
1480
2339
|
{
|
|
1481
|
-
file: "src/
|
|
2340
|
+
file: "src/modules/users/services/auth.ts",
|
|
1482
2341
|
slot: "imports",
|
|
1483
2342
|
content: "import { phoneNumber } from \"better-auth/plugins\";"
|
|
1484
2343
|
},
|
|
1485
2344
|
{
|
|
1486
|
-
file: "src/
|
|
2345
|
+
file: "src/modules/users/services/auth.ts",
|
|
1487
2346
|
slot: "plugins",
|
|
1488
2347
|
content: [
|
|
1489
2348
|
"phoneNumber({",
|
|
@@ -1513,12 +2372,12 @@ const authPassword = {
|
|
|
1513
2372
|
}],
|
|
1514
2373
|
slots: [
|
|
1515
2374
|
{
|
|
1516
|
-
file: "src/
|
|
2375
|
+
file: "src/modules/users/services/auth.ts",
|
|
1517
2376
|
slot: "imports",
|
|
1518
|
-
content: "import { deliverEmail } from \"#
|
|
2377
|
+
content: "import { deliverEmail } from \"#modules/users/services/delivery\";"
|
|
1519
2378
|
},
|
|
1520
2379
|
{
|
|
1521
|
-
file: "src/
|
|
2380
|
+
file: "src/modules/users/services/auth.ts",
|
|
1522
2381
|
slot: "options",
|
|
1523
2382
|
content: [
|
|
1524
2383
|
"emailAndPassword: {",
|
|
@@ -1531,7 +2390,7 @@ const authPassword = {
|
|
|
1531
2390
|
].join("\n")
|
|
1532
2391
|
},
|
|
1533
2392
|
{
|
|
1534
|
-
file: "src/
|
|
2393
|
+
file: "src/modules/users/db/seed.ts",
|
|
1535
2394
|
slot: "seed-after-user",
|
|
1536
2395
|
content: [
|
|
1537
2396
|
"if (env.SEED_ADMIN_PASSWORD) {",
|
|
@@ -1548,25 +2407,25 @@ const authPassword = {
|
|
|
1548
2407
|
};
|
|
1549
2408
|
const adminFields = [
|
|
1550
2409
|
{
|
|
1551
|
-
file: "src/
|
|
2410
|
+
file: "src/modules/users/db/auth.schema.ts",
|
|
1552
2411
|
slot: "user-fields",
|
|
1553
2412
|
when: drizzlePg,
|
|
1554
2413
|
content: "role: text(),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ withTimezone: true, mode: \"date\" }),"
|
|
1555
2414
|
},
|
|
1556
2415
|
{
|
|
1557
|
-
file: "src/
|
|
2416
|
+
file: "src/modules/users/db/auth.schema.ts",
|
|
1558
2417
|
slot: "user-fields",
|
|
1559
2418
|
when: drizzleMy,
|
|
1560
2419
|
content: "role: varchar({ length: 255 }),\nbanned: boolean().default(false),\nbanReason: text(),\nbanExpires: timestamp({ fsp: 3, mode: \"date\" }),"
|
|
1561
2420
|
},
|
|
1562
2421
|
{
|
|
1563
|
-
file: "src/
|
|
2422
|
+
file: "src/modules/users/db/auth.schema.ts",
|
|
1564
2423
|
slot: "session-fields",
|
|
1565
2424
|
when: drizzlePg,
|
|
1566
2425
|
content: "activeOrganizationId: text(),\nimpersonatedBy: text(),"
|
|
1567
2426
|
},
|
|
1568
2427
|
{
|
|
1569
|
-
file: "src/
|
|
2428
|
+
file: "src/modules/users/db/auth.schema.ts",
|
|
1570
2429
|
slot: "session-fields",
|
|
1571
2430
|
when: drizzleMy,
|
|
1572
2431
|
content: "activeOrganizationId: varchar({ length: 36 }),\nimpersonatedBy: varchar({ length: 36 }),"
|
|
@@ -1592,57 +2451,70 @@ const adminFields = [
|
|
|
1592
2451
|
content: ["activeOrganizationId String? @map(\"active_organization_id\") @db.VarChar(36)", "impersonatedBy String? @map(\"impersonated_by\") @db.VarChar(36)"].join("\n")
|
|
1593
2452
|
},
|
|
1594
2453
|
{
|
|
1595
|
-
file: "src/
|
|
2454
|
+
file: "src/modules/users/services/adapter.ts",
|
|
1596
2455
|
slot: "imports",
|
|
1597
2456
|
when: ["drizzle"],
|
|
1598
|
-
content: "import * as orgSchema from \"#
|
|
2457
|
+
content: "import * as orgSchema from \"#modules/users/db/organization.schema\";"
|
|
1599
2458
|
},
|
|
1600
2459
|
{
|
|
1601
|
-
file: "src/
|
|
2460
|
+
file: "src/modules/users/services/adapter.ts",
|
|
1602
2461
|
slot: "adapter-schema",
|
|
1603
2462
|
when: ["drizzle"],
|
|
1604
2463
|
content: "organization: orgSchema.organizations,\nmember: orgSchema.members,\ninvitation: orgSchema.invitations,"
|
|
1605
2464
|
}
|
|
1606
2465
|
];
|
|
2466
|
+
const JOIN_TEST_ORG = [
|
|
2467
|
+
"/** Makes the user a member of `orgId` (a shared test org by default), creating it on first use. */",
|
|
2468
|
+
"export async function joinTestOrg(userId: string, orgId = \"test-org\", role = \"member\"): Promise<string> {",
|
|
2469
|
+
" const { adapter } = await auth.$context;",
|
|
2470
|
+
" const org = await adapter.findOne({ model: \"organization\", where: [{ field: \"id\", value: orgId }] });",
|
|
2471
|
+
" if (!org) {",
|
|
2472
|
+
" await adapter.create({",
|
|
2473
|
+
" model: \"organization\",",
|
|
2474
|
+
" data: { id: orgId, name: orgId, slug: orgId, createdAt: new Date() },",
|
|
2475
|
+
" forceAllowId: true,",
|
|
2476
|
+
" });",
|
|
2477
|
+
" }",
|
|
2478
|
+
" await adapter.create({",
|
|
2479
|
+
" model: \"member\",",
|
|
2480
|
+
" data: { organizationId: orgId, userId, role, createdAt: new Date() },",
|
|
2481
|
+
" });",
|
|
2482
|
+
" return orgId;",
|
|
2483
|
+
"}"
|
|
2484
|
+
].join("\n");
|
|
1607
2485
|
const authOrgs = {
|
|
1608
2486
|
id: "auth-orgs",
|
|
1609
2487
|
kind: "auth-method",
|
|
1610
2488
|
label: "Organizations + roles",
|
|
1611
2489
|
slots: [
|
|
1612
2490
|
{
|
|
1613
|
-
file: "src/
|
|
2491
|
+
file: "src/modules/users/services/auth.ts",
|
|
1614
2492
|
slot: "imports",
|
|
1615
2493
|
content: "import { admin, organization } from \"better-auth/plugins\";"
|
|
1616
2494
|
},
|
|
1617
2495
|
{
|
|
1618
|
-
file: "src/
|
|
2496
|
+
file: "src/modules/users/services/auth.ts",
|
|
1619
2497
|
slot: "plugins",
|
|
1620
2498
|
unless: ["authz"],
|
|
1621
2499
|
content: "organization(),\nadmin(),",
|
|
1622
2500
|
order: 900
|
|
1623
2501
|
},
|
|
1624
2502
|
{
|
|
1625
|
-
file: "src/
|
|
2503
|
+
file: "src/modules/users/services/auth.ts",
|
|
1626
2504
|
slot: "plugins",
|
|
1627
2505
|
when: ["authz"],
|
|
1628
2506
|
content: [
|
|
1629
|
-
"
|
|
1630
|
-
"
|
|
1631
|
-
" // authz rows outlive better-auth's member row; without this, re-adding restores old roles",
|
|
1632
|
-
" afterRemoveMember: async ({ member }) => {",
|
|
1633
|
-
" await removeMemberAccess(member.organizationId, member.userId);",
|
|
1634
|
-
" },",
|
|
1635
|
-
" },",
|
|
1636
|
-
"}),",
|
|
2507
|
+
"// a removed member's authz roles and overlays go with the membership",
|
|
2508
|
+
"organization({ organizationHooks: authzOrganizationHooks(authz) }),",
|
|
1637
2509
|
"admin(),"
|
|
1638
2510
|
].join("\n"),
|
|
1639
2511
|
order: 900
|
|
1640
2512
|
},
|
|
1641
2513
|
{
|
|
1642
|
-
file: "src/
|
|
2514
|
+
file: "src/modules/users/services/auth.ts",
|
|
1643
2515
|
slot: "imports",
|
|
1644
2516
|
when: ["authz"],
|
|
1645
|
-
content: "import {
|
|
2517
|
+
content: "import { authzOrganizationHooks } from \"@tatara/authz\";\nimport { authz } from \"#core/authz/index\";"
|
|
1646
2518
|
},
|
|
1647
2519
|
{
|
|
1648
2520
|
file: "tests/helpers/auth.ts",
|
|
@@ -1650,7 +2522,22 @@ const authOrgs = {
|
|
|
1650
2522
|
content: "...(options.roles ? { role: options.roles.join(\",\") } : {}),"
|
|
1651
2523
|
},
|
|
1652
2524
|
{
|
|
1653
|
-
file: "
|
|
2525
|
+
file: "tests/helpers/auth.ts",
|
|
2526
|
+
slot: "options",
|
|
2527
|
+
content: "/** the org the user joins and acts in (default: a shared test org) */\norgId?: string;"
|
|
2528
|
+
},
|
|
2529
|
+
{
|
|
2530
|
+
file: "tests/helpers/auth.ts",
|
|
2531
|
+
slot: "authenticated",
|
|
2532
|
+
content: ["const orgId = await joinTestOrg(userId, options.orgId);", "return { userId, orgId, headers: { ...headers, \"x-org-id\": orgId } };"].join("\n")
|
|
2533
|
+
},
|
|
2534
|
+
{
|
|
2535
|
+
file: "tests/helpers/auth.ts",
|
|
2536
|
+
slot: "helpers",
|
|
2537
|
+
content: JOIN_TEST_ORG
|
|
2538
|
+
},
|
|
2539
|
+
{
|
|
2540
|
+
file: "src/modules/users/db/seed.ts",
|
|
1654
2541
|
slot: "seed-user-fields",
|
|
1655
2542
|
content: "role: \"admin\","
|
|
1656
2543
|
},
|
|
@@ -1708,41 +2595,41 @@ const authKeycloak = {
|
|
|
1708
2595
|
} },
|
|
1709
2596
|
slots: [
|
|
1710
2597
|
{
|
|
1711
|
-
file: "src/
|
|
2598
|
+
file: "src/modules/users/services/auth.ts",
|
|
1712
2599
|
slot: "imports",
|
|
1713
2600
|
content: "import { keycloakTokens } from \"@tatara/keycloak\";"
|
|
1714
2601
|
},
|
|
1715
2602
|
{
|
|
1716
|
-
file: "src/
|
|
2603
|
+
file: "src/modules/users/services/auth.ts",
|
|
1717
2604
|
slot: "imports",
|
|
1718
|
-
content: "import { keycloak } from \"#
|
|
2605
|
+
content: "import { keycloak } from \"#modules/users/services/keycloak\";"
|
|
1719
2606
|
},
|
|
1720
2607
|
{
|
|
1721
|
-
file: "src/
|
|
2608
|
+
file: "src/modules/users/services/auth.ts",
|
|
1722
2609
|
slot: "imports",
|
|
1723
2610
|
content: "import { logger } from \"#core/logging/logger\";"
|
|
1724
2611
|
},
|
|
1725
2612
|
{
|
|
1726
|
-
file: "src/
|
|
2613
|
+
file: "src/modules/users/services/auth.ts",
|
|
1727
2614
|
slot: "plugins",
|
|
1728
2615
|
unless: ["auth-orgs"],
|
|
1729
2616
|
content: "keycloakTokens({ verifier: keycloak, logger }),",
|
|
1730
2617
|
order: 800
|
|
1731
2618
|
},
|
|
1732
2619
|
{
|
|
1733
|
-
file: "src/
|
|
2620
|
+
file: "src/modules/users/services/auth.ts",
|
|
1734
2621
|
slot: "plugins",
|
|
1735
2622
|
when: ["auth-orgs"],
|
|
1736
2623
|
content: "keycloakTokens({ verifier: keycloak, logger, organizations: true }),",
|
|
1737
2624
|
order: 800
|
|
1738
2625
|
},
|
|
1739
2626
|
{
|
|
1740
|
-
file: "src/
|
|
2627
|
+
file: "src/modules/users/services/auth.ts",
|
|
1741
2628
|
slot: "resolvers",
|
|
1742
|
-
content: "(c) => keycloakPrincipal(
|
|
2629
|
+
content: "(instance) => (c) => keycloakPrincipal(instance, c.req.header(\"authorization\")),"
|
|
1743
2630
|
},
|
|
1744
2631
|
{
|
|
1745
|
-
file: "src/
|
|
2632
|
+
file: "src/modules/users/services/auth.ts",
|
|
1746
2633
|
slot: "imports",
|
|
1747
2634
|
content: "import { keycloakPrincipal } from \"@tatara/keycloak\";"
|
|
1748
2635
|
},
|
|
@@ -1754,7 +2641,7 @@ const authKeycloak = {
|
|
|
1754
2641
|
{
|
|
1755
2642
|
file: "tests/helpers/auth.ts",
|
|
1756
2643
|
slot: "imports",
|
|
1757
|
-
content: "import { keycloak } from \"#
|
|
2644
|
+
content: "import { keycloak } from \"#modules/users/services/keycloak\";"
|
|
1758
2645
|
},
|
|
1759
2646
|
{
|
|
1760
2647
|
file: "tests/helpers/auth.ts",
|
|
@@ -1868,6 +2755,18 @@ const base = {
|
|
|
1868
2755
|
key: "BODY_LIMIT_BYTES",
|
|
1869
2756
|
schema: "z.coerce.number().int().positive().default(1_048_576)",
|
|
1870
2757
|
example: "1048576"
|
|
2758
|
+
},
|
|
2759
|
+
{
|
|
2760
|
+
key: "REQUEST_TIMEOUT_MS",
|
|
2761
|
+
schema: "z.coerce.number().int().positive().default(30_000)",
|
|
2762
|
+
example: "30000",
|
|
2763
|
+
comment: "Longest a request may take before a 504 (slow paths get their own in src/apps/http.ts)"
|
|
2764
|
+
},
|
|
2765
|
+
{
|
|
2766
|
+
key: "TRUSTED_PROXY_HOPS",
|
|
2767
|
+
schema: "z.coerce.number().int().min(0).default(0)",
|
|
2768
|
+
example: "0",
|
|
2769
|
+
comment: "Proxies in front of the app that append to X-Forwarded-For (a load balancer, Cloud Run: 1)"
|
|
1871
2770
|
}
|
|
1872
2771
|
],
|
|
1873
2772
|
entrypoints: [{
|
|
@@ -2039,6 +2938,16 @@ const db$1 = {
|
|
|
2039
2938
|
"process.env.DATABASE_URL = workerDatabaseUrl(process.env.DATABASE_URL ?? \"\", process.env.VITEST_POOL_ID ?? \"1\");"
|
|
2040
2939
|
].join("\n")
|
|
2041
2940
|
},
|
|
2941
|
+
{
|
|
2942
|
+
file: "src/apps/cli/index.ts",
|
|
2943
|
+
slot: "imports",
|
|
2944
|
+
content: "import { env } from \"#core/config/env\";\nimport { commandLedger } from \"#core/db/admin\";"
|
|
2945
|
+
},
|
|
2946
|
+
{
|
|
2947
|
+
file: "src/apps/cli/index.ts",
|
|
2948
|
+
slot: "options",
|
|
2949
|
+
content: "ledger: commandLedger(env.DATABASE_URL),"
|
|
2950
|
+
},
|
|
2042
2951
|
{
|
|
2043
2952
|
file: "AGENTS.md",
|
|
2044
2953
|
slot: "sections",
|
|
@@ -2188,20 +3097,31 @@ const FEATURES = Object.fromEntries([
|
|
|
2188
3097
|
auth,
|
|
2189
3098
|
authz,
|
|
2190
3099
|
jobs,
|
|
3100
|
+
cache,
|
|
3101
|
+
notifications,
|
|
3102
|
+
realtime,
|
|
2191
3103
|
admin,
|
|
2192
3104
|
storage,
|
|
2193
3105
|
email,
|
|
2194
|
-
i18n
|
|
3106
|
+
i18n,
|
|
3107
|
+
observability
|
|
2195
3108
|
].map((f) => [f.id, f]));
|
|
2196
3109
|
//#endregion
|
|
2197
3110
|
//#region src/engine/resolve.ts
|
|
2198
3111
|
var OptionsError = class extends Error {};
|
|
3112
|
+
/** Add-ons others need: notifications deliver through jobs; schedules and realtime tickets use the cache. */
|
|
3113
|
+
function impliedAddons(addons) {
|
|
3114
|
+
const out = new Set(addons);
|
|
3115
|
+
if (out.has("notifications")) out.add("jobs");
|
|
3116
|
+
if (out.has("jobs") || out.has("realtime")) out.add("cache");
|
|
3117
|
+
return [...out];
|
|
3118
|
+
}
|
|
2199
3119
|
/** Normalizes and validates options; throws OptionsError with a user-facing message. */
|
|
2200
3120
|
function validateOptions(input) {
|
|
2201
3121
|
const options = {
|
|
2202
3122
|
...input,
|
|
2203
3123
|
authMethods: [...new Set(input.authMethods)],
|
|
2204
|
-
addons:
|
|
3124
|
+
addons: impliedAddons(input.addons)
|
|
2205
3125
|
};
|
|
2206
3126
|
if (!/^[a-z][a-z0-9-]*$/.test(options.name)) throw new OptionsError(`App name "${options.name}" must be lowercase letters, digits and dashes, starting with a letter.`);
|
|
2207
3127
|
if (options.orm === "none" !== (options.database === "none")) throw new OptionsError("Choose both an ORM and a database, or neither.");
|
|
@@ -2211,9 +3131,8 @@ function validateOptions(input) {
|
|
|
2211
3131
|
if (options.authMethods.filter((m) => m !== "orgs").length === 0) throw new OptionsError("better-auth needs at least one sign-in method: email-otp, phone-otp, password or keycloak.");
|
|
2212
3132
|
} else if (options.authMethods.length > 0) throw new OptionsError("Auth methods only apply to better-auth.");
|
|
2213
3133
|
for (const addon of options.addons) {
|
|
2214
|
-
if ((addon === "authz" || addon === "admin") && options.auth === "none") throw new OptionsError(`The ${addon} add-on needs an auth provider.`);
|
|
2215
|
-
if (addon === "authz" && !hasDb) throw new OptionsError(
|
|
2216
|
-
if (addon === "admin" && options.auth === "better-auth" && !options.authMethods.includes("orgs") && !options.authMethods.includes("keycloak")) throw new OptionsError("The admin add-on with better-auth needs the \"orgs\" or \"keycloak\" method (they provide user roles).");
|
|
3134
|
+
if ((addon === "authz" || addon === "admin" || addon === "notifications" || addon === "realtime") && options.auth === "none") throw new OptionsError(`The ${addon} add-on needs an auth provider.`);
|
|
3135
|
+
if ((addon === "authz" || addon === "notifications") && !hasDb) throw new OptionsError(`The ${addon} add-on needs a database.`);
|
|
2217
3136
|
}
|
|
2218
3137
|
return options;
|
|
2219
3138
|
}
|
|
@@ -2438,7 +3357,7 @@ function pnpmWorkspace(features) {
|
|
|
2438
3357
|
}
|
|
2439
3358
|
/** Tests of tatara's wiring, not of the app; generated apps leave them out. */
|
|
2440
3359
|
function isFrameworkTest(path) {
|
|
2441
|
-
return /^src\/core\/.+\/tests\/[^/]+\.test\.ts$/.test(path);
|
|
3360
|
+
return path.startsWith("tests/tatara/") || /^src\/core\/.+\/tests\/[^/]+\.test\.ts$/.test(path);
|
|
2442
3361
|
}
|
|
2443
3362
|
/** Folds applicable variants into each feature so later steps see one flat definition. */
|
|
2444
3363
|
function effectiveFeatures(options) {
|
|
@@ -2648,7 +3567,12 @@ function guardSlots(project, n) {
|
|
|
2648
3567
|
});
|
|
2649
3568
|
if (project.has("authz")) {
|
|
2650
3569
|
add("imports", "import { requirePermission } from \"#core/authz/index\";");
|
|
2651
|
-
|
|
3570
|
+
const orgs = project.has("auth-orgs");
|
|
3571
|
+
if (orgs) add("imports", "import { requireOrg } from \"#modules/users/services/auth\";");
|
|
3572
|
+
for (const [route, action] of Object.entries(ACTIONS)) {
|
|
3573
|
+
const guards = `${orgs ? "requireOrg(), " : ""}requirePermission("${n.kebab}:${action}")`;
|
|
3574
|
+
add(`guard-${route}`, `middleware: [${guards}] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
|
|
3575
|
+
}
|
|
2652
3576
|
add("auth-errors", "...errorResponses(401, 403),");
|
|
2653
3577
|
} else if (project.has("auth")) {
|
|
2654
3578
|
add("imports", "import { requireAuth } from \"@tatara/auth\";");
|
|
@@ -2871,6 +3795,10 @@ function normalizeAuthFlags(flags) {
|
|
|
2871
3795
|
const ADDONS = [
|
|
2872
3796
|
"authz",
|
|
2873
3797
|
"jobs",
|
|
3798
|
+
"cache",
|
|
3799
|
+
"notifications",
|
|
3800
|
+
"realtime",
|
|
3801
|
+
"observability",
|
|
2874
3802
|
"admin",
|
|
2875
3803
|
"storage",
|
|
2876
3804
|
"email",
|
|
@@ -2977,6 +3905,10 @@ async function prompt(name, flags) {
|
|
|
2977
3905
|
const labels = {
|
|
2978
3906
|
authz: "Permission system",
|
|
2979
3907
|
jobs: "Jobs + cron (BullMQ)",
|
|
3908
|
+
cache: "Cache (Redis, Postgres or memory)",
|
|
3909
|
+
notifications: "Notifications (in-app, email, SMS, push)",
|
|
3910
|
+
realtime: "Realtime (websockets)",
|
|
3911
|
+
observability: "Observability (OpenTelemetry)",
|
|
2980
3912
|
admin: "Admin API app",
|
|
2981
3913
|
storage: "File storage",
|
|
2982
3914
|
email: "Email",
|
|
@@ -3140,111 +4072,6 @@ async function recordGenerated(root) {
|
|
|
3140
4072
|
await writeFile(path, `${JSON.stringify(config, null, " ")}\n`);
|
|
3141
4073
|
}
|
|
3142
4074
|
//#endregion
|
|
3143
|
-
//#region src/commands/routes.ts
|
|
3144
|
-
/** Reads every app's OpenAPI document in-process and lists its routes. */
|
|
3145
|
-
async function listRoutes(project) {
|
|
3146
|
-
await sync(project);
|
|
3147
|
-
const apps = [["api", "#apps/api/app"], ...project.has("admin") ? [["admin", "#apps/admin/app"]] : []];
|
|
3148
|
-
const code = `
|
|
3149
|
-
const out = [];
|
|
3150
|
-
for (const [name, spec] of ${JSON.stringify(apps)}) {
|
|
3151
|
-
const { app } = await import(spec);
|
|
3152
|
-
// Fetch from /openapi.json endpoint to get the document with all extensions applied
|
|
3153
|
-
const res = await app.request("/openapi.json");
|
|
3154
|
-
const doc = await res.json();
|
|
3155
|
-
const securitySchemes = doc.components?.securitySchemes ?? {};
|
|
3156
|
-
|
|
3157
|
-
for (const [path, item] of Object.entries(doc.paths ?? {})) {
|
|
3158
|
-
for (const [method, op] of Object.entries(item)) {
|
|
3159
|
-
const tags = op.tags ?? [];
|
|
3160
|
-
const security = op.security ?? [];
|
|
3161
|
-
|
|
3162
|
-
// Parse access label based on security schemes and tags
|
|
3163
|
-
let access = "public";
|
|
3164
|
-
|
|
3165
|
-
// Routes tagged "Auth" are from better-auth's merged document
|
|
3166
|
-
if (tags.includes("Auth")) {
|
|
3167
|
-
access = "better-auth";
|
|
3168
|
-
} else if (security.length > 0) {
|
|
3169
|
-
const labels = [];
|
|
3170
|
-
for (const requirement of security) {
|
|
3171
|
-
const schemes = Object.keys(requirement);
|
|
3172
|
-
if (schemes.length === 0) continue;
|
|
3173
|
-
|
|
3174
|
-
const schemeName = schemes[0];
|
|
3175
|
-
const roles = requirement[schemeName] ?? [];
|
|
3176
|
-
|
|
3177
|
-
// For bearer auth with specific roles, show the roles
|
|
3178
|
-
if (schemeName === "bearerAuth" && roles.length > 0) {
|
|
3179
|
-
labels.push(roles.join(", "));
|
|
3180
|
-
}
|
|
3181
|
-
// For bearer auth without roles, show "authenticated"
|
|
3182
|
-
else if (schemeName === "bearerAuth") {
|
|
3183
|
-
labels.push("authenticated");
|
|
3184
|
-
}
|
|
3185
|
-
// For other schemes (API keys, etc.), show the scheme name
|
|
3186
|
-
else {
|
|
3187
|
-
labels.push(schemeName);
|
|
3188
|
-
}
|
|
3189
|
-
}
|
|
3190
|
-
access = labels.length > 1 ? labels.join(" OR ") : (labels[0] ?? "authenticated");
|
|
3191
|
-
}
|
|
3192
|
-
|
|
3193
|
-
const summary = op.summary || op.description || "";
|
|
3194
|
-
out.push({ app: name, method: method.toUpperCase(), path, summary, access });
|
|
3195
|
-
}
|
|
3196
|
-
}
|
|
3197
|
-
}
|
|
3198
|
-
process.stdout.write(JSON.stringify(out));
|
|
3199
|
-
process.exit(0);
|
|
3200
|
-
`;
|
|
3201
|
-
return new Promise((resolve, reject) => {
|
|
3202
|
-
const child = spawn(process.execPath, [
|
|
3203
|
-
...NODE_FLAGS,
|
|
3204
|
-
"--input-type=module",
|
|
3205
|
-
"-e",
|
|
3206
|
-
code
|
|
3207
|
-
], {
|
|
3208
|
-
cwd: project.root,
|
|
3209
|
-
env: {
|
|
3210
|
-
...process.env,
|
|
3211
|
-
LOG_LEVEL: "silent"
|
|
3212
|
-
}
|
|
3213
|
-
});
|
|
3214
|
-
let stdout = "";
|
|
3215
|
-
let stderr = "";
|
|
3216
|
-
child.stdout.on("data", (d) => {
|
|
3217
|
-
stdout += d.toString();
|
|
3218
|
-
});
|
|
3219
|
-
child.stderr.on("data", (d) => {
|
|
3220
|
-
stderr += d.toString();
|
|
3221
|
-
});
|
|
3222
|
-
child.on("exit", (code) => {
|
|
3223
|
-
if (code !== 0) return reject(/* @__PURE__ */ new Error(`Could not load the apps:\n${stderr}`));
|
|
3224
|
-
resolve(JSON.parse(stdout));
|
|
3225
|
-
});
|
|
3226
|
-
});
|
|
3227
|
-
}
|
|
3228
|
-
function printRoutes(routes) {
|
|
3229
|
-
const rows = routes.map((r) => [
|
|
3230
|
-
r.app,
|
|
3231
|
-
r.method,
|
|
3232
|
-
r.path,
|
|
3233
|
-
r.access,
|
|
3234
|
-
r.summary
|
|
3235
|
-
]);
|
|
3236
|
-
const widths = [
|
|
3237
|
-
0,
|
|
3238
|
-
1,
|
|
3239
|
-
2,
|
|
3240
|
-
3
|
|
3241
|
-
].map((i) => Math.max(...rows.map((r) => (r[i] ?? "").length), 4));
|
|
3242
|
-
for (const r of rows) {
|
|
3243
|
-
const [app = "", method = "", path = "", access = "", summary = ""] = r;
|
|
3244
|
-
console.log(`${pc.dim(app.padEnd(widths[0] ?? 0))} ${pc.bold(method.padEnd(widths[1] ?? 0))} ${path.padEnd(widths[2] ?? 0)} ${(access === "public" ? pc.yellow : pc.green)(access.padEnd(widths[3] ?? 0))} ${pc.dim(summary)}`);
|
|
3245
|
-
}
|
|
3246
|
-
}
|
|
3247
|
-
//#endregion
|
|
3248
4075
|
//#region src/commands/tinker.ts
|
|
3249
4076
|
/** A REPL with env, logger, db and every module's services loaded. */
|
|
3250
4077
|
async function tinker(project) {
|
|
@@ -3451,7 +4278,7 @@ function importsOf(text, specifier) {
|
|
|
3451
4278
|
}
|
|
3452
4279
|
//#endregion
|
|
3453
4280
|
//#region src/upgrade/codemods/groups.ts
|
|
3454
|
-
async function read$
|
|
4281
|
+
async function read$6(ctx, path) {
|
|
3455
4282
|
return ctx.read(path).catch(() => void 0);
|
|
3456
4283
|
}
|
|
3457
4284
|
function sourceFiles(ctx) {
|
|
@@ -3464,7 +4291,7 @@ function sourceFiles(ctx) {
|
|
|
3464
4291
|
async function blockersOf$1(ctx, group, sources) {
|
|
3465
4292
|
const blockers = [];
|
|
3466
4293
|
for (const path of [...group.regenerate, ...group.remove]) {
|
|
3467
|
-
const text = await read$
|
|
4294
|
+
const text = await read$6(ctx, path);
|
|
3468
4295
|
if (text === void 0) continue;
|
|
3469
4296
|
if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
|
|
3470
4297
|
}
|
|
@@ -3604,6 +4431,140 @@ const addonPackages = {
|
|
|
3604
4431
|
}
|
|
3605
4432
|
};
|
|
3606
4433
|
//#endregion
|
|
4434
|
+
//#region src/upgrade/moves.ts
|
|
4435
|
+
/**
|
|
4436
|
+
* Files later CLIs generate somewhere else. An older codemod that regenerates an old path gets
|
|
4437
|
+
* what this CLI generates at the new one; the codemod of the release that moved it moves it.
|
|
4438
|
+
*/
|
|
4439
|
+
const MOVED_IN_0_10 = {
|
|
4440
|
+
"src/core/http/app.ts": "src/apps/http.ts",
|
|
4441
|
+
"src/core/auth/auth.ts": "src/modules/users/services/auth.ts",
|
|
4442
|
+
"src/core/auth/adapter.ts": "src/modules/users/services/adapter.ts",
|
|
4443
|
+
"src/core/auth/delivery.ts": "src/modules/users/services/delivery.ts",
|
|
4444
|
+
"src/core/auth/sms.ts": "src/modules/users/services/sms.ts",
|
|
4445
|
+
"src/core/auth/keycloak.ts": "src/modules/users/services/keycloak.ts",
|
|
4446
|
+
"src/core/auth/db/seed.ts": "src/modules/users/db/seed.ts",
|
|
4447
|
+
"src/core/auth/db/auth.schema.ts": "src/modules/users/db/auth.schema.ts",
|
|
4448
|
+
"src/core/auth/db/organization.schema.ts": "src/modules/users/db/organization.schema.ts",
|
|
4449
|
+
"src/core/auth/routes/me.routes.ts": "src/modules/users/routes/me.routes.ts",
|
|
4450
|
+
"src/core/auth/commands/keycloak-token.ts": "src/modules/users/commands/keycloak-token.ts",
|
|
4451
|
+
"src/core/authz/routes/authorization.routes.ts": "src/modules/users/routes/authorization.routes.ts"
|
|
4452
|
+
};
|
|
4453
|
+
/** Merged into another file in 0.10: nothing is generated in its place. */
|
|
4454
|
+
const MERGED_IN_0_10 = { "src/core/auth/principal.ts": "src/modules/users/services/auth.ts" };
|
|
4455
|
+
/** Where this CLI generates what used to be at `path` (the path itself if it never moved). */
|
|
4456
|
+
function currentPath(path) {
|
|
4457
|
+
return MOVED_IN_0_10[path] ?? path;
|
|
4458
|
+
}
|
|
4459
|
+
//#endregion
|
|
4460
|
+
//#region src/upgrade/codemods/app-owned-auth.ts
|
|
4461
|
+
/** Import specifiers that moved, old → new; every name keeps working at the new place. */
|
|
4462
|
+
const SPECIFIERS = {
|
|
4463
|
+
"#core/http/app": "#apps/http",
|
|
4464
|
+
"#core/auth/auth": "#modules/users/services/auth",
|
|
4465
|
+
"#core/auth/principal": "#modules/users/services/auth",
|
|
4466
|
+
"#core/auth/adapter": "#modules/users/services/adapter",
|
|
4467
|
+
"#core/auth/delivery": "#modules/users/services/delivery",
|
|
4468
|
+
"#core/auth/sms": "#modules/users/services/sms",
|
|
4469
|
+
"#core/auth/keycloak": "#modules/users/services/keycloak",
|
|
4470
|
+
"#core/auth/db/auth.schema": "#modules/users/db/auth.schema",
|
|
4471
|
+
"#core/auth/db/organization.schema": "#modules/users/db/organization.schema"
|
|
4472
|
+
};
|
|
4473
|
+
/** Files whose content this release changes in place (new imports, orgRole, single tenant). */
|
|
4474
|
+
const REGENERATED$4 = [
|
|
4475
|
+
"src/core/http/context.ts",
|
|
4476
|
+
"tests/helpers/auth.ts",
|
|
4477
|
+
"src/core/authz/index.ts"
|
|
4478
|
+
];
|
|
4479
|
+
async function read$5(ctx, path) {
|
|
4480
|
+
return ctx.read(path).catch(() => void 0);
|
|
4481
|
+
}
|
|
4482
|
+
/** Inserts the single-tenant option into an app's own createAuthz call, if it lacks one. */
|
|
4483
|
+
function withSingleTenant(text) {
|
|
4484
|
+
if (text.includes("singleTenant")) return text;
|
|
4485
|
+
return text.replace(/(createAuthz(?:<[^>]*>)?\(\{\n)/, "$1 singleTenant: \"default\",\n");
|
|
4486
|
+
}
|
|
4487
|
+
const STAFF_FILES = [
|
|
4488
|
+
"src/modules/staff/services/auth.ts",
|
|
4489
|
+
"src/modules/staff/services/adapter.ts",
|
|
4490
|
+
"src/modules/staff/db/seed.ts",
|
|
4491
|
+
"src/modules/staff/db/staff.schema.ts",
|
|
4492
|
+
"prisma/models/staff.prisma",
|
|
4493
|
+
"tests/helpers/staff.ts"
|
|
4494
|
+
];
|
|
4495
|
+
/** The admin app now signs in staff accounts of its own; the regenerated apps/http.ts imports them. */
|
|
4496
|
+
async function addStaff(ctx) {
|
|
4497
|
+
for (const path of STAFF_FILES) {
|
|
4498
|
+
if (await read$5(ctx, path) !== void 0) continue;
|
|
4499
|
+
const text = await ctx.renderCurrent(path);
|
|
4500
|
+
if (text !== void 0) await ctx.write(path, text);
|
|
4501
|
+
}
|
|
4502
|
+
const customisedEnv = [];
|
|
4503
|
+
for (const path of [
|
|
4504
|
+
"src/core/config/env.ts",
|
|
4505
|
+
".env.example",
|
|
4506
|
+
".env.test"
|
|
4507
|
+
]) {
|
|
4508
|
+
const text = await read$5(ctx, path);
|
|
4509
|
+
if (text === void 0) continue;
|
|
4510
|
+
if (await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
|
|
4511
|
+
else customisedEnv.push(path);
|
|
4512
|
+
}
|
|
4513
|
+
if (customisedEnv.length > 0) ctx.report.notes.push(`Add ADMIN_URL, SEED_STAFF_EMAIL and SEED_STAFF_PASSWORD to ${customisedEnv.join(", ")} (they are customised, so they were left as they are).`);
|
|
4514
|
+
ctx.report.notes.push("The admin app now signs in staff accounts (src/modules/staff, tables staff_*) instead of users with the admin role. Run `tatara db generate` for the new tables, add { key: \"staff\" } to src/core/authz/system-roles.ts if you use authz, and grant your platform permissions to it.");
|
|
4515
|
+
}
|
|
4516
|
+
const appOwnedAuth = {
|
|
4517
|
+
version: "0.10.0",
|
|
4518
|
+
id: "app-owned-auth",
|
|
4519
|
+
description: "Move auth into src/modules/users and HTTP wiring into src/apps; explicit org context",
|
|
4520
|
+
async run(ctx) {
|
|
4521
|
+
const { config } = ctx.project;
|
|
4522
|
+
const orgs = config.authMethods.includes("orgs");
|
|
4523
|
+
if (config.addons.includes("authz") && !orgs) {
|
|
4524
|
+
const index = await read$5(ctx, "src/core/authz/index.ts");
|
|
4525
|
+
const next = index && withSingleTenant(index);
|
|
4526
|
+
if (next && next !== index) await ctx.write("src/core/authz/index.ts", next);
|
|
4527
|
+
}
|
|
4528
|
+
const blockers = [];
|
|
4529
|
+
const moves = [];
|
|
4530
|
+
for (const [from, to] of Object.entries(MOVED_IN_0_10)) {
|
|
4531
|
+
const text = await read$5(ctx, from);
|
|
4532
|
+
if (text === void 0) continue;
|
|
4533
|
+
const there = await read$5(ctx, to);
|
|
4534
|
+
if (there !== void 0 && there !== await ctx.renderCurrent(to)) blockers.push(`${to} already exists`);
|
|
4535
|
+
else if (await ctx.baseline.isUnchanged(from, text)) moves.push([from, to]);
|
|
4536
|
+
else blockers.push(`${from} is customised`);
|
|
4537
|
+
}
|
|
4538
|
+
const merged = [];
|
|
4539
|
+
for (const from of Object.keys(MERGED_IN_0_10)) {
|
|
4540
|
+
const text = await read$5(ctx, from);
|
|
4541
|
+
if (text === void 0) continue;
|
|
4542
|
+
if (await ctx.baseline.isUnchanged(from, text)) merged.push(from);
|
|
4543
|
+
else blockers.push(`${from} is customised`);
|
|
4544
|
+
}
|
|
4545
|
+
if (blockers.length > 0) {
|
|
4546
|
+
ctx.report.notes.push(`Auth was not moved into src/modules/users: ${blockers.join("; ")}. Compare with a newly generated app (auth in src/modules/users/services/auth.ts built with createAuthInstance, HTTP wiring in src/apps/http.ts) and move it by hand.`);
|
|
4547
|
+
ctx.report.blocked.push("app-owned-auth");
|
|
4548
|
+
return;
|
|
4549
|
+
}
|
|
4550
|
+
for (const [from, to] of moves) {
|
|
4551
|
+
const next = await ctx.renderCurrent(to);
|
|
4552
|
+
if (next !== void 0) await ctx.write(to, next);
|
|
4553
|
+
await ctx.removeIfUnchanged(from);
|
|
4554
|
+
}
|
|
4555
|
+
for (const from of merged) await ctx.removeIfUnchanged(from);
|
|
4556
|
+
for (const path of REGENERATED$4) {
|
|
4557
|
+
const text = await read$5(ctx, path);
|
|
4558
|
+
if (text !== void 0 && await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
|
|
4559
|
+
}
|
|
4560
|
+
for (const [from, to] of Object.entries(SPECIFIERS)) await ctx.rewriteImports(from, to);
|
|
4561
|
+
if (config.addons.includes("admin")) await addStaff(ctx);
|
|
4562
|
+
const leftovers = await glob(["src/core/auth/**/*.ts"], { cwd: ctx.project.root });
|
|
4563
|
+
if (leftovers.length > 0) ctx.report.notes.push(`Move these into src/modules/users (core must not hold auth flows): ${leftovers.join(", ")}`);
|
|
4564
|
+
if (orgs) ctx.report.notes.push("Requests that act in an organization now name it (x-org-id header, or the session's active organization); authz no longer falls back to a default org.");
|
|
4565
|
+
}
|
|
4566
|
+
};
|
|
4567
|
+
//#endregion
|
|
3607
4568
|
//#region src/upgrade/codemods/auth-package.ts
|
|
3608
4569
|
const AUTH = "@tatara/auth";
|
|
3609
4570
|
const KEYCLOAK = "@tatara/keycloak";
|
|
@@ -3680,7 +4641,7 @@ function groups(keycloak) {
|
|
|
3680
4641
|
});
|
|
3681
4642
|
return all;
|
|
3682
4643
|
}
|
|
3683
|
-
async function read$
|
|
4644
|
+
async function read$4(ctx, path) {
|
|
3684
4645
|
return ctx.read(path).catch(() => void 0);
|
|
3685
4646
|
}
|
|
3686
4647
|
/** True when `text` imports `entry` (or one of its gone names). */
|
|
@@ -3694,7 +4655,7 @@ function usesExport(text, entry) {
|
|
|
3694
4655
|
async function blockersOf(ctx, group, sources) {
|
|
3695
4656
|
const blockers = [];
|
|
3696
4657
|
for (const path of [...group.regenerate, ...group.remove]) {
|
|
3697
|
-
const text = await read$
|
|
4658
|
+
const text = await read$4(ctx, path);
|
|
3698
4659
|
if (text === void 0) continue;
|
|
3699
4660
|
if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
|
|
3700
4661
|
}
|
|
@@ -3728,7 +4689,7 @@ const authPackage = {
|
|
|
3728
4689
|
}
|
|
3729
4690
|
for (const group of ready) {
|
|
3730
4691
|
for (const path of group.regenerate) {
|
|
3731
|
-
if (await read$
|
|
4692
|
+
if (await read$4(ctx, path) === void 0) continue;
|
|
3732
4693
|
await ctx.regenerate(path);
|
|
3733
4694
|
}
|
|
3734
4695
|
for (const path of group.remove) await ctx.removeIfUnchanged(path);
|
|
@@ -3759,7 +4720,7 @@ const REMOVED = [
|
|
|
3759
4720
|
"services/sync.ts"
|
|
3760
4721
|
].map((f) => `${DIR}/${f}`);
|
|
3761
4722
|
/** App files that stay (or are new), and become what this CLI generates. system-roles.ts is the app's config and stays as it is. */
|
|
3762
|
-
const REGENERATED$
|
|
4723
|
+
const REGENERATED$3 = [
|
|
3763
4724
|
`${DIR}/index.ts`,
|
|
3764
4725
|
`${DIR}/db/seed.ts`,
|
|
3765
4726
|
`${DIR}/commands/grant-role.ts`,
|
|
@@ -3825,7 +4786,7 @@ const MOVES = [
|
|
|
3825
4786
|
];
|
|
3826
4787
|
/** Names the 0.5 constants file had that the package dropped. */
|
|
3827
4788
|
const DROPPED_CONSTANTS = ["CACHE_TTL_MS"];
|
|
3828
|
-
async function read$
|
|
4789
|
+
async function read$3(ctx, path) {
|
|
3829
4790
|
return ctx.read(path).catch(() => void 0);
|
|
3830
4791
|
}
|
|
3831
4792
|
const authzPackage = {
|
|
@@ -3837,8 +4798,8 @@ const authzPackage = {
|
|
|
3837
4798
|
await ctx.addDependency(AUTHZ, versionOf(AUTHZ));
|
|
3838
4799
|
const blockers = [];
|
|
3839
4800
|
const regenerate = [];
|
|
3840
|
-
for (const path of REGENERATED$
|
|
3841
|
-
const text = await read$
|
|
4801
|
+
for (const path of REGENERATED$3) {
|
|
4802
|
+
const text = await read$3(ctx, path);
|
|
3842
4803
|
if (text === void 0) {
|
|
3843
4804
|
if (path === `${DIR}/index.ts`) regenerate.push(path);
|
|
3844
4805
|
continue;
|
|
@@ -3848,12 +4809,12 @@ const authzPackage = {
|
|
|
3848
4809
|
}
|
|
3849
4810
|
const removed = [];
|
|
3850
4811
|
for (const path of REMOVED) {
|
|
3851
|
-
const text = await read$
|
|
4812
|
+
const text = await read$3(ctx, path);
|
|
3852
4813
|
if (text === void 0) continue;
|
|
3853
4814
|
removed.push(path);
|
|
3854
4815
|
if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
|
|
3855
4816
|
}
|
|
3856
|
-
const own = /* @__PURE__ */ new Set([...REGENERATED$
|
|
4817
|
+
const own = /* @__PURE__ */ new Set([...REGENERATED$3, ...REMOVED]);
|
|
3857
4818
|
const sources = await glob(["src/**/*.ts", "tests/**/*.ts"], {
|
|
3858
4819
|
cwd: ctx.project.root,
|
|
3859
4820
|
ignore: ["src/generated/**"]
|
|
@@ -4036,13 +4997,13 @@ const MOVED$1 = [
|
|
|
4036
4997
|
}
|
|
4037
4998
|
];
|
|
4038
4999
|
/** App files that stay, but become what this CLI generates (driver re-exports, thin helpers). */
|
|
4039
|
-
const REGENERATED = [
|
|
5000
|
+
const REGENERATED$2 = [
|
|
4040
5001
|
"src/core/db/admin.ts",
|
|
4041
5002
|
"src/core/db/migrate.ts",
|
|
4042
5003
|
"tests/helpers/api.ts",
|
|
4043
5004
|
"tests/global-setup.ts"
|
|
4044
5005
|
];
|
|
4045
|
-
async function read$
|
|
5006
|
+
async function read$2(ctx, path) {
|
|
4046
5007
|
return ctx.read(path).catch(() => void 0);
|
|
4047
5008
|
}
|
|
4048
5009
|
const dbTesting = {
|
|
@@ -4054,8 +5015,8 @@ const dbTesting = {
|
|
|
4054
5015
|
if (hasDb) await ctx.addDependency("@tatara/db", versionOf("@tatara/db"));
|
|
4055
5016
|
await ctx.addDependency("@tatara/testing", versionOf("@tatara/testing"), { dev: true });
|
|
4056
5017
|
const regenerate = [];
|
|
4057
|
-
for (const path of REGENERATED) {
|
|
4058
|
-
const text = await read$
|
|
5018
|
+
for (const path of REGENERATED$2) {
|
|
5019
|
+
const text = await read$2(ctx, path);
|
|
4059
5020
|
if (text === void 0) continue;
|
|
4060
5021
|
if (await ctx.baseline.isUnchanged(path, text)) regenerate.push(path);
|
|
4061
5022
|
else ctx.report.notes.push(`${path} is customised and was kept; compare it with a newly generated app.`);
|
|
@@ -4075,6 +5036,67 @@ const dbTesting = {
|
|
|
4075
5036
|
}
|
|
4076
5037
|
};
|
|
4077
5038
|
//#endregion
|
|
5039
|
+
//#region src/upgrade/codemods/deploy-ledger.ts
|
|
5040
|
+
const REGENERATED$1 = [
|
|
5041
|
+
"src/apps/cli/index.ts",
|
|
5042
|
+
"src/core/db/admin.ts",
|
|
5043
|
+
"src/apps/api/server.ts",
|
|
5044
|
+
"src/core/logging/logger.ts"
|
|
5045
|
+
];
|
|
5046
|
+
const ADDED$1 = ["src/core/commands/db/command-runs.schema.ts", "prisma/models/command-runs.prisma"];
|
|
5047
|
+
/** `deploy`: migrations and deploy/once commands, with a ledger of what ran once. */
|
|
5048
|
+
const deployLedger = {
|
|
5049
|
+
version: "0.11.0",
|
|
5050
|
+
id: "deploy-ledger",
|
|
5051
|
+
description: "A deploy command (migrations, then deploy and once commands) with a command ledger",
|
|
5052
|
+
async run(ctx) {
|
|
5053
|
+
const { config } = ctx.project;
|
|
5054
|
+
const customised = [];
|
|
5055
|
+
for (const path of REGENERATED$1) {
|
|
5056
|
+
const text = await ctx.read(path).catch(() => void 0);
|
|
5057
|
+
if (text === void 0) continue;
|
|
5058
|
+
if (await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
|
|
5059
|
+
else customised.push(path);
|
|
5060
|
+
}
|
|
5061
|
+
let added = false;
|
|
5062
|
+
for (const path of ADDED$1) {
|
|
5063
|
+
if (await ctx.read(path).catch(() => void 0) !== void 0) continue;
|
|
5064
|
+
const text = await ctx.renderCurrent(path);
|
|
5065
|
+
if (text === void 0) continue;
|
|
5066
|
+
await ctx.write(path, text);
|
|
5067
|
+
added = true;
|
|
5068
|
+
}
|
|
5069
|
+
if (customised.length > 0) ctx.report.notes.push(`Compare ${customised.join(", ")} with a newly generated app: runCommand takes { ledger: commandLedger(env.DATABASE_URL) } (commandLedger from #core/db/admin), and the api server takes an options slot.`);
|
|
5070
|
+
if (added) ctx.report.notes.push("MySQL keeps the command ledger in the app's own tatara_command_runs table: run `tatara db generate --name command_runs`.");
|
|
5071
|
+
if (config.database !== "none") ctx.report.notes.push("Deploys now run `node dist/apps/cli/index.mjs deploy` (migrations, then commands with a phase, e.g. authz:sync-permissions) instead of separate db:migrate and sync steps.");
|
|
5072
|
+
}
|
|
5073
|
+
};
|
|
5074
|
+
//#endregion
|
|
5075
|
+
//#region src/upgrade/codemods/email-v2.ts
|
|
5076
|
+
const FILES = [
|
|
5077
|
+
"src/core/email/send.ts",
|
|
5078
|
+
"src/core/config/env.ts",
|
|
5079
|
+
".env.example",
|
|
5080
|
+
".env.test"
|
|
5081
|
+
];
|
|
5082
|
+
/** Mailgun and email templates (`emails/*.ts`, `sendTemplate`) in apps with the email add-on. */
|
|
5083
|
+
const emailV2 = {
|
|
5084
|
+
version: "0.11.0",
|
|
5085
|
+
id: "email-v2",
|
|
5086
|
+
description: "Email: a Mailgun driver and templates (emails/*.ts, sendTemplate)",
|
|
5087
|
+
async run(ctx) {
|
|
5088
|
+
if (!ctx.project.config.addons.includes("email")) return;
|
|
5089
|
+
const customised = [];
|
|
5090
|
+
for (const path of FILES) {
|
|
5091
|
+
const text = await ctx.read(path).catch(() => void 0);
|
|
5092
|
+
if (text === void 0) continue;
|
|
5093
|
+
if (await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
|
|
5094
|
+
else customised.push(path);
|
|
5095
|
+
}
|
|
5096
|
+
if (customised.length > 0) ctx.report.notes.push(`Compare ${customised.join(", ")} with a newly generated app: EMAIL_DRIVER may be "mailgun" (MAILGUN_API_KEY, MAILGUN_DOMAIN, MAILGUN_API_URL), and src/core/email/send.ts exports sendTemplate for emails/*.ts templates.`);
|
|
5097
|
+
}
|
|
5098
|
+
};
|
|
5099
|
+
//#endregion
|
|
4078
5100
|
//#region src/upgrade/codemods/framework-tests.ts
|
|
4079
5101
|
const frameworkTests = {
|
|
4080
5102
|
version: "0.9.0",
|
|
@@ -4089,6 +5111,29 @@ const frameworkTests = {
|
|
|
4089
5111
|
}
|
|
4090
5112
|
};
|
|
4091
5113
|
//#endregion
|
|
5114
|
+
//#region src/upgrade/codemods/http-limits.ts
|
|
5115
|
+
const ENV_FILES = [
|
|
5116
|
+
"src/core/config/env.ts",
|
|
5117
|
+
".env.example",
|
|
5118
|
+
".env.test"
|
|
5119
|
+
];
|
|
5120
|
+
/** Request timeouts and the caller's real address for better-auth, in apps made before 0.11. */
|
|
5121
|
+
const httpLimits = {
|
|
5122
|
+
version: "0.11.0",
|
|
5123
|
+
id: "http-limits",
|
|
5124
|
+
description: "Request timeouts (REQUEST_TIMEOUT_MS) and trusted proxies (TRUSTED_PROXY_HOPS)",
|
|
5125
|
+
async run(ctx) {
|
|
5126
|
+
const customised = [];
|
|
5127
|
+
for (const path of ["src/apps/http.ts", ...ENV_FILES]) {
|
|
5128
|
+
const text = await ctx.read(path).catch(() => void 0);
|
|
5129
|
+
if (text === void 0) continue;
|
|
5130
|
+
if (await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
|
|
5131
|
+
else customised.push(path);
|
|
5132
|
+
}
|
|
5133
|
+
if (customised.length > 0) ctx.report.notes.push(`Compare ${customised.join(", ")} with a newly generated app: env gains REQUEST_TIMEOUT_MS and TRUSTED_PROXY_HOPS; src/apps/http.ts passes timeouts: { defaultMs: env.REQUEST_TIMEOUT_MS } to createApp and hands better-auth the caller's address with withClientIp(c.req.raw, clientIp(c, { trustedHops: env.TRUSTED_PROXY_HOPS })), since better-auth otherwise rate-limits on an X-Forwarded-For the client can forge.`);
|
|
5134
|
+
}
|
|
5135
|
+
};
|
|
5136
|
+
//#endregion
|
|
4092
5137
|
//#region src/upgrade/codemods/http-package.ts
|
|
4093
5138
|
const HTTP = "@tatara/http";
|
|
4094
5139
|
/** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
|
|
@@ -4128,7 +5173,7 @@ const MOVED = [
|
|
|
4128
5173
|
usedBy: SERVERS
|
|
4129
5174
|
}
|
|
4130
5175
|
];
|
|
4131
|
-
async function read(ctx, path) {
|
|
5176
|
+
async function read$1(ctx, path) {
|
|
4132
5177
|
return ctx.read(path).catch(() => void 0);
|
|
4133
5178
|
}
|
|
4134
5179
|
/** Files outside the group that import one of the moved files (they would break). */
|
|
@@ -4164,14 +5209,14 @@ const httpPackage = {
|
|
|
4164
5209
|
ROUTES,
|
|
4165
5210
|
...SERVERS
|
|
4166
5211
|
]) {
|
|
4167
|
-
const text = await read(ctx, path);
|
|
5212
|
+
const text = await read$1(ctx, path);
|
|
4168
5213
|
if (text === void 0) continue;
|
|
4169
5214
|
if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
|
|
4170
5215
|
else blockers.push(`${path} is customised`);
|
|
4171
5216
|
}
|
|
4172
5217
|
const present = [];
|
|
4173
5218
|
for (const entry of MOVED) {
|
|
4174
|
-
const text = await read(ctx, entry.path);
|
|
5219
|
+
const text = await read$1(ctx, entry.path);
|
|
4175
5220
|
if (text === void 0) continue;
|
|
4176
5221
|
present.push(entry);
|
|
4177
5222
|
if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
|
|
@@ -4192,6 +5237,60 @@ const httpPackage = {
|
|
|
4192
5237
|
}
|
|
4193
5238
|
};
|
|
4194
5239
|
//#endregion
|
|
5240
|
+
//#region src/upgrade/codemods/jobs-drivers.ts
|
|
5241
|
+
/** Generated jobs wiring that 0.11 replaces when the app hasn't changed it. */
|
|
5242
|
+
const REGENERATED = [
|
|
5243
|
+
"src/core/jobs/index.ts",
|
|
5244
|
+
"src/apps/worker/index.ts",
|
|
5245
|
+
"src/apps/cron/index.ts",
|
|
5246
|
+
"src/modules/example/tests/jobs.test.ts",
|
|
5247
|
+
"src/core/config/env.ts",
|
|
5248
|
+
".env.example",
|
|
5249
|
+
".env.test"
|
|
5250
|
+
];
|
|
5251
|
+
/** New in 0.11; written only where the app has nothing. */
|
|
5252
|
+
const ADDED = [
|
|
5253
|
+
"src/apps/cron/runner.ts",
|
|
5254
|
+
"src/core/jobs/outbox.ts",
|
|
5255
|
+
"src/core/cache/index.ts",
|
|
5256
|
+
"src/core/http/rate-limit.ts"
|
|
5257
|
+
];
|
|
5258
|
+
async function read(ctx, path) {
|
|
5259
|
+
return ctx.read(path).catch(() => void 0);
|
|
5260
|
+
}
|
|
5261
|
+
/** Jobs over drivers (inline, Postgres outbox, BullMQ, Kafka); schedules lock with the cache. */
|
|
5262
|
+
const jobsDrivers = {
|
|
5263
|
+
version: "0.11.0",
|
|
5264
|
+
id: "jobs-drivers",
|
|
5265
|
+
description: "Jobs drivers (JOBS_DRIVER), typed enqueue, dead letters, schedule triggers; jobs bring the cache",
|
|
5266
|
+
async run(ctx) {
|
|
5267
|
+
const { config } = ctx.project;
|
|
5268
|
+
if (!config.addons.includes("jobs")) return;
|
|
5269
|
+
if (!config.addons.includes("cache")) {
|
|
5270
|
+
config.addons.push("cache");
|
|
5271
|
+
const raw = JSON.parse(await ctx.read("tatara.json"));
|
|
5272
|
+
raw.addons = [.../* @__PURE__ */ new Set([...raw.addons, "cache"])];
|
|
5273
|
+
await ctx.write("tatara.json", `${JSON.stringify(raw, null, " ")}\n`);
|
|
5274
|
+
await ctx.addDependency("@tatara/cache", versionOf("@tatara/cache"));
|
|
5275
|
+
}
|
|
5276
|
+
await ctx.addDependency("kafkajs", versionOf("kafkajs"));
|
|
5277
|
+
for (const path of ADDED) {
|
|
5278
|
+
if (await read(ctx, path) !== void 0) continue;
|
|
5279
|
+
const text = await ctx.renderCurrent(path);
|
|
5280
|
+
if (text !== void 0) await ctx.write(path, text);
|
|
5281
|
+
}
|
|
5282
|
+
const customised = [];
|
|
5283
|
+
for (const path of REGENERATED) {
|
|
5284
|
+
const text = await read(ctx, path);
|
|
5285
|
+
if (text === void 0) continue;
|
|
5286
|
+
if (await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
|
|
5287
|
+
else customised.push(path);
|
|
5288
|
+
}
|
|
5289
|
+
if (customised.length > 0) ctx.report.notes.push(`Compare ${customised.join(", ")} with a newly generated app: env gains CACHE_DRIVER, JOBS_DRIVER, KAFKA_BROKERS, KAFKA_CLIENT_ID, KAFKA_SSL, KAFKA_SASL_USERNAME, KAFKA_SASL_PASSWORD and SCHEDULE_TRIGGER_SECRET; the queue is now \`jobs\` (createJobs over the JOBS_DRIVER driver), startWorker takes { driver: jobs.driver, consumers, … }, and startCron takes the schedule runner in src/apps/cron/runner.ts (it locks with the cache).`);
|
|
5290
|
+
ctx.report.notes.push("Jobs now pick a driver with JOBS_DRIVER (bullmq stays the default; tests use inline). onDeadLetter runs once, after the last attempt. The app now has the cache add-on, which schedules lock with.");
|
|
5291
|
+
}
|
|
5292
|
+
};
|
|
5293
|
+
//#endregion
|
|
4195
5294
|
//#region src/upgrade/codemods/package-commands.ts
|
|
4196
5295
|
/** Copies whose commands and seeds the packages contribute now (wired by `tatara sync`). */
|
|
4197
5296
|
const COPIES = [
|
|
@@ -4238,12 +5337,21 @@ const CODEMODS = [
|
|
|
4238
5337
|
await ctx.removeIfUnchanged(path);
|
|
4239
5338
|
}
|
|
4240
5339
|
}
|
|
4241
|
-
}
|
|
5340
|
+
},
|
|
5341
|
+
appOwnedAuth,
|
|
5342
|
+
httpLimits,
|
|
5343
|
+
jobsDrivers,
|
|
5344
|
+
emailV2,
|
|
5345
|
+
deployLedger
|
|
4242
5346
|
];
|
|
4243
5347
|
//#endregion
|
|
4244
5348
|
//#region src/upgrade/context.ts
|
|
4245
5349
|
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4246
|
-
function createContext(project,
|
|
5350
|
+
function createContext(project, appBaseline, opts = {}) {
|
|
5351
|
+
const ours = /* @__PURE__ */ new Map();
|
|
5352
|
+
const baseline = { async isUnchanged(path, content) {
|
|
5353
|
+
return ours.get(path) === hashContent(content) || appBaseline.isUnchanged(path, content);
|
|
5354
|
+
} };
|
|
4247
5355
|
const report = {
|
|
4248
5356
|
ran: [],
|
|
4249
5357
|
written: [],
|
|
@@ -4263,6 +5371,7 @@ function createContext(project, baseline, opts = {}) {
|
|
|
4263
5371
|
const before = existsSync(abs(path)) ? await readFile(abs(path), "utf8") : void 0;
|
|
4264
5372
|
if (before === void 0 || await baseline.isUnchanged(path, before)) report.regenerated.push(path);
|
|
4265
5373
|
}
|
|
5374
|
+
ours.set(path, hashContent(text));
|
|
4266
5375
|
if (opts.dryRun) return;
|
|
4267
5376
|
await mkdir(dirname(abs(path)), { recursive: true });
|
|
4268
5377
|
await writeFile(abs(path), text);
|
|
@@ -4315,7 +5424,7 @@ function createContext(project, baseline, opts = {}) {
|
|
|
4315
5424
|
addons,
|
|
4316
5425
|
packageManager
|
|
4317
5426
|
});
|
|
4318
|
-
return (await rendered).get(path);
|
|
5427
|
+
return (await rendered).get(currentPath(path));
|
|
4319
5428
|
},
|
|
4320
5429
|
async regenerate(path) {
|
|
4321
5430
|
const next = await ctx.renderCurrent(path);
|
|
@@ -4529,6 +5638,14 @@ program.command("routes").description("List every route and what protects it").o
|
|
|
4529
5638
|
if (flags.json) console.log(JSON.stringify(routes, null, 2));
|
|
4530
5639
|
else printRoutes(routes);
|
|
4531
5640
|
});
|
|
5641
|
+
program.command("contracts").description("Write each app's OpenAPI document and TypeScript types for typed clients").option("--out <dir>", "folder to write them to", "contracts").option("--check", "fail when the files are out of date instead of writing them").action(async (flags) => {
|
|
5642
|
+
const { written, stale } = await writeContracts(loadProject(), flags);
|
|
5643
|
+
for (const file of written) console.log(`${pc.green("✔")} ${file}`);
|
|
5644
|
+
if (stale.length > 0) {
|
|
5645
|
+
console.error(`Out of date: ${stale.join(", ")}. Run tatara contracts.`);
|
|
5646
|
+
process.exitCode = 1;
|
|
5647
|
+
}
|
|
5648
|
+
});
|
|
4532
5649
|
program.command("upgrade").description("Run this CLI version's codemods on the app, then sync and verify").option("--dry-run", "show what would change without writing").option("--no-verify", "skip pnpm verify at the end").action(async (flags) => {
|
|
4533
5650
|
const project = loadProject();
|
|
4534
5651
|
const from = project.config.version;
|