@tatara/cli 0.10.0 → 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 (46) hide show
  1. package/dist/bin.mjs +1161 -312
  2. package/package.json +1 -1
  3. package/templates/features/base/files/AGENTS.md +17 -0
  4. package/templates/features/base/files/biome.template.json +1 -1
  5. package/templates/features/base/files/src/apps/api/server.ts +7 -1
  6. package/templates/features/base/files/src/apps/cli/index.ts +4 -1
  7. package/templates/features/base/files/src/apps/http.ts +6 -0
  8. package/templates/features/base/files/src/core/logging/logger.ts +5 -3
  9. package/templates/features/cache/files/src/core/cache/index.ts +23 -0
  10. package/templates/features/cache/files/src/core/http/rate-limit.ts +15 -0
  11. package/templates/features/cache/files/tests/tatara/cache.test.ts +22 -0
  12. package/templates/features/cache/files/tests/tatara/rate-limit.test.ts +27 -0
  13. package/templates/features/cache/files+postgres/src/core/cache/index.ts +36 -0
  14. package/templates/features/db/files+mysql+drizzle/src/core/commands/db/command-runs.schema.ts +10 -0
  15. package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +6 -1
  16. package/templates/features/db/files+mysql+prisma/prisma/models/command-runs.prisma +9 -0
  17. package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +1 -0
  18. package/templates/features/db/files+postgres/src/core/db/admin.ts +6 -1
  19. package/templates/features/email/files/src/core/email/send.ts +22 -2
  20. package/templates/features/email/files/src/modules/example/emails/welcome.ts +11 -0
  21. package/templates/features/jobs/files/src/apps/cron/index.ts +2 -3
  22. package/templates/features/jobs/files/src/apps/cron/runner.ts +11 -0
  23. package/templates/features/jobs/files/src/apps/worker/index.ts +2 -9
  24. package/templates/features/jobs/files/src/core/jobs/index.ts +37 -6
  25. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +7 -10
  26. package/templates/features/jobs/files+drizzle+postgres/src/core/jobs/outbox.ts +23 -0
  27. package/templates/features/jobs/files+drizzle+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
  28. package/templates/features/jobs/files+postgres/src/core/jobs/index.ts +47 -0
  29. package/templates/features/jobs/files+prisma+postgres/src/core/jobs/outbox.ts +17 -0
  30. package/templates/features/jobs/files+prisma+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
  31. package/templates/features/notifications/files/src/modules/example/notifications/welcome.ts +14 -0
  32. package/templates/features/notifications/files/src/modules/notifications/consumers/deliver.ts +6 -0
  33. package/templates/features/notifications/files/src/modules/notifications/consumers/fanout.ts +6 -0
  34. package/templates/features/notifications/files/src/modules/notifications/routes/notifications.routes.ts +168 -0
  35. package/templates/features/notifications/files/src/modules/notifications/services/notifier.ts +59 -0
  36. package/templates/features/notifications/files/src/modules/notifications/tests/notifications.test.ts +72 -0
  37. package/templates/features/notifications/files+drizzle/src/modules/notifications/services/store.ts +5 -0
  38. package/templates/features/notifications/files+drizzle+mysql/src/modules/notifications/db/notifications.schema.ts +40 -0
  39. package/templates/features/notifications/files+drizzle+postgres/src/modules/notifications/db/notifications.schema.ts +41 -0
  40. package/templates/features/notifications/files+prisma/prisma/models/notifications.prisma +34 -0
  41. package/templates/features/notifications/files+prisma/src/modules/notifications/services/store.ts +4 -0
  42. package/templates/features/observability/files/src/apps/instrument.ts +15 -0
  43. package/templates/features/realtime/files/src/core/realtime/index.ts +29 -0
  44. package/templates/features/realtime/files/src/modules/realtime/routes/realtime.routes.ts +38 -0
  45. package/templates/features/realtime/files/tests/tatara/realtime.test.ts +37 -0
  46. package/templates/versions.json +26 -6
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,34 +1040,6 @@ 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",
@@ -920,22 +1101,44 @@ const authz = {
920
1101
  - Effective permissions = role grants (assigned roles + built-in roles whose key matches an
921
1102
  identity-provider role) + grant overlays − deny overlays. Deny always wins.
922
1103
  - Custom roles and grant overlays can never grant \`role\`, \`member\` or \`audit_log\`.
923
- - Built-in roles live in \`src/core/authz/system-roles.ts\`. Run \`__PM_EXEC__ tatara run authz:sync-permissions\`
924
- 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.
925
1106
  - In tests: \`asUser({ permissions: ["project:read"] })\` or \`asUser({ systemRoles: ["admin"] })\`.
926
1107
  `
927
1108
  }
