@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.
Files changed (100) hide show
  1. package/dist/bin.mjs +1436 -319
  2. package/package.json +1 -1
  3. package/templates/features/admin/files/src/apps/admin/app.ts +1 -1
  4. package/templates/features/admin/files/src/modules/example/routes/admin-greeting.routes.ts +2 -2
  5. package/templates/features/admin/files/src/modules/example/tests/admin.test.ts +11 -4
  6. package/templates/features/admin/files/src/modules/staff/db/seed.ts +27 -0
  7. package/templates/features/admin/files/src/modules/staff/services/auth.ts +46 -0
  8. package/templates/features/admin/files/tests/helpers/staff.ts +8 -0
  9. package/templates/features/admin/files+drizzle+mysql/src/modules/staff/db/staff.schema.ts +58 -0
  10. package/templates/features/admin/files+drizzle+mysql/src/modules/staff/services/adapter.ts +13 -0
  11. package/templates/features/admin/files+drizzle+postgres/src/modules/staff/db/staff.schema.ts +57 -0
  12. package/templates/features/admin/files+drizzle+postgres/src/modules/staff/services/adapter.ts +13 -0
  13. package/templates/features/admin/files+prisma/prisma/models/staff.prisma +61 -0
  14. package/templates/features/admin/files+prisma+mysql/src/modules/staff/services/adapter.ts +5 -0
  15. package/templates/features/admin/files+prisma+postgres/src/modules/staff/services/adapter.ts +5 -0
  16. package/templates/features/auth-keycloak/files/keycloak/realm.json +70 -9
  17. package/templates/features/auth-keycloak/files/src/{core/auth → modules/users}/commands/keycloak-token.ts +3 -1
  18. package/templates/features/auth-keycloak/files/{src/core/auth/tests/keycloak-tokens.test.ts → tests/tatara/auth-keycloak-tokens.test.ts} +2 -2
  19. 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
  20. package/templates/features/auth-keycloak/files+authz/{src/core/auth/tests/keycloak-authz.test.ts → tests/tatara/auth-keycloak-authz.test.ts} +3 -2
  21. package/templates/features/auth-orgs/files+drizzle+mysql/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
  22. package/templates/features/auth-orgs/files+drizzle+postgres/src/{core/auth → modules/users}/db/organization.schema.ts +1 -1
  23. package/templates/features/authz/files/src/core/authz/system-roles.ts +1 -0
  24. package/templates/features/authz/files/src/{core/authz → modules/users}/routes/authorization.routes.ts +4 -1
  25. package/templates/features/authz/files/{src/core/authz/tests → tests/tatara}/authz.test.ts +4 -2
  26. package/templates/features/authz/files+drizzle/src/core/authz/index.ts +1 -0
  27. package/templates/features/authz/files+prisma/src/core/authz/index.ts +1 -0
  28. package/templates/features/base/files/AGENTS.md +17 -0
  29. package/templates/features/base/files/biome.template.json +1 -1
  30. package/templates/features/base/files/src/apps/api/app.ts +1 -1
  31. package/templates/features/base/files/src/apps/api/server.ts +7 -1
  32. package/templates/features/base/files/src/apps/cli/index.ts +4 -1
  33. package/templates/features/base/files/src/{core/http/app.ts → apps/http.ts} +6 -0
  34. package/templates/features/base/files/src/core/http/context.ts +2 -0
  35. package/templates/features/base/files/src/core/logging/logger.ts +5 -3
  36. package/templates/features/better-auth/files/src/{core/auth → modules/users}/db/seed.ts +1 -1
  37. package/templates/features/better-auth/files/src/modules/users/services/auth.ts +37 -0
  38. package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/delivery.ts +1 -1
  39. package/templates/features/better-auth/files/tests/helpers/auth.ts +4 -3
  40. package/templates/features/better-auth/files+admin/tests/tatara/auth-openapi-admin.test.ts +10 -0
  41. package/templates/features/better-auth/files+auth-orgs/{src/core/auth/tests/principal.test.ts → tests/tatara/auth-principal.test.ts} +1 -1
  42. 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
  43. package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users/services}/adapter.ts +1 -1
  44. package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users/services}/adapter.ts +1 -1
  45. package/templates/features/cache/files/src/core/cache/index.ts +23 -0
  46. package/templates/features/cache/files/src/core/http/rate-limit.ts +15 -0
  47. package/templates/features/cache/files/tests/tatara/cache.test.ts +22 -0
  48. package/templates/features/cache/files/tests/tatara/rate-limit.test.ts +27 -0
  49. package/templates/features/cache/files+postgres/src/core/cache/index.ts +36 -0
  50. package/templates/features/db/files+mysql+drizzle/src/core/commands/db/command-runs.schema.ts +10 -0
  51. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +6 -1
  52. package/templates/features/db/files+mysql+prisma/prisma/models/command-runs.prisma +9 -0
  53. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +1 -0
  54. package/templates/features/db/files+postgres/src/core/db/admin.ts +6 -1
  55. package/templates/features/email/files/src/core/email/send.ts +22 -2
  56. package/templates/features/email/files/src/modules/example/emails/welcome.ts +11 -0
  57. package/templates/features/jobs/files/src/apps/cron/index.ts +2 -3
  58. package/templates/features/jobs/files/src/apps/cron/runner.ts +11 -0
  59. package/templates/features/jobs/files/src/apps/worker/index.ts +2 -9
  60. package/templates/features/jobs/files/src/core/jobs/index.ts +37 -6
  61. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +7 -10
  62. package/templates/features/jobs/files+drizzle+postgres/src/core/jobs/outbox.ts +23 -0
  63. package/templates/features/jobs/files+drizzle+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
  64. package/templates/features/jobs/files+postgres/src/core/jobs/index.ts +47 -0
  65. package/templates/features/jobs/files+prisma+postgres/src/core/jobs/outbox.ts +17 -0
  66. package/templates/features/jobs/files+prisma+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
  67. package/templates/features/notifications/files/src/modules/example/notifications/welcome.ts +14 -0
  68. package/templates/features/notifications/files/src/modules/notifications/consumers/deliver.ts +6 -0
  69. package/templates/features/notifications/files/src/modules/notifications/consumers/fanout.ts +6 -0
  70. package/templates/features/notifications/files/src/modules/notifications/routes/notifications.routes.ts +168 -0
  71. package/templates/features/notifications/files/src/modules/notifications/services/notifier.ts +59 -0
  72. package/templates/features/notifications/files/src/modules/notifications/tests/notifications.test.ts +72 -0
  73. package/templates/features/notifications/files+drizzle/src/modules/notifications/services/store.ts +5 -0
  74. package/templates/features/notifications/files+drizzle+mysql/src/modules/notifications/db/notifications.schema.ts +40 -0
  75. package/templates/features/notifications/files+drizzle+postgres/src/modules/notifications/db/notifications.schema.ts +41 -0
  76. package/templates/features/notifications/files+prisma/prisma/models/notifications.prisma +34 -0
  77. package/templates/features/notifications/files+prisma/src/modules/notifications/services/store.ts +4 -0
  78. package/templates/features/observability/files/src/apps/instrument.ts +15 -0
  79. package/templates/features/realtime/files/src/core/realtime/index.ts +29 -0
  80. package/templates/features/realtime/files/src/modules/realtime/routes/realtime.routes.ts +38 -0
  81. package/templates/features/realtime/files/tests/tatara/realtime.test.ts +37 -0
  82. package/templates/versions.json +28 -8
  83. package/templates/features/better-auth/files/src/core/auth/auth.ts +0 -26
  84. package/templates/features/better-auth/files/src/core/auth/principal.ts +0 -10
  85. package/templates/features/better-auth/files+admin/src/core/auth/tests/openapi-admin.test.ts +0 -8
  86. /package/templates/features/auth/files/src/{core/auth → modules/users}/routes/me.routes.ts +0 -0
  87. /package/templates/features/auth/files/{src/core/auth/tests/me.test.ts → tests/tatara/auth-me.test.ts} +0 -0
  88. /package/templates/features/auth-keycloak/files/src/{core/auth → modules/users/services}/keycloak.ts +0 -0
  89. /package/templates/features/authz/files/{src/core/authz/tests/error-messages.test.ts → tests/tatara/authz-error-messages.test.ts} +0 -0
  90. /package/templates/features/authz/files/{src/core/authz/tests/request-body.test.ts → tests/tatara/authz-request-body.test.ts} +0 -0
  91. /package/templates/features/base/files/{src/core/config/tests/env.test.ts → tests/tatara/config-env.test.ts} +0 -0
  92. /package/templates/features/better-auth/files/src/{core/auth → modules/users/services}/sms.ts +0 -0
  93. /package/templates/features/better-auth/files/{src/core/auth/tests/openapi.test.ts → tests/tatara/auth-openapi.test.ts} +0 -0
  94. /package/templates/features/better-auth/files+drizzle+mysql/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
  95. /package/templates/features/better-auth/files+drizzle+postgres/src/{core/auth → modules/users}/db/auth.schema.ts +0 -0
  96. /package/templates/features/better-auth/files+prisma+mysql/src/{core/auth → modules/users/services}/adapter.ts +0 -0
  97. /package/templates/features/better-auth/files+prisma+postgres/src/{core/auth → modules/users/services}/adapter.ts +0 -0
  98. /package/templates/features/email/files/{src/core/email/tests → tests/tatara}/email.test.ts +0 -0
  99. /package/templates/features/i18n/files/{src/core/i18n/tests → tests/tatara}/i18n.test.ts +0 -0
  100. /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"), listModule(consumers, {
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: "options",
881
- when: ["better-auth"],
882
- content: "orgId?: string;"
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
- content: "await applyTestAccess(user.userId, options);"
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\`. Run \`__PM_EXEC__ tatara run authz:sync-permissions\`
905
- on every deploy (it is idempotent); \`db:seed\` runs it locally.
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
- compose: { redis: {
952
- image: "redis:8-alpine",
953
- ports: ["${REDIS_PORT:-6379}:6379"],
954
- healthcheck: {
955
- test: [
956
- "CMD",
957
- "redis-cli",
958
- "ping"
959
- ],
960
- interval: "2s",
961
- timeout: "3s",
962
- retries: 30
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
- - Background work: \`enqueue(topic, payload)\` from \`#core/jobs/index\`; a module handles it in
990
- \`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\` from \`@tatara/jobs\`). Payloads are validated by the
991
- consumer's zod schema. Handlers must be idempotent: jobs retry.
992
- - Recurring work: \`schedules/<name>.ts\` (\`defineSchedule({ name, cron, run })\`). A Redis lock makes
993
- each tick run once across every cron process.
994
- - Processes: \`apps/worker\` runs consumers, \`apps/cron\` runs schedules. \`dev\` starts both.
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 admin = {
1000
- id: "admin",
1778
+ const observability = {
1779
+ id: "observability",
1001
1780
  kind: "addon",
1002
- label: "Admin API app",
1003
- scripts: { "start:admin": "node dist/apps/admin/server.mjs" },
1004
- env: [{
1005
- key: "ADMIN_PORT",
1006
- schema: "z.coerce.number().int().default(3001)",
1007
- example: "3001"
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
- slots: [
1807
+ env: [
1010
1808
  {
1011
- file: "src/core/http/routes.ts",
1012
- slot: "app-names",
1013
- content: "\"admin\","
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/admin/server\": \"src/apps/admin/server.ts\","
1830
+ content: "\"apps/instrument\": \"src/apps/instrument.ts\","
1019
1831
  },
1020
1832
  {
1021
- file: "src/core/http/app.ts",
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 { requireRole } from \"@tatara/auth\";"
1841
+ content: "import { httpTracing } from \"@tatara/observability\";"
1024
1842
  },
1025
1843
  {
1026
- file: "src/core/http/app.ts",
1027
- slot: "routes",
1844
+ file: "src/apps/http.ts",
1845
+ slot: "middleware",
1028
1846
  order: 5,
1029
- content: "if (name === \"admin\") app.use(`${API_PREFIX}/*`, requireRole(\"admin\"));"
1847
+ content: "app.use(httpTracing(env.OTEL_SERVICE_NAME));"
1030
1848
  },
1031
1849
  {
1032
- file: "tests/helpers/api.ts",
1850
+ file: "src/core/logging/logger.ts",
1033
1851
  slot: "imports",
1034
- content: "import { app as adminApp } from \"#apps/admin/app\";"
1852
+ content: "import { traceIds } from \"@tatara/observability\";"
1035
1853
  },
1036
1854
  {
1037
- file: "tests/helpers/api.ts",
1038
- slot: "exports",
1039
- content: "\n/** Calls the admin app in-process. */\nexport const adminApi = client(adminApp);"
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: `## Admin app
1862
+ content: `## Observability
1045
1863
 
