@tatara/cli 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +1161 -312
- package/package.json +1 -1
- package/templates/features/base/files/AGENTS.md +17 -0
- package/templates/features/base/files/biome.template.json +1 -1
- package/templates/features/base/files/src/apps/api/server.ts +7 -1
- package/templates/features/base/files/src/apps/cli/index.ts +4 -1
- package/templates/features/base/files/src/apps/http.ts +6 -0
- package/templates/features/base/files/src/core/logging/logger.ts +5 -3
- package/templates/features/cache/files/src/core/cache/index.ts +23 -0
- package/templates/features/cache/files/src/core/http/rate-limit.ts +15 -0
- package/templates/features/cache/files/tests/tatara/cache.test.ts +22 -0
- package/templates/features/cache/files/tests/tatara/rate-limit.test.ts +27 -0
- package/templates/features/cache/files+postgres/src/core/cache/index.ts +36 -0
- package/templates/features/db/files+mysql+drizzle/src/core/commands/db/command-runs.schema.ts +10 -0
- package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +6 -1
- package/templates/features/db/files+mysql+prisma/prisma/models/command-runs.prisma +9 -0
- package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +1 -0
- package/templates/features/db/files+postgres/src/core/db/admin.ts +6 -1
- package/templates/features/email/files/src/core/email/send.ts +23 -2
- package/templates/features/email/files/src/modules/example/emails/welcome.ts +11 -0
- package/templates/features/jobs/files/src/apps/cron/index.ts +2 -3
- package/templates/features/jobs/files/src/apps/cron/runner.ts +11 -0
- package/templates/features/jobs/files/src/apps/worker/index.ts +2 -9
- package/templates/features/jobs/files/src/core/jobs/index.ts +37 -6
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +7 -10
- package/templates/features/jobs/files+drizzle+postgres/src/core/jobs/outbox.ts +23 -0
- package/templates/features/jobs/files+drizzle+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
- package/templates/features/jobs/files+postgres/src/core/jobs/index.ts +47 -0
- package/templates/features/jobs/files+prisma+postgres/src/core/jobs/outbox.ts +17 -0
- package/templates/features/jobs/files+prisma+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
- package/templates/features/notifications/files/src/modules/example/notifications/welcome.ts +14 -0
- package/templates/features/notifications/files/src/modules/notifications/consumers/deliver.ts +6 -0
- package/templates/features/notifications/files/src/modules/notifications/consumers/fanout.ts +6 -0
- package/templates/features/notifications/files/src/modules/notifications/routes/notifications.routes.ts +168 -0
- package/templates/features/notifications/files/src/modules/notifications/services/notifier.ts +59 -0
- package/templates/features/notifications/files/src/modules/notifications/tests/notifications.test.ts +72 -0
- package/templates/features/notifications/files+drizzle/src/modules/notifications/services/store.ts +5 -0
- package/templates/features/notifications/files+drizzle+mysql/src/modules/notifications/db/notifications.schema.ts +40 -0
- package/templates/features/notifications/files+drizzle+postgres/src/modules/notifications/db/notifications.schema.ts +41 -0
- package/templates/features/notifications/files+prisma/prisma/models/notifications.prisma +34 -0
- package/templates/features/notifications/files+prisma/src/modules/notifications/services/store.ts +4 -0
- package/templates/features/observability/files/src/apps/instrument.ts +15 -0
- package/templates/features/realtime/files/src/core/realtime/index.ts +29 -0
- package/templates/features/realtime/files/src/modules/realtime/routes/realtime.routes.ts +38 -0
- package/templates/features/realtime/files/tests/tatara/realtime.test.ts +37 -0
- package/templates/versions.json +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"),
|
|
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\`.
|
|
924
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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: "
|
|
992
|
-
slot: "
|
|
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
|
-
"
|
|
996
|
-
"
|
|
997
|
-
"
|
|
998
|
-
"
|
|
999
|
-
"
|
|
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: `##
|
|
1762
|
+
content: `## Realtime
|
|
1007
1763
|
|
|
1008
|
-
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
|
1019
|
-
id: "
|
|
1778
|
+
const observability = {
|
|
1779
|
+
id: "observability",
|
|
1020
1780
|
kind: "addon",
|
|
1021
|
-
label: "
|
|
1022
|
-
|
|
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: "
|
|
1026
|
-
schema: "z.
|
|
1027
|
-
example: "
|
|
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: "
|
|
1037
|
-
schema: "z.
|
|
1038
|
-
example: "
|
|
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: "
|
|
1043
|
-
schema: "z.
|
|
1044
|
-
example: "",
|
|
1045
|
-
|
|
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/
|
|
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: "
|
|
1071
|
-
slot: "
|
|
1072
|
-
order:
|
|
1073
|
-
content: "app.
|
|
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: "
|
|
1078
|
-
|
|
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: "
|
|
1845
|
+
slot: "middleware",
|
|
1084
1846
|
order: 5,
|
|
1085
|
-
content: "
|
|
1847
|
+
content: "app.use(httpTracing(env.OTEL_SERVICE_NAME));"
|
|
1086
1848
|
},
|
|
1087
1849
|
{
|
|
1088
|
-
file: "src/core/
|
|
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 {
|
|
1852
|
+
content: "import { traceIds } from \"@tatara/observability\";"
|
|
1108
1853
|
},
|
|
1109
1854
|
{
|
|
1110
|
-
file: "
|
|
1111
|
-
slot: "
|
|
1112
|
-
content: "
|
|
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: `##
|
|
1862
|
+
content: `## Observability
|
|
1118
1863
|
|
|
1119
|
-
- \`
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
-
|
|
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)
|
|
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:
|
|
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(
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
4531
|
+
const text = await read$5(ctx, from);
|
|
3833
4532
|
if (text === void 0) continue;
|
|
3834
|
-
const there = await read$
|
|
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$
|
|
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$
|
|
3858
|
-
const text = await read$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
4103
|
-
const text = await read$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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;
|