928
1109
  ]
929
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
+ });
930
1132
  const jobs = {
931
1133
  id: "jobs",
932
1134
  kind: "addon",
933
- label: "Jobs + cron (BullMQ)",
1135
+ label: "Jobs + cron (BullMQ, Postgres or Kafka)",
934
1136
  dependencies: [
935
1137
  "@tatara/jobs",
936
1138
  "bullmq",
937
1139
  "ioredis",
938
- "croner"
1140
+ "croner",
1141
+ "kafkajs"
939
1142
  ],
940
1143
  allowBuilds: ["msgpackr-extract"],
941
1144
  scripts: {
@@ -965,22 +1168,57 @@ const jobs = {
965
1168
  key: "WORKER_CONCURRENCY",
966
1169
  schema: "z.coerce.number().int().positive().default(5)",
967
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"
968
1203
  }
969
1204
  ],
970
- compose: { redis: {
971
- image: "redis:8-alpine",
972
- ports: ["${REDIS_PORT:-6379}:6379"],
973
- healthcheck: {
974
- test: [
975
- "CMD",
976
- "redis-cli",
977
- "ping"
978
- ],
979
- interval: "2s",
980
- timeout: "3s",
981
- retries: 30
982
- }
983
- } },
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 },
984
1222
  slots: [
985
1223
  {
986
1224
  file: "tsdown.config.ts",
@@ -988,141 +1226,647 @@ const jobs = {
988
1226
  content: "\"apps/worker/index\": \"src/apps/worker/index.ts\",\n\"apps/cron/index\": \"src/apps/cron/index.ts\","
989
1227
  },
990
1228
  {
991
- file: "tests/setup.ts",
992
- slot: "setup",
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,
993
1242
  content: [
994
- "",
995
- "// One Redis database per worker.",
996
- "if (process.env.REDIS_URL) {",
997
- " const redisUrl = new URL(process.env.REDIS_URL);",
998
- " redisUrl.pathname = `/${process.env.VITEST_POOL_ID ?? \"1\"}`;",
999
- " process.env.REDIS_URL = redisUrl.toString();",
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
+ " });",
1000
1253
  "}"
1001
1254
  ].join("\n")
1002
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
+ },
1282
+ {
1283
+ file: "tests/setup.ts",
1284
+ slot: "setup",
1285
+ content: [
1286
+ "",
1287
+ "// One Redis database per worker.",
1288
+ "if (process.env.REDIS_URL) {",
1289
+ " const redisUrl = new URL(process.env.REDIS_URL);",
1290
+ " redisUrl.pathname = `/${process.env.VITEST_POOL_ID ?? \"1\"}`;",
1291
+ " process.env.REDIS_URL = redisUrl.toString();",
1292
+ "}"
1293
+ ].join("\n")
1294
+ },
1295
+ {
1296
+ file: "AGENTS.md",
1297
+ slot: "sections",
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
+ },
1003
1759
  {
1004
1760
  file: "AGENTS.md",
1005
1761
  slot: "sections",
1006
- content: `## Jobs and schedules
1762
+ content: `## Realtime
1007
1763
 
1008
- - Background work: \`enqueue(topic, payload)\` from \`#core/jobs/index\`; a module handles it in
1009
- \`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\` from \`@tatara/jobs\`). Payloads are validated by the
1010
- consumer's zod schema. Handlers must be idempotent: jobs retry.
1011
- - Recurring work: \`schedules/<name>.ts\` (\`defineSchedule({ name, cron, run })\`). A Redis lock makes
1012
- each tick run once across every cron process.
1013
- - 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\`.
1014
1774
  `
1015
1775
  }
1016
1776
  ]
1017
1777
  };
1018
- const admin = {
1019
- id: "admin",
1778
+ const observability = {
1779
+ id: "observability",
1020
1780
  kind: "addon",
1021
- label: "Admin API app",
1022
- scripts: { "start:admin": "node dist/apps/admin/server.mjs" },
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" }
1806
+ }],
1023
1807
  env: [
1024
1808
  {
1025
- key: "ADMIN_PORT",
1026
- schema: "z.coerce.number().int().default(3001)",
1027
- example: "3001"
1028
- },
1029
- {
1030
- key: "ADMIN_URL",
1031
- schema: "localDefault(z.url(), \"http://localhost:3001\")",
1032
- example: "http://localhost:3001",
1033
- comment: "Where the admin app is served; staff sign in there"
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"
1034
1813
  },
1035
1814
  {
1036
- key: "SEED_STAFF_EMAIL",
1037
- schema: "z.email().default(\"staff@example.com\")",
1038
- example: "staff@example.com",
1039
- comment: "Development staff account created by `db:seed` (never in production)"
1815
+ key: "OTEL_SERVICE_NAME",
1816
+ schema: "z.string().default(\"__APP_NAME__\")",
1817
+ example: "__APP_NAME__"
1040
1818
  },
1041
1819
  {
1042
- key: "SEED_STAFF_PASSWORD",
1043
- schema: "z.string().min(10).optional()",
1044
- example: "",
1045
- secret: true,
1046
- comment: "Password for the development staff account (generated by tatara new)"
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)"
1047
1824
  }