1046
- - \`apps/admin\` is a second HTTP app on \`ADMIN_PORT\`. Routes with \`defineRoutes({ app: "admin", … })\`
1047
- are served only there, and every \`/api\` route on it requires the \`admin\` role.
1048
- - Test it with \`adminApi\` from \`#tests/helpers/api\`.
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/core/http/app.ts",
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/core/http/app.ts",
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/core/http/app.ts",
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/core/http/app.ts",
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/core/http/app.ts",
2111
+ file: "src/apps/http.ts",
1272
2112
  slot: "imports",
1273
- content: "import { attachPrincipal } from \"#core/auth/principal\";"
2113
+ content: "import { clientIp, withClientIp } from \"@tatara/http\";\nimport { attachPrincipal } from \"#modules/users/services/auth\";"
1274
2114
  },
1275
2115
  {
1276
- file: "src/core/http/app.ts",
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/core/http/app.ts",
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/core/auth/sms.ts\`.
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/core/http/app.ts",
2187
+ file: "src/apps/http.ts",
1347
2188
  slot: "imports",
1348
- content: "import { auth } from \"#core/auth/auth\";"
2189
+ content: "import { auth } from \"#modules/users/services/auth\";"
1349
2190
  },
1350
2191
  {
1351
- file: "src/core/http/app.ts",
2192
+ file: "src/apps/http.ts",
1352
2193
  slot: "imports",
1353
- content: "import { outbox } from \"#core/auth/delivery\";"
2194
+ content: "import { outbox } from \"#modules/users/services/delivery\";"
1354
2195
  },
1355
2196
  {
1356
- file: "src/core/http/app.ts",
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/core/http/app.ts",
2202
+ file: "src/apps/http.ts",
1362
2203
  slot: "expose-headers",
1363
2204
  content: "\"set-auth-token\","
1364
2205
  },
1365
2206
  {
1366
- file: "src/core/http/app.ts",
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/core/http/app.ts",
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) => auth.handler(c.req.raw));",
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/core/auth/delivery.ts",
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/core/auth/delivery.ts",
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/core/auth/delivery.ts",
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/core/auth/delivery.ts",
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/core/auth/auth.ts",
2283
+ file: "src/modules/users/services/auth.ts",
1425
2284
  slot: "imports",
1426
- content: "import { deliverEmail } from \"#core/auth/delivery\";"
2285
+ content: "import { deliverEmail } from \"#modules/users/services/delivery\";"
1427
2286
  },
1428
2287
  {
1429
- file: "src/core/auth/auth.ts",
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/core/auth/auth.ts",
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/core/auth/db/auth.schema.ts",
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/core/auth/db/auth.schema.ts",
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/core/auth/auth.ts",
2335
+ file: "src/modules/users/services/auth.ts",
1477
2336
  slot: "imports",
1478
- content: "import { deliverSms } from \"#core/auth/delivery\";"
2337
+ content: "import { deliverSms } from \"#modules/users/services/delivery\";"
1479
2338
  },
1480
2339
  {
1481
- file: "src/core/auth/auth.ts",
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/core/auth/auth.ts",
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/core/auth/auth.ts",
2375
+ file: "src/modules/users/services/auth.ts",
1517
2376
  slot: "imports",
1518
- content: "import { deliverEmail } from \"#core/auth/delivery\";"
2377
+ content: "import { deliverEmail } from \"#modules/users/services/delivery\";"
1519
2378
  },
1520
2379
  {
1521
- file: "src/core/auth/auth.ts",
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/core/auth/db/seed.ts",
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/core/auth/db/auth.schema.ts",
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/core/auth/db/auth.schema.ts",
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/core/auth/db/auth.schema.ts",
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/core/auth/db/auth.schema.ts",
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/core/auth/adapter.ts",
2454
+ file: "src/modules/users/services/adapter.ts",
1596
2455
  slot: "imports",
1597
2456
  when: ["drizzle"],
1598
- content: "import * as orgSchema from \"#core/auth/db/organization.schema\";"
2457
+ content: "import * as orgSchema from \"#modules/users/db/organization.schema\";"
1599
2458
  },
1600
2459
  {
1601
- file: "src/core/auth/adapter.ts",
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/core/auth/auth.ts",
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/core/auth/auth.ts",
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/core/auth/auth.ts",
2503
+ file: "src/modules/users/services/auth.ts",
1626
2504
  slot: "plugins",
1627
2505
  when: ["authz"],
1628
2506
  content: [
1629
- "organization({",
1630
- " organizationHooks: {",
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/core/auth/auth.ts",
2514
+ file: "src/modules/users/services/auth.ts",
1643
2515
  slot: "imports",
1644
2516
  when: ["authz"],
1645
- content: "import { removeMemberAccess } from \"#core/authz/index\";"
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: "src/core/auth/db/seed.ts",
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/core/auth/auth.ts",
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/core/auth/auth.ts",
2603
+ file: "src/modules/users/services/auth.ts",
1717
2604
  slot: "imports",
1718
- content: "import { keycloak } from \"#core/auth/keycloak\";"
2605
+ content: "import { keycloak } from \"#modules/users/services/keycloak\";"
1719
2606
  },
1720
2607
  {
1721
- file: "src/core/auth/auth.ts",
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/core/auth/auth.ts",
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/core/auth/auth.ts",
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/core/auth/principal.ts",
2627
+ file: "src/modules/users/services/auth.ts",
1741
2628
  slot: "resolvers",
1742
- content: "(c) => keycloakPrincipal(auth, c.req.header(\"authorization\")),"
2629
+ content: "(instance) => (c) => keycloakPrincipal(instance, c.req.header(\"authorization\")),"
1743
2630
  },
1744
2631
  {
1745
- file: "src/core/auth/principal.ts",
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 \"#core/auth/keycloak\";"
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: [...new Set(input.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("The authz add-on needs a database.");
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
- for (const [route, action] of Object.entries(ACTIONS)) add(`guard-${route}`, `middleware: [requirePermission("${n.kebab}:${action}")] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
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$4(ctx, path) {
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$4(ctx, path);
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$3(ctx, path) {
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$3(ctx, path);
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$3(ctx, path) === void 0) continue;
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$1 = [
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$2(ctx, path) {
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$1) {
3841
- const text = await read$2(ctx, path);
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$2(ctx, path);
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$1, ...REMOVED]);
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$1(ctx, path) {
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$1(ctx, path);
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, baseline, opts = {}) {
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;