1048
1825
  ],
1049
1826
  slots: [
1050
- {
1051
- file: "src/core/http/routes.ts",
1052
- slot: "app-names",
1053
- content: "\"admin\","
1054
- },
1055
1827
  {
1056
1828
  file: "tsdown.config.ts",
1057
1829
  slot: "entries",
1058
- content: "\"apps/admin/server\": \"src/apps/admin/server.ts\","
1059
- },
1060
- {
1061
- file: "src/apps/http.ts",
1062
- slot: "imports",
1063
- content: [
1064
- "import { requireRole } from \"@tatara/auth\";",
1065
- "import { except } from \"hono/combine\";",
1066
- "import { attachStaffPrincipal, staffAuth } from \"#modules/staff/services/auth\";"
1067
- ].join("\n")
1830
+ content: "\"apps/instrument\": \"src/apps/instrument.ts\","
1068
1831
  },
1069
1832
  {
1070
- file: "src/apps/http.ts",
1071
- slot: "middleware",
1072
- order: 50,
1073
- content: "app.use(name === \"admin\" ? attachStaffPrincipal : attachPrincipal);"
1833
+ file: "Dockerfile",
1834
+ slot: "docker-runtime",
1835
+ order: 60,
1836
+ content: "ENV NODE_OPTIONS=\"--import /app/dist/apps/instrument.mjs\""
1074
1837
  },
1075
1838
  {
1076
1839
  file: "src/apps/http.ts",
1077
- slot: "routes",
1078
- order: 2,
1079
- content: "if (name === \"admin\") app.on([\"GET\", \"POST\"], `${API_PREFIX}/auth/*`, (c) => staffAuth.handler(c.req.raw));"
1840
+ slot: "imports",
1841
+ content: "import { httpTracing } from \"@tatara/observability\";"
1080
1842
  },
1081
1843
  {
1082
1844
  file: "src/apps/http.ts",
1083
- slot: "routes",
1845
+ slot: "middleware",
1084
1846
  order: 5,
1085
- content: "if (name === \"admin\") app.use(`${API_PREFIX}/*`, except(`${API_PREFIX}/auth/*`, requireRole(\"staff\")));"
1847
+ content: "app.use(httpTracing(env.OTEL_SERVICE_NAME));"
1086
1848
  },
1087
1849
  {
1088
- file: "src/core/authz/system-roles.ts",
1089
- slot: "roles",
1090
- content: "{ key: \"staff\", name: \"Staff\", description: \"Internal staff: platform permissions on the admin app.\" },"
1091
- },
1092
- {
1093
- file: "src/modules/staff/services/auth.ts",
1094
- slot: "resolvers",
1095
- when: ["auth-keycloak"],
1096
- content: "() => (c) => keycloakPrincipal(auth, c.req.header(\"authorization\")),"
1097
- },
1098
- {
1099
- file: "src/modules/staff/services/auth.ts",
1100
- slot: "imports",
1101
- when: ["auth-keycloak"],
1102
- content: "import { keycloakPrincipal } from \"@tatara/keycloak\";\nimport { auth } from \"#modules/users/services/auth\";"
1103
- },
1104
- {
1105
- file: "tests/helpers/api.ts",
1850
+ file: "src/core/logging/logger.ts",
1106
1851
  slot: "imports",
1107
- content: "import { app as adminApp } from \"#apps/admin/app\";"
1852
+ content: "import { traceIds } from \"@tatara/observability\";"
1108
1853
  },
1109
1854
  {
1110
- file: "tests/helpers/api.ts",
1111
- slot: "exports",
1112
- 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,"
1113
1858
  },
1114
1859
  {
1115
1860
  file: "AGENTS.md",
1116
1861
  slot: "sections",
1117
- content: `## Admin app and staff
1862
+ content: `## Observability
1118
1863
 
1119
- - \`apps/admin\` is a second HTTP app on \`ADMIN_PORT\`. Routes with \`defineRoutes({ app: "admin", … })\`
1120
- are served only there, and every \`/api\` route on it needs a staff principal (role \`staff\`).
1121
- - Staff are separate accounts (\`src/modules/staff\`, tables \`staff_*\`), with their own better-auth
1122
- mounted at \`/api/auth\` on the admin app. They never share a table or a session with users.
1123
- There's no staff sign-up; the dev account comes from \`SEED_STAFF_EMAIL\`/\`SEED_STAFF_PASSWORD\`.
1124
- - Staff act in the \`platform\` org, so grant platform permissions to the built-in \`staff\` role.
1125
- - Test it with \`adminApi\` from \`#tests/helpers/api\` and \`asStaff()\` from \`#tests/helpers/staff\`.
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\`.
1126
1870
  `
1127
1871
  }
1128
1872
  ]
@@ -1242,10 +1986,27 @@ const email = {
1242
1986
  env: [
1243
1987
  {
1244
1988
  key: "EMAIL_DRIVER",
1245
- schema: "localDefault(z.enum([\"smtp\", \"console\"]), \"console\")",
1989
+ schema: "localDefault(z.enum([\"smtp\", \"mailgun\", \"console\"]), \"console\")",
1246
1990
  example: "smtp",
1247
1991
  test: "console"
1248
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
+ },
1249
2010
  {
1250
2011
  key: "SMTP_URL",
1251
2012
  schema: "localDefault(z.string(), \"smtp://localhost:1025\")",
@@ -1289,7 +2050,9 @@ const email = {
1289
2050
  content: `## Email
1290
2051
 
1291
2052
  - \`sendEmail({ to, subject, text, html? })\` from \`#core/email/send\`. Locally it goes to Mailpit
1292
- (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)\`.
1293
2056
  `
1294
2057
  }]
1295
2058
  };
@@ -1347,7 +2110,7 @@ const auth = {
1347
2110
  {
1348
2111
  file: "src/apps/http.ts",
1349
2112
  slot: "imports",
1350
- content: "import { attachPrincipal } from \"#modules/users/services/auth\";"
2113
+ content: "import { clientIp, withClientIp } from \"@tatara/http\";\nimport { attachPrincipal } from \"#modules/users/services/auth\";"
1351
2114
  },
1352
2115
  {
1353
2116
  file: "src/apps/http.ts",
@@ -1467,7 +2230,7 @@ const betterAuth = {
1467
2230
  order: 1,
1468
2231
  content: [
1469
2232
  "if (name === \"api\") {",
1470
- " 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 );",
1471
2234
  " if (outbox.enabled) {",
1472
2235
  " app.get(`${API_PREFIX}/dev/outbox`, (c) => c.json({ messages: outbox.read(c.req.query(\"to\")) }));",
1473
2236
  " }",
@@ -1992,6 +2755,18 @@ const base = {
1992
2755
  key: "BODY_LIMIT_BYTES",
1993
2756
  schema: "z.coerce.number().int().positive().default(1_048_576)",
1994
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)"
1995
2770
  }
1996
2771
  ],
1997
2772
  entrypoints: [{
@@ -2163,6 +2938,16 @@ const db$1 = {
2163
2938
  "process.env.DATABASE_URL = workerDatabaseUrl(process.env.DATABASE_URL ?? \"\", process.env.VITEST_POOL_ID ?? \"1\");"
2164
2939
  ].join("\n")
2165
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
+ },
2166
2951
  {
2167
2952
  file: "AGENTS.md",
2168
2953
  slot: "sections",
@@ -2312,20 +3097,31 @@ const FEATURES = Object.fromEntries([
2312
3097
  auth,
2313
3098
  authz,
2314
3099
  jobs,
3100
+ cache,
3101
+ notifications,
3102
+ realtime,
2315
3103
  admin,
2316
3104
  storage,
2317
3105
  email,
2318
- i18n
3106
+ i18n,
3107
+ observability
2319
3108
  ].map((f) => [f.id, f]));
2320
3109
  //#endregion
2321
3110
  //#region src/engine/resolve.ts
2322
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
+ }
2323
3119
  /** Normalizes and validates options; throws OptionsError with a user-facing message. */
2324
3120
  function validateOptions(input) {
2325
3121
  const options = {
2326
3122
  ...input,
2327
3123
  authMethods: [...new Set(input.authMethods)],
2328
- addons: [...new Set(input.addons)]
3124
+ addons: impliedAddons(input.addons)
2329
3125
  };
2330
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.`);
2331
3127
  if (options.orm === "none" !== (options.database === "none")) throw new OptionsError("Choose both an ORM and a database, or neither.");
@@ -2335,8 +3131,8 @@ function validateOptions(input) {
2335
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.");
2336
3132
  } else if (options.authMethods.length > 0) throw new OptionsError("Auth methods only apply to better-auth.");
2337
3133
  for (const addon of options.addons) {
2338
- if ((addon === "authz" || addon === "admin") && options.auth === "none") throw new OptionsError(`The ${addon} add-on needs an auth provider.`);
2339
- if (addon === "authz" && !hasDb) throw new OptionsError("The authz add-on needs a database.");
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.`);
2340
3136
  }
2341
3137
  return options;
2342
3138
  }
@@ -2999,6 +3795,10 @@ function normalizeAuthFlags(flags) {
2999
3795
  const ADDONS = [
3000
3796
  "authz",
3001
3797
  "jobs",
3798
+ "cache",
3799
+ "notifications",
3800
+ "realtime",
3801
+ "observability",
3002
3802
  "admin",
3003
3803
  "storage",
3004
3804
  "email",
@@ -3105,6 +3905,10 @@ async function prompt(name, flags) {
3105
3905
  const labels = {
3106
3906
  authz: "Permission system",
3107
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)",
3108
3912
  admin: "Admin API app",
3109
3913
  storage: "File storage",
3110
3914
  email: "Email",
@@ -3268,111 +4072,6 @@ async function recordGenerated(root) {
3268
4072
  await writeFile(path, `${JSON.stringify(config, null, " ")}\n`);
3269
4073
  }
3270
4074
  //#endregion
3271
- //#region src/commands/routes.ts
3272
- /** Reads every app's OpenAPI document in-process and lists its routes. */
3273
- async function listRoutes(project) {
3274
- await sync(project);
3275
- const apps = [["api", "#apps/api/app"], ...project.has("admin") ? [["admin", "#apps/admin/app"]] : []];
3276
- const code = `
3277
- const out = [];
3278
- for (const [name, spec] of ${JSON.stringify(apps)}) {
3279
- const { app } = await import(spec);
3280
- // Fetch from /openapi.json endpoint to get the document with all extensions applied
3281
- const res = await app.request("/openapi.json");
3282
- const doc = await res.json();
3283
- const securitySchemes = doc.components?.securitySchemes ?? {};
3284
-
3285
- for (const [path, item] of Object.entries(doc.paths ?? {})) {
3286
- for (const [method, op] of Object.entries(item)) {
3287
- const tags = op.tags ?? [];
3288
- const security = op.security ?? [];
3289
-
3290
- // Parse access label based on security schemes and tags
3291
- let access = "public";
3292
-
3293
- // Routes tagged "Auth" are from better-auth's merged document
3294
- if (tags.includes("Auth")) {
3295
- access = "better-auth";
3296
- } else if (security.length > 0) {
3297
- const labels = [];
3298
- for (const requirement of security) {
3299
- const schemes = Object.keys(requirement);
3300
- if (schemes.length === 0) continue;
3301
-
3302
- const schemeName = schemes[0];
3303
- const roles = requirement[schemeName] ?? [];
3304
-
3305
- // For bearer auth with specific roles, show the roles
3306
- if (schemeName === "bearerAuth" && roles.length > 0) {
3307
- labels.push(roles.join(", "));
3308
- }
3309
- // For bearer auth without roles, show "authenticated"
3310
- else if (schemeName === "bearerAuth") {
3311
- labels.push("authenticated");
3312
- }
3313
- // For other schemes (API keys, etc.), show the scheme name
3314
- else {
3315
- labels.push(schemeName);
3316
- }
3317
- }
3318
- access = labels.length > 1 ? labels.join(" OR ") : (labels[0] ?? "authenticated");
3319
- }
3320
-
3321
- const summary = op.summary || op.description || "";
3322
- out.push({ app: name, method: method.toUpperCase(), path, summary, access });
3323
- }
3324
- }
3325
- }
3326
- process.stdout.write(JSON.stringify(out));
3327
- process.exit(0);
3328
- `;
3329
- return new Promise((resolve, reject) => {
3330
- const child = spawn(process.execPath, [
3331
- ...NODE_FLAGS,
3332
- "--input-type=module",
3333
- "-e",
3334
- code
3335
- ], {
3336
- cwd: project.root,
3337
- env: {
3338
- ...process.env,
3339
- LOG_LEVEL: "silent"
3340
- }
3341
- });
3342
- let stdout = "";
3343
- let stderr = "";
3344
- child.stdout.on("data", (d) => {
3345
- stdout += d.toString();
3346
- });
3347
- child.stderr.on("data", (d) => {
3348
- stderr += d.toString();
3349
- });
3350
- child.on("exit", (code) => {
3351
- if (code !== 0) return reject(/* @__PURE__ */ new Error(`Could not load the apps:\n${stderr}`));
3352
- resolve(JSON.parse(stdout));
3353
- });
3354
- });
3355
- }
3356
- function printRoutes(routes) {
3357
- const rows = routes.map((r) => [
3358
- r.app,
3359
- r.method,
3360
- r.path,
3361
- r.access,
3362
- r.summary
3363
- ]);
3364
- const widths = [
3365
- 0,
3366
- 1,
3367
- 2,
3368
- 3
3369
- ].map((i) => Math.max(...rows.map((r) => (r[i] ?? "").length), 4));
3370
- for (const r of rows) {
3371
- const [app = "", method = "", path = "", access = "", summary = ""] = r;
3372
- 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)}`);
3373
- }
3374
- }
3375
- //#endregion
3376
4075
  //#region src/commands/tinker.ts
3377
4076
  /** A REPL with env, logger, db and every module's services loaded. */
3378
4077
  async function tinker(project) {
@@ -3579,7 +4278,7 @@ function importsOf(text, specifier) {
3579
4278
  }
3580
4279
  //#endregion
3581
4280
  //#region src/upgrade/codemods/groups.ts
3582
- async function read$5(ctx, path) {
4281
+ async function read$6(ctx, path) {
3583
4282
  return ctx.read(path).catch(() => void 0);
3584
4283
  }
3585
4284
  function sourceFiles(ctx) {
@@ -3592,7 +4291,7 @@ function sourceFiles(ctx) {
3592
4291
  async function blockersOf$1(ctx, group, sources) {
3593
4292
  const blockers = [];
3594
4293
  for (const path of [...group.regenerate, ...group.remove]) {
3595
- const text = await read$5(ctx, path);
4294
+ const text = await read$6(ctx, path);
3596
4295
  if (text === void 0) continue;
3597
4296
  if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
3598
4297
  }
@@ -3772,12 +4471,12 @@ const SPECIFIERS = {
3772
4471
  "#core/auth/db/organization.schema": "#modules/users/db/organization.schema"
3773
4472
  };
3774
4473
  /** Files whose content this release changes in place (new imports, orgRole, single tenant). */
3775
- const REGENERATED$2 = [
4474
+ const REGENERATED$4 = [
3776
4475
  "src/core/http/context.ts",
3777
4476
  "tests/helpers/auth.ts",
3778
4477
  "src/core/authz/index.ts"
3779
4478
  ];
3780
- async function read$4(ctx, path) {
4479
+ async function read$5(ctx, path) {
3781
4480
  return ctx.read(path).catch(() => void 0);
3782
4481
  }
3783
4482
  /** Inserts the single-tenant option into an app's own createAuthz call, if it lacks one. */
@@ -3796,7 +4495,7 @@ const STAFF_FILES = [
3796
4495
  /** The admin app now signs in staff accounts of its own; the regenerated apps/http.ts imports them. */
3797
4496
  async function addStaff(ctx) {
3798
4497
  for (const path of STAFF_FILES) {
3799
- if (await read$4(ctx, path) !== void 0) continue;
4498
+ if (await read$5(ctx, path) !== void 0) continue;
3800
4499
  const text = await ctx.renderCurrent(path);
3801
4500
  if (text !== void 0) await ctx.write(path, text);
3802
4501
  }
@@ -3806,7 +4505,7 @@ async function addStaff(ctx) {
3806
4505
  ".env.example",
3807
4506
  ".env.test"
3808
4507
  ]) {
3809
- const text = await read$4(ctx, path);
4508
+ const text = await read$5(ctx, path);
3810
4509
  if (text === void 0) continue;
3811
4510
  if (await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
3812
4511
  else customisedEnv.push(path);
@@ -3822,23 +4521,23 @@ const appOwnedAuth = {
3822
4521
  const { config } = ctx.project;
3823
4522
  const orgs = config.authMethods.includes("orgs");
3824
4523
  if (config.addons.includes("authz") && !orgs) {
3825
- const index = await read$4(ctx, "src/core/authz/index.ts");
4524
+ const index = await read$5(ctx, "src/core/authz/index.ts");
3826
4525
  const next = index && withSingleTenant(index);
3827
4526
  if (next && next !== index) await ctx.write("src/core/authz/index.ts", next);
3828
4527
  }
3829
4528
  const blockers = [];
3830
4529
  const moves = [];
3831
4530
  for (const [from, to] of Object.entries(MOVED_IN_0_10)) {
3832
- const text = await read$4(ctx, from);
4531
+ const text = await read$5(ctx, from);
3833
4532
  if (text === void 0) continue;
3834
- const there = await read$4(ctx, to);
4533
+ const there = await read$5(ctx, to);
3835
4534
  if (there !== void 0 && there !== await ctx.renderCurrent(to)) blockers.push(`${to} already exists`);
3836
4535
  else if (await ctx.baseline.isUnchanged(from, text)) moves.push([from, to]);
3837
4536
  else blockers.push(`${from} is customised`);
3838
4537
  }
3839
4538
  const merged = [];
3840
4539
  for (const from of Object.keys(MERGED_IN_0_10)) {
3841
- const text = await read$4(ctx, from);
4540
+ const text = await read$5(ctx, from);
3842
4541
  if (text === void 0) continue;
3843
4542
  if (await ctx.baseline.isUnchanged(from, text)) merged.push(from);
3844
4543
  else blockers.push(`${from} is customised`);
@@ -3854,8 +4553,8 @@ const appOwnedAuth = {
3854
4553
  await ctx.removeIfUnchanged(from);
3855
4554
  }
3856
4555
  for (const from of merged) await ctx.removeIfUnchanged(from);
3857
- for (const path of REGENERATED$2) {
3858
- const text = await read$4(ctx, path);
4556
+ for (const path of REGENERATED$4) {
4557
+ const text = await read$5(ctx, path);
3859
4558
  if (text !== void 0 && await ctx.baseline.isUnchanged(path, text)) await ctx.regenerate(path);
3860
4559
  }
3861
4560
  for (const [from, to] of Object.entries(SPECIFIERS)) await ctx.rewriteImports(from, to);
@@ -3942,7 +4641,7 @@ function groups(keycloak) {
3942
4641
  });
3943
4642
  return all;
3944
4643
  }
3945
- async function read$3(ctx, path) {
4644
+ async function read$4(ctx, path) {
3946
4645
  return ctx.read(path).catch(() => void 0);
3947
4646
  }
3948
4647
  /** True when `text` imports `entry` (or one of its gone names). */
@@ -3956,7 +4655,7 @@ function usesExport(text, entry) {
3956
4655
  async function blockersOf(ctx, group, sources) {
3957
4656
  const blockers = [];
3958
4657
  for (const path of [...group.regenerate, ...group.remove]) {
3959
- const text = await read$3(ctx, path);
4658
+ const text = await read$4(ctx, path);
3960
4659
  if (text === void 0) continue;
3961
4660
  if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
3962
4661
  }
@@ -3990,7 +4689,7 @@ const authPackage = {
3990
4689
  }
3991
4690
  for (const group of ready) {
3992
4691
  for (const path of group.regenerate) {
3993
- if (await read$3(ctx, path) === void 0) continue;
4692
+ if (await read$4(ctx, path) === void 0) continue;
3994
4693
  await ctx.regenerate(path);
3995
4694
  }
3996
4695
  for (const path of group.remove) await ctx.removeIfUnchanged(path);
@@ -4021,7 +4720,7 @@ const REMOVED = [
4021
4720
  "services/sync.ts"
4022
4721
  ].map((f) => `${DIR}/${f}`);
4023
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. */
4024
- const REGENERATED$1 = [
4723
+ const REGENERATED$3 = [
4025
4724
  `${DIR}/index.ts`,
4026
4725
  `${DIR}/db/seed.ts`,
4027
4726
  `${DIR}/commands/grant-role.ts`,
@@ -4087,7 +4786,7 @@ const MOVES = [
4087
4786
  ];
4088
4787
  /** Names the 0.5 constants file had that the package dropped. */
4089
4788
  const DROPPED_CONSTANTS = ["CACHE_TTL_MS"];
4090
- async function read$2(ctx, path) {
4789
+ async function read$3(ctx, path) {
4091
4790
  return ctx.read(path).catch(() => void 0);
4092
4791
  }
4093
4792
  const authzPackage = {
@@ -4099,8 +4798,8 @@ const authzPackage = {
4099
4798
  await ctx.addDependency(AUTHZ, versionOf(AUTHZ));
4100
4799
  const blockers = [];
4101
4800
  const regenerate = [];
4102
- for (const path of REGENERATED$1) {
4103
- const text = await read$2(ctx, path);
4801
+ for (const path of REGENERATED$3) {
4802
+ const text = await read$3(ctx, path);
4104
4803
  if (text === void 0) {
4105
4804
  if (path === `${DIR}/index.ts`) regenerate.push(path);
4106
4805
  continue;
@@ -4110,12 +4809,12 @@ const authzPackage = {
4110
4809
  }
4111
4810
  const removed = [];
4112
4811
  for (const path of REMOVED) {
4113
- const text = await read$2(ctx, path);
4812
+ const text = await read$3(ctx, path);
4114
4813
  if (text === void 0) continue;
4115
4814
  removed.push(path);
4116
4815
  if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
4117
4816
  }
4118
- const own = /* @__PURE__ */ new Set([...REGENERATED$1, ...REMOVED]);
4817
+ const own = /* @__PURE__ */ new Set([...REGENERATED$3, ...REMOVED]);
4119
4818
  const sources = await glob(["src/**/*.ts", "tests/**/*.ts"], {
4120
4819
  cwd: ctx.project.root,
4121
4820
  ignore: ["src/generated/**"]
@@ -4298,13 +4997,13 @@ const MOVED$1 = [
4298
4997
  }
4299
4998
  ];
4300
4999
  /** App files that stay, but become what this CLI generates (driver re-exports, thin helpers). */
4301
- const REGENERATED = [
5000
+ const REGENERATED$2 = [
4302
5001
  "src/core/db/admin.ts",
4303
5002
  "src/core/db/migrate.ts",
4304
5003
  "tests/helpers/api.ts",
4305
5004
  "tests/global-setup.ts"
4306
5005
  ];
4307
- async function read$1(ctx, path) {
5006
+ async function read$2(ctx, path) {
4308
5007
  return ctx.read(path).catch(() => void 0);
4309
5008
  }
4310
5009
  const dbTesting = {
@@ -4316,8 +5015,8 @@ const dbTesting = {
4316
5015
  if (hasDb) await ctx.addDependency("@tatara/db", versionOf("@tatara/db"));
4317
5016
  await ctx.addDependency("@tatara/testing", versionOf("@tatara/testing"), { dev: true });
4318
5017
  const regenerate = [];
4319
- for (const path of REGENERATED) {
4320
- const text = await read$1(ctx, path);
5018
+ for (const path of REGENERATED$2) {
5019
+ const text = await read$2(ctx, path);
4321
5020
  if (text === void 0) continue;
4322
5021
  if (await ctx.baseline.isUnchanged(path, text)) regenerate.push(path);
4323
5022
  else ctx.report.notes.push(`${path} is customised and was kept; compare it with a newly generated app.`);
@@ -4337,6 +5036,67 @@ const dbTesting = {
4337
5036
  }
4338
5037
  };
4339
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
4340
5100
  //#region src/upgrade/codemods/framework-tests.ts
4341
5101
  const frameworkTests = {
4342
5102
  version: "0.9.0",
@@ -4351,6 +5111,29 @@ const frameworkTests = {
4351
5111
  }
4352
5112
  };
4353
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
4354
5137
  //#region src/upgrade/codemods/http-package.ts
4355
5138
  const HTTP = "@tatara/http";
4356
5139
  /** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
@@ -4390,7 +5173,7 @@ const MOVED = [
4390
5173
  usedBy: SERVERS
4391
5174
  }
4392
5175
  ];
4393
- async function read(ctx, path) {
5176
+ async function read$1(ctx, path) {
4394
5177
  return ctx.read(path).catch(() => void 0);
4395
5178
  }
4396
5179
  /** Files outside the group that import one of the moved files (they would break). */
@@ -4426,14 +5209,14 @@ const httpPackage = {
4426
5209
  ROUTES,
4427
5210
  ...SERVERS
4428
5211
  ]) {
4429
- const text = await read(ctx, path);
5212
+ const text = await read$1(ctx, path);
4430
5213
  if (text === void 0) continue;
4431
5214
  if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
4432
5215
  else blockers.push(`${path} is customised`);
4433
5216
  }
4434
5217
  const present = [];
4435
5218
  for (const entry of MOVED) {
4436
- const text = await read(ctx, entry.path);
5219
+ const text = await read$1(ctx, entry.path);
4437
5220
  if (text === void 0) continue;
4438
5221
  present.push(entry);
4439
5222
  if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
@@ -4454,6 +5237,60 @@ const httpPackage = {
4454
5237
  }
4455
5238
  };
4456
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
4457
5294
  //#region src/upgrade/codemods/package-commands.ts
4458
5295
  /** Copies whose commands and seeds the packages contribute now (wired by `tatara sync`). */
4459
5296
  const COPIES = [
@@ -4501,7 +5338,11 @@ const CODEMODS = [
4501
5338
  }
4502
5339
  }
4503
5340
  },
4504
- appOwnedAuth
5341
+ appOwnedAuth,
5342
+ httpLimits,
5343
+ jobsDrivers,
5344
+ emailV2,
5345
+ deployLedger
4505
5346
  ];
4506
5347
  //#endregion
4507
5348
  //#region src/upgrade/context.ts
@@ -4797,6 +5638,14 @@ program.command("routes").description("List every route and what protects it").o
4797
5638
  if (flags.json) console.log(JSON.stringify(routes, null, 2));
4798
5639
  else printRoutes(routes);
4799
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
+ });
4800
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) => {
4801
5650
  const project = loadProject();
4802
5651
  const from = project.config.version;