@tatara/cli 0.8.0 → 0.9.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
2
+ import { Command, Option } from "commander";
3
3
  import pc from "picocolors";
4
4
  import { spawn } from "node:child_process";
5
5
  import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
@@ -239,6 +239,55 @@ ${imports.join("\n")}${imports.length ? "\n" : ""}
239
239
  export const ${options.exportName}: ${options.typeName}[] = [${names.join(", ")}];
240
240
  `;
241
241
  }
242
+ /** What the app's installed (or ejected) @tatara/* packages contribute, by package name. */
243
+ async function contributions(project, kind) {
244
+ const pkg = JSON.parse(await readFile(join(project.root, "package.json"), "utf8"));
245
+ const ejected = ejectedPackages(project.config);
246
+ const names = [...Object.keys({
247
+ ...pkg.dependencies,
248
+ ...pkg.devDependencies
249
+ }).filter((n) => n.startsWith("@tatara/")).map((n) => n.slice(8)), ...ejected];
250
+ const out = [];
251
+ for (const name of [...new Set(names)].sort()) {
252
+ const file = ejected.includes(name) ? join(project.root, "src/ejected", name, "tatara-package.json") : join(project.root, "node_modules/@tatara", name, "package.json");
253
+ if (!existsSync(file)) continue;
254
+ const json = JSON.parse(await readFile(file, "utf8"));
255
+ out.push(...(ejected.includes(name) ? json[kind] : json.tatara?.[kind]) ?? []);
256
+ }
257
+ return out;
258
+ }
259
+ /**
260
+ * Like listModule, plus the packages' factories: `own` are the app's files, and an own entry wins
261
+ * over a package's entry with the same name.
262
+ */
263
+ function listWithPackages(files, from, options) {
264
+ if (from.length === 0) return listModule(files, options);
265
+ const imports = /* @__PURE__ */ new Map();
266
+ const bind = (name, spec) => {
267
+ const existing = imports.get(name);
268
+ if (existing && existing !== spec) throw new Error(`tatara packages need "${name}" from both ${existing} and ${spec}`);
269
+ imports.set(name, spec);
270
+ };
271
+ for (const c of from) {
272
+ bind(c.factory, c.import);
273
+ for (const [arg, spec] of Object.entries(c.args)) bind(arg, spec);
274
+ }
275
+ const named = [...imports].map(([name, spec]) => `import { ${name} } from "${spec}";`);
276
+ const own = files.map((f) => `import ${identifier(f, options.prefix)} from "${specifier(f)}";`);
277
+ const calls = from.map((c) => `...${c.factory}({ ${Object.keys(c.args).join(", ")} })`);
278
+ const t = options.typeName;
279
+ return `${HEADER}${options.typeImport}
280
+ ${[...named, ...own].join("\n")}
281
+
282
+ const own: ${t}[] = [${files.map((f) => identifier(f, options.prefix)).join(", ")}];
283
+ // from the installed @tatara/* packages; the app's own entry of the same name wins
284
+ const fromPackages: ${t}[] = [${calls.join(", ")}];
285
+ export const ${options.exportName}: ${t}[] = [
286
+ ...own,
287
+ ...fromPackages.filter((c) => !own.some((o) => o.name === c.name)),
288
+ ];
289
+ `;
290
+ }
242
291
  async function writeIfChanged(path, content, written) {
243
292
  if ((existsSync(path) ? await readFile(path, "utf8") : void 0) !== content) {
244
293
  await writeFile(path, content);
@@ -261,7 +310,7 @@ async function sync(project = loadProject()) {
261
310
  exportName: "routes"
262
311
  }), written);
263
312
  const commands = await find(root, dirs.map((d) => `${d}/commands/*.ts`));
264
- await write(join(out, "commands.ts"), listModule(commands, {
313
+ await write(join(out, "commands.ts"), listWithPackages(commands, await contributions(project, "commands"), {
265
314
  prefix: "command",
266
315
  typeImport: "import type { CommandDefinition } from \"@tatara/core\";",
267
316
  typeName: "CommandDefinition",
@@ -297,7 +346,7 @@ export type PermissionKey =
297
346
  }
298
347
  if (project.config.database !== "none") {
299
348
  const seeds = await find(root, dirs.map((d) => `${d}/db/seed.ts`));
300
- await write(join(out, "seeds.ts"), listModule(seeds, {
349
+ await write(join(out, "seeds.ts"), listWithPackages(seeds, await contributions(project, "seeds"), {
301
350
  prefix: "seed",
302
351
  typeImport: "import type { SeedDefinition } from \"@tatara/db\";",
303
352
  typeName: "SeedDefinition",
@@ -722,6 +771,10 @@ async function eject(project, name, opts = {}) {
722
771
  await cp(join(source, file), join(project.root, to));
723
772
  report.copied.push(to);
724
773
  }
774
+ if (manifest.tatara) {
775
+ const to = `src/ejected/${dep}/tatara-package.json`;
776
+ await writeFile(join(project.root, to), `${JSON.stringify(manifest.tatara, null, " ")}\n`);
777
+ }
725
778
  }
726
779
  const ejected = [...already, ...chain];
727
780
  for (const file of await glob(["src/**/*.ts", "tests/**/*.ts"], { cwd: project.root })) {
@@ -2383,6 +2436,10 @@ function pnpmWorkspace(features) {
2383
2436
  lines.push("# tatara's own packages are installed as soon as they are released", "minimumReleaseAgeExclude:", " - \"@tatara/*\"");
2384
2437
  return `${lines.join("\n")}\n`;
2385
2438
  }
2439
+ /** Tests of tatara's wiring, not of the app; generated apps leave them out. */
2440
+ function isFrameworkTest(path) {
2441
+ return /^src\/core\/.+\/tests\/[^/]+\.test\.ts$/.test(path);
2442
+ }
2386
2443
  /** Folds applicable variants into each feature so later steps see one flat definition. */
2387
2444
  function effectiveFeatures(options) {
2388
2445
  const features = resolveFeatures(options);
@@ -2406,6 +2463,9 @@ async function renderApp(options, generate = {}) {
2406
2463
  const selected = new Set(features.map((f) => f.id));
2407
2464
  const tokenValues = tokens(options);
2408
2465
  const files = await collectTemplateFiles(features, selected);
2466
+ if (!generate.frameworkTests) {
2467
+ for (const path of files.keys()) if (isFrameworkTest(path)) files.delete(path);
2468
+ }
2409
2469
  const env = dedupeEnv(features, tokenValues);
2410
2470
  files.set("src/core/config/env.ts", envModule(env.filter((v) => !v.composeOnly)));
2411
2471
  files.set(".env.example", envFile(env, "example"));
@@ -2998,7 +3058,10 @@ async function newApp(dir, rawFlags) {
2998
3058
  const name = root.split(/[\\/]/).at(-1) ?? "app";
2999
3059
  const options = validateOptions(interactive ? await prompt(name, flags) : fromFlags(name, flags));
3000
3060
  const pm = pmCommands(options.packageManager);
3001
- await writeFiles(root, await renderApp(options, { pmVersion: await pmVersion(options.packageManager) }), { env: devEnvFromExample(options) });
3061
+ await writeFiles(root, await renderApp(options, {
3062
+ pmVersion: await pmVersion(options.packageManager),
3063
+ frameworkTests: flags.frameworkTests
3064
+ }), { env: devEnvFromExample(options) });
3002
3065
  const project = loadProject(root);
3003
3066
  await afterCreate(project, "files");
3004
3067
  if (flags.install !== false) {
@@ -3086,12 +3149,42 @@ async function listRoutes(project) {
3086
3149
  const out = [];
3087
3150
  for (const [name, spec] of ${JSON.stringify(apps)}) {
3088
3151
  const { app } = await import(spec);
3089
- const doc = app.getOpenAPI31Document({ openapi: "3.1.0", info: { title: name, version: "1" } });
3152
+ // Fetch from /openapi.json endpoint to get the document with all extensions applied
3153
+ const res = await app.request("/openapi.json");
3154
+ const doc = await res.json();
3155
+ const securitySchemes = doc.components?.securitySchemes ?? {};
3156
+
3090
3157
  for (const [path, item] of Object.entries(doc.paths ?? {})) {
3091
3158
  for (const [method, op] of Object.entries(item)) {
3092
3159
  const security = op.security ?? [];
3093
- const roles = security.flatMap((s) => Object.values(s).flat());
3094
- const access = security.length === 0 ? "public" : roles.length ? roles.join(", ") : "authenticated";
3160
+
3161
+ // Parse access label based on security schemes
3162
+ let access = "public";
3163
+ if (security.length > 0) {
3164
+ const labels = [];
3165
+ for (const requirement of security) {
3166
+ const schemes = Object.keys(requirement);
3167
+ if (schemes.length === 0) continue;
3168
+
3169
+ const schemeName = schemes[0];
3170
+ const roles = requirement[schemeName] ?? [];
3171
+
3172
+ // For bearer auth with specific roles, show the roles
3173
+ if (schemeName === "bearerAuth" && roles.length > 0) {
3174
+ labels.push(roles.join(", "));
3175
+ }
3176
+ // For bearer auth without roles, show "authenticated"
3177
+ else if (schemeName === "bearerAuth") {
3178
+ labels.push("authenticated");
3179
+ }
3180
+ // For other schemes (API keys, etc.), show the scheme name
3181
+ else {
3182
+ labels.push(schemeName);
3183
+ }
3184
+ }
3185
+ access = labels.length > 1 ? labels.join(" OR ") : (labels[0] ?? "authenticated");
3186
+ }
3187
+
3095
3188
  out.push({ app: name, method: method.toUpperCase(), path, summary: op.summary ?? "", access });
3096
3189
  }
3097
3190
  }
@@ -3384,10 +3477,7 @@ async function blockersOf$1(ctx, group, sources) {
3384
3477
  return blockers;
3385
3478
  }
3386
3479
  async function applyGroup(ctx, group) {
3387
- for (const path of group.regenerate) {
3388
- const next = await ctx.renderCurrent(path);
3389
- if (next !== void 0) await ctx.write(path, next);
3390
- }
3480
+ for (const path of group.regenerate) await ctx.regenerate(path);
3391
3481
  for (const path of group.remove) await ctx.removeIfUnchanged(path);
3392
3482
  for (const rewrite of group.rewrites) await ctx.rewriteImports(rewrite.from, rewrite.to);
3393
3483
  }
@@ -3633,8 +3723,7 @@ const authPackage = {
3633
3723
  for (const group of ready) {
3634
3724
  for (const path of group.regenerate) {
3635
3725
  if (await read$3(ctx, path) === void 0) continue;
3636
- const next = await ctx.renderCurrent(path);
3637
- if (next !== void 0) await ctx.write(path, next);
3726
+ await ctx.regenerate(path);
3638
3727
  }
3639
3728
  for (const path of group.remove) await ctx.removeIfUnchanged(path);
3640
3729
  }
@@ -3779,15 +3868,43 @@ const authzPackage = {
3779
3868
  ctx.report.blocked.push("authz-package");
3780
3869
  return;
3781
3870
  }
3782
- for (const path of regenerate) {
3783
- const next = await ctx.renderCurrent(path);
3784
- if (next !== void 0) await ctx.write(path, next);
3785
- }
3871
+ for (const path of regenerate) await ctx.regenerate(path);
3786
3872
  for (const path of removed) await ctx.removeIfUnchanged(path);
3787
3873
  for (const move of MOVES) await ctx.rewriteImports(move.from, move.to);
3788
3874
  }
3789
3875
  };
3790
3876
  //#endregion
3877
+ //#region src/upgrade/codemods/authz-stores.ts
3878
+ const group = {
3879
+ name: "authz store",
3880
+ regenerate: ["src/core/authz/index.ts"],
3881
+ remove: ["src/core/authz/db/store.ts"],
3882
+ rewrites: [{
3883
+ from: "#core/authz/db/store",
3884
+ to: "#core/authz/index",
3885
+ names: []
3886
+ }]
3887
+ };
3888
+ const authzStores = {
3889
+ version: "0.9.0",
3890
+ id: "authz-stores",
3891
+ description: "Use the Prisma or Drizzle authz store from @tatara/authz",
3892
+ async run(ctx) {
3893
+ if (!ctx.project.config.addons.includes("authz")) return;
3894
+ if (ejectedPackages(ctx.project.config).includes("authz")) {
3895
+ ctx.report.notes.push("@tatara/authz is ejected; its store stays in src/core/authz/db/store.ts");
3896
+ return;
3897
+ }
3898
+ const blockers = await blockersOf$1(ctx, group, await sourceFiles(ctx));
3899
+ if (blockers.length > 0) {
3900
+ ctx.report.notes.push(`The authz store was not moved onto @tatara/authz: ${blockers.join("; ")}. Pass prismaAuthzStore(db) or drizzleAuthzStore(db, tables) to createAuthz by hand.`);
3901
+ ctx.report.blocked.push("authz-stores");
3902
+ return;
3903
+ }
3904
+ await applyGroup(ctx, group);
3905
+ }
3906
+ };
3907
+ //#endregion
3791
3908
  //#region src/upgrade/codemods/core-package.ts
3792
3909
  const CORE = "@tatara/core";
3793
3910
  /** Files @tatara/core replaces, with the import specifier apps used for each. */
@@ -3952,6 +4069,20 @@ const dbTesting = {
3952
4069
  }
3953
4070
  };
3954
4071
  //#endregion
4072
+ //#region src/upgrade/codemods/framework-tests.ts
4073
+ const frameworkTests = {
4074
+ version: "0.9.0",
4075
+ id: "framework-tests",
4076
+ description: "Remove tatara's own wiring tests from src/core; the packages and tatara's e2e cover them",
4077
+ async run(ctx) {
4078
+ for (const path of await sourceFiles(ctx)) {
4079
+ if (!isFrameworkTest(path)) continue;
4080
+ if (!await ctx.baseline.isUnchanged(path, await ctx.read(path))) continue;
4081
+ await ctx.removeIfUnchanged(path);
4082
+ }
4083
+ }
4084
+ };
4085
+ //#endregion
3955
4086
  //#region src/upgrade/codemods/http-package.ts
3956
4087
  const HTTP = "@tatara/http";
3957
4088
  /** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
@@ -4014,58 +4145,94 @@ async function outsideImporters(ctx) {
4014
4145
  }
4015
4146
  return out;
4016
4147
  }
4148
+ const httpPackage = {
4149
+ version: "0.3.0",
4150
+ id: "http-package",
4151
+ description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
4152
+ async run(ctx) {
4153
+ await ctx.addDependency(HTTP, versionOf(HTTP));
4154
+ const blockers = [];
4155
+ const replace = [];
4156
+ for (const path of [
4157
+ APP,
4158
+ ROUTES,
4159
+ ...SERVERS
4160
+ ]) {
4161
+ const text = await read(ctx, path);
4162
+ if (text === void 0) continue;
4163
+ if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
4164
+ else blockers.push(`${path} is customised`);
4165
+ }
4166
+ const present = [];
4167
+ for (const entry of MOVED) {
4168
+ const text = await read(ctx, entry.path);
4169
+ if (text === void 0) continue;
4170
+ present.push(entry);
4171
+ if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
4172
+ }
4173
+ for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
4174
+ if (blockers.length > 0) {
4175
+ ctx.report.notes.push(`Nothing in src/core/http was changed: ${blockers.join("; ")}. Move these onto @tatara/http (createApp, mountRoutes, serveOpenApi, handleErrors, startServer and its error classes) by hand, then delete the old files.`);
4176
+ ctx.report.blocked.push("http-package");
4177
+ return;
4178
+ }
4179
+ for (const path of replace) await ctx.regenerate(path);
4180
+ for (const entry of present) await ctx.removeIfUnchanged(entry.path);
4181
+ for (const specifier of [
4182
+ "#core/http/errors",
4183
+ "#core/http/response",
4184
+ "#core/http/middleware/validation"
4185
+ ]) await ctx.rewriteImports(specifier, HTTP);
4186
+ }
4187
+ };
4188
+ //#endregion
4189
+ //#region src/upgrade/codemods/package-commands.ts
4190
+ /** Copies whose commands and seeds the packages contribute now (wired by `tatara sync`). */
4191
+ const COPIES = [
4192
+ "src/core/db/commands/migrate.ts",
4193
+ "src/core/db/commands/reset.ts",
4194
+ "src/core/db/commands/seed.ts",
4195
+ "src/core/authz/commands/grant-role.ts",
4196
+ "src/core/authz/commands/sync-permissions.ts",
4197
+ "src/core/authz/db/seed.ts",
4198
+ "src/core/email/commands/send-test.ts",
4199
+ "src/core/storage/commands/check.ts",
4200
+ "src/core/jobs/commands/enqueue.ts"
4201
+ ];
4202
+ const specifierOf = (path) => `#${path.replace(/^src\//, "").replace(/\.ts$/, "")}`;
4017
4203
  //#endregion
4018
4204
  //#region src/upgrade/codemods.ts
4019
4205
  /** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
4020
4206
  const CODEMODS = [
4021
4207
  corePackage,
4208
+ httpPackage,
4209
+ dbTesting,
4210
+ authPackage,
4211
+ authzPackage,
4212
+ addonPackages,
4213
+ frameworkTests,
4214
+ authzStores,
4022
4215
  {
4023
- version: "0.3.0",
4024
- id: "http-package",
4025
- description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
4216
+ version: "0.9.0",
4217
+ id: "package-commands",
4218
+ description: "Take db, authz, email, storage and jobs commands and seeds from their packages",
4026
4219
  async run(ctx) {
4027
- await ctx.addDependency(HTTP, versionOf(HTTP));
4028
- const blockers = [];
4029
- const replace = [];
4030
- for (const path of [
4031
- APP,
4032
- ROUTES,
4033
- ...SERVERS
4034
- ]) {
4035
- const text = await read(ctx, path);
4036
- if (text === void 0) continue;
4037
- if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
4038
- else blockers.push(`${path} is customised`);
4039
- }
4040
- const present = [];
4041
- for (const entry of MOVED) {
4042
- const text = await read(ctx, entry.path);
4043
- if (text === void 0) continue;
4044
- present.push(entry);
4045
- if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
4046
- }
4047
- for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
4048
- if (blockers.length > 0) {
4049
- ctx.report.notes.push(`Nothing in src/core/http was changed: ${blockers.join("; ")}. Move these onto @tatara/http (createApp, mountRoutes, serveOpenApi, handleErrors, startServer and its error classes) by hand, then delete the old files.`);
4050
- ctx.report.blocked.push("http-package");
4051
- return;
4052
- }
4053
- for (const path of replace) {
4054
- const next = await ctx.renderCurrent(path);
4055
- if (next !== void 0) await ctx.write(path, next);
4220
+ const sources = await sourceFiles(ctx);
4221
+ const texts = /* @__PURE__ */ new Map();
4222
+ for (const path of sources) texts.set(path, await ctx.read(path));
4223
+ const ejected = ejectedPackages(ctx.project.config);
4224
+ for (const path of COPIES) {
4225
+ if (!texts.has(path)) continue;
4226
+ if (ejected.includes(path.split("/")[2] ?? "")) continue;
4227
+ const importers = [...texts].filter(([other, text]) => other !== path && importsOf(text, specifierOf(path)));
4228
+ if (importers.length > 0) {
4229
+ ctx.report.notes.push(`${path} was kept: ${importers.map(([p]) => p).join(", ")} import it.`);
4230
+ continue;
4231
+ }
4232
+ await ctx.removeIfUnchanged(path);
4056
4233
  }
4057
- for (const entry of present) await ctx.removeIfUnchanged(entry.path);
4058
- for (const specifier of [
4059
- "#core/http/errors",
4060
- "#core/http/response",
4061
- "#core/http/middleware/validation"
4062
- ]) await ctx.rewriteImports(specifier, HTTP);
4063
4234
  }
4064
- },
4065
- dbTesting,
4066
- authPackage,
4067
- authzPackage,
4068
- addonPackages
4235
+ }
4069
4236
  ];
4070
4237
  //#endregion
4071
4238
  //#region src/upgrade/context.ts
@@ -4094,7 +4261,7 @@ function createContext(project, baseline, opts = {}) {
4094
4261
  await mkdir(dirname(abs(path)), { recursive: true });
4095
4262
  await writeFile(abs(path), text);
4096
4263
  };
4097
- return {
4264
+ const ctx = {
4098
4265
  project,
4099
4266
  baseline,
4100
4267
  report,
@@ -4144,6 +4311,24 @@ function createContext(project, baseline, opts = {}) {
4144
4311
  });
4145
4312
  return (await rendered).get(path);
4146
4313
  },
4314
+ async regenerate(path) {
4315
+ const next = await ctx.renderCurrent(path);
4316
+ if (next === void 0) {
4317
+ const spec = `#${path.replace(/^src\//, "").replace(/\.ts$/, "")}`;
4318
+ const importers = [];
4319
+ for (const other of await glob(["src/**/*.ts", "tests/**/*.ts"], {
4320
+ cwd: project.root,
4321
+ ignore: ["src/generated/**"]
4322
+ })) if (other !== path && importsOf(await readFile(abs(other), "utf8"), spec)) importers.push(other);
4323
+ if (importers.length > 0) {
4324
+ report.notes.push(`${path} was kept: ${importers.join(", ")} import it.`);
4325
+ return "kept";
4326
+ }
4327
+ return ctx.removeIfUnchanged(path);
4328
+ }
4329
+ await write(path, next);
4330
+ return "written";
4331
+ },
4147
4332
  async addDependency(name, range, { dev = false } = {}) {
4148
4333
  if (ejected.includes(name.replace(/^@tatara\//, ""))) {
4149
4334
  const note = `${name} is ejected; it was not added (its code is in src/ejected)`;
@@ -4159,9 +4344,33 @@ function createContext(project, baseline, opts = {}) {
4159
4344
  await write("package.json", `${JSON.stringify(pkg, null, " ")}\n`);
4160
4345
  }
4161
4346
  };
4347
+ return ctx;
4162
4348
  }
4163
4349
  //#endregion
4164
4350
  //#region src/commands/upgrade.ts
4351
+ async function pmInstall(project) {
4352
+ const [cmd = "pnpm", ...args] = pmCommands(project.config.packageManager).install;
4353
+ if ((await x(cmd, args, {
4354
+ throwOnError: false,
4355
+ nodeOptions: {
4356
+ cwd: project.root,
4357
+ stdio: "inherit"
4358
+ }
4359
+ })).exitCode !== 0) throw new Error(`${cmd} ${args.join(" ")} failed; fix it, then run tatara upgrade again`);
4360
+ }
4361
+ /** Sets every @tatara/* library the app depends on to the range this CLI ships with. */
4362
+ async function bumpPackages(ctx) {
4363
+ const pkg = JSON.parse(await ctx.read("package.json"));
4364
+ const known = templateVersions();
4365
+ for (const dev of [false, true]) for (const [name, range] of Object.entries((dev ? pkg.devDependencies : pkg.dependencies) ?? {})) {
4366
+ if (!name.startsWith("@tatara/") || name === "@tatara/cli" || !(name in known)) continue;
4367
+ const current = /^\^(\d+\.\d+\.\d+)$/.exec(range)?.[1];
4368
+ const next = versionOf(name);
4369
+ const shipped = /^\^(\d+\.\d+\.\d+)$/.exec(next)?.[1];
4370
+ if (!current || !shipped || compareVersions(current, shipped) >= 0) continue;
4371
+ await ctx.addDependency(name, next, { dev });
4372
+ }
4373
+ }
4165
4374
  async function biomeFormat(file, project) {
4166
4375
  const biome = join(project.root, "node_modules", ".bin", "biome");
4167
4376
  if (!existsSync(biome)) return;
@@ -4208,9 +4417,11 @@ async function upgrade(project, opts = {}) {
4208
4417
  ctx.report.ran.push(mod.id);
4209
4418
  if (ctx.report.blocked.length > blockedBefore && reached === target) reached = due.slice(0, due.indexOf(mod)).at(-1)?.version ?? from;
4210
4419
  }
4420
+ await bumpPackages(ctx);
4211
4421
  if (opts.dryRun) return ctx.report;
4212
4422
  const format = opts.formatFile ?? biomeFormat;
4213
4423
  for (const path of ctx.report.written) await format(path, project);
4424
+ await (opts.runInstall ?? pmInstall)(project);
4214
4425
  await (opts.runSync ?? sync)(project);
4215
4426
  const path = join(project.root, "tatara.json");
4216
4427
  const config = JSON.parse(await readFile(path, "utf8"));
@@ -4243,7 +4454,7 @@ function printUpgrade(report, from, to) {
4243
4454
  //#endregion
4244
4455
  //#region src/bin.ts
4245
4456
  const program = new Command("tatara").description("Create and grow opinionated Hono backends").version(cliVersion());
4246
- program.command("new").description("Create a new app").argument("[dir]", "directory to create").option("--orm <orm>", "drizzle | prisma | none").option("--db <database>", "postgres | mysql | none").option("--auth <provider>", "better-auth | none (keycloak = better-auth with --auth-methods keycloak)").option("--auth-methods <list>", "email-otp,phone-otp,password,orgs (better-auth)").option("--addons <list>", "authz,jobs,admin,storage,email,i18n").option("--pm <pm>", "pnpm | npm | bun").option("-y, --yes", "skip prompts and use flags/defaults").option("--no-install", "do not install dependencies").option("--no-git", "do not initialise git").action(async (dir, flags) => {
4457
+ program.command("new").description("Create a new app").argument("[dir]", "directory to create").option("--orm <orm>", "drizzle | prisma | none").option("--db <database>", "postgres | mysql | none").option("--auth <provider>", "better-auth | none (keycloak = better-auth with --auth-methods keycloak)").option("--auth-methods <list>", "email-otp,phone-otp,password,orgs (better-auth)").option("--addons <list>", "authz,jobs,admin,storage,email,i18n").option("--pm <pm>", "pnpm | npm | bun").option("-y, --yes", "skip prompts and use flags/defaults").option("--no-install", "do not install dependencies").option("--no-git", "do not initialise git").addOption(new Option("--framework-tests", "include tatara's own wiring tests (e2e)").hideHelp()).action(async (dir, flags) => {
4247
4458
  await newApp(dir, flags);
4248
4459
  });
4249
4460
  program.command("sync").description("Regenerate src/generated from discovered module files").option("--quiet", "no output").action(async (flags) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,15 @@
1
+ import { createAuthz } from "@tatara/authz";
2
+ import { drizzleAuthzStore } from "@tatara/authz/drizzle";
3
+ import * as tables from "#core/authz/db/authz.schema";
4
+ import { SYSTEM_ROLES } from "#core/authz/system-roles";
5
+ import { db } from "#core/db/client";
6
+ import { catalog, type PermissionKey } from "#generated/permissions";
7
+
8
+ /** The app's authorization: its tables (authz.schema.ts), the catalog from `tatara sync`, built-in roles. */
9
+ export const authz = createAuthz<PermissionKey>({
10
+ store: drizzleAuthzStore(db, tables),
11
+ catalog,
12
+ systemRoles: SYSTEM_ROLES,
13
+ });
14
+
15
+ export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
@@ -0,0 +1,14 @@
1
+ import { createAuthz } from "@tatara/authz";
2
+ import { prismaAuthzStore } from "@tatara/authz/prisma";
3
+ import { SYSTEM_ROLES } from "#core/authz/system-roles";
4
+ import { db } from "#core/db/client";
5
+ import { catalog, type PermissionKey } from "#generated/permissions";
6
+
7
+ /** The app's authorization: its tables (prisma/models/authz.prisma), the catalog from `tatara sync`, built-in roles. */
8
+ export const authz = createAuthz<PermissionKey>({
9
+ store: prismaAuthzStore(db),
10
+ catalog,
11
+ systemRoles: SYSTEM_ROLES,
12
+ });
13
+
14
+ export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
@@ -33,13 +33,13 @@
33
33
  "@types/nodemailer": "8.0.2",
34
34
  "@tatara/core": "0.1.1",
35
35
  "@tatara/http": "0.1.1",
36
- "@tatara/db": "0.1.1",
36
+ "@tatara/db": "0.2.0",
37
37
  "@tatara/testing": "0.1.1",
38
38
  "@tatara/auth": "0.1.1",
39
39
  "@tatara/keycloak": "0.1.1",
40
- "@tatara/authz": "0.1.0",
41
- "@tatara/storage": "0.1.0",
42
- "@tatara/email": "0.1.0",
40
+ "@tatara/authz": "0.2.0",
41
+ "@tatara/storage": "0.2.0",
42
+ "@tatara/email": "0.2.0",
43
43
  "@tatara/i18n": "0.1.0",
44
- "@tatara/jobs": "0.1.0"
44
+ "@tatara/jobs": "0.2.0"
45
45
  }
@@ -1,21 +0,0 @@
1
- import { z } from "zod";
2
- import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "@tatara/authz";
3
- import { authz } from "#core/authz/index";
4
- import { defineCommand } from "@tatara/core";
5
-
6
- export default defineCommand({
7
- name: "authz:grant-role",
8
- description: "Assign a role (built-in key or custom role key) to a user in an org",
9
- args: z.object({
10
- user: z.string().min(1),
11
- role: z.string().min(1),
12
- org: z.string().default(DEFAULT_ORG_ID),
13
- }),
14
- async run({ user, role, org }, { logger }) {
15
- const found = (await authz.store.findRoleByKey(org, role)) ??
16
- (await authz.store.findRoleByKey(SYSTEM_ORG_ID, role));
17
- if (!found) throw new Error(`No role "${role}" (run authz:sync-permissions first for built-in roles)`);
18
- const added = await authz.assignRole(null, org, user, found.id);
19
- logger.info({ user, role, org, added }, added ? "role assigned" : "role already assigned");
20
- },
21
- });
@@ -1,11 +0,0 @@
1
- import { defineCommand } from "@tatara/core";
2
- import { authz } from "#core/authz/index";
3
-
4
- export default defineCommand({
5
- name: "authz:sync-permissions",
6
- description: "Create/update built-in roles so their grants match the code (run on every deploy)",
7
- async run(_args, { logger }) {
8
- const result = await authz.syncSystemRoles();
9
- logger.info(result, "built-in roles in sync");
10
- },
11
- });
@@ -1,10 +0,0 @@
1
- import { authz } from "#core/authz/index";
2
- import { defineSeed } from "@tatara/db";
3
-
4
- export default defineSeed({
5
- name: "authz:system-roles",
6
- order: -10,
7
- async run({ logger }) {
8
- logger.info(await authz.syncSystemRoles(), "built-in roles in sync");
9
- },
10
- });
@@ -1,9 +0,0 @@
1
- import { createAuthz } from "@tatara/authz";
2
- import { store } from "#core/authz/db/store";
3
- import { SYSTEM_ROLES } from "#core/authz/system-roles";
4
- import { catalog, type PermissionKey } from "#generated/permissions";
5
-
6
- /** The app's authorization: its store, the permission catalog from `tatara sync`, built-in roles. */
7
- export const authz = createAuthz<PermissionKey>({ store, catalog, systemRoles: SYSTEM_ROLES });
8
-
9
- export const { checkPermission, removeMemberAccess, requirePermission, resolveScope } = authz;
@@ -1,275 +0,0 @@
1
- import { and, count, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
2
- import type { OverlayEffect, Scope } from "@tatara/authz";
3
- import { SYSTEM_ORG_ID } from "@tatara/authz";
4
- import {
5
- authzAuditLog,
6
- authzMemberRoles,
7
- authzOverlays,
8
- authzRolePermissions,
9
- authzRoles,
10
- } from "#core/authz/db/authz.schema";
11
- import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
12
- import { db } from "#core/db/client";
13
- import { newId } from "@tatara/db";
14
-
15
- const toScope = (scope: string): Scope | null => (scope === "" ? null : (scope as Scope));
16
-
17
- type RoleRecord = typeof authzRoles.$inferSelect;
18
- type OverlayRecord = typeof authzOverlays.$inferSelect;
19
-
20
- function toOverlay(row: OverlayRecord): OverlayRow {
21
- return { ...row, scope: toScope(row.scope), effect: row.effect as OverlayEffect };
22
- }
23
-
24
- async function hydrate(roles: RoleRecord[], orgId: string): Promise<RoleRow[]> {
25
- if (roles.length === 0) return [];
26
- const ids = roles.map((r) => r.id);
27
- const [grants, members] = await Promise.all([
28
- db.select().from(authzRolePermissions).where(inArray(authzRolePermissions.roleId, ids)),
29
- db
30
- .select({ roleId: authzMemberRoles.roleId, total: count() })
31
- .from(authzMemberRoles)
32
- .where(and(eq(authzMemberRoles.orgId, orgId), inArray(authzMemberRoles.roleId, ids)))
33
- .groupBy(authzMemberRoles.roleId),
34
- ]);
35
- return roles.map((role) => ({
36
- id: role.id,
37
- orgId: role.orgId,
38
- key: role.key,
39
- name: role.name,
40
- description: role.description,
41
- isSystem: role.isSystem,
42
- grants: grants
43
- .filter((g) => g.roleId === role.id)
44
- .map((g) => ({ resource: g.resource, action: g.action, scope: toScope(g.scope) })),
45
- memberCount: members.find((m) => m.roleId === role.id)?.total ?? 0,
46
- }));
47
- }
48
-
49
- const visibleTo = (orgId: string) => or(eq(authzRoles.orgId, SYSTEM_ORG_ID), eq(authzRoles.orgId, orgId));
50
-
51
- async function insertGrants(roleId: string, grants: PermissionGrant[]) {
52
- if (grants.length === 0) return;
53
- await db.insert(authzRolePermissions).values(
54
- grants.map((g) => ({
55
- id: newId(),
56
- roleId,
57
- resource: g.resource,
58
- action: g.action,
59
- scope: g.scope ?? "",
60
- })),
61
- );
62
- }
63
-
64
- export const store: AuthzStore = {
65
- async roleGrants(orgId, userId, impliedRoleKeys) {
66
- const assigned = await db
67
- .select({ roleId: authzMemberRoles.roleId })
68
- .from(authzMemberRoles)
69
- .where(and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId)));
70
- const implied = impliedRoleKeys.length
71
- ? await db
72
- .select({ id: authzRoles.id })
73
- .from(authzRoles)
74
- .where(and(eq(authzRoles.orgId, SYSTEM_ORG_ID), inArray(authzRoles.key, impliedRoleKeys)))
75
- : [];
76
- const roleIds = [...new Set([...assigned.map((r) => r.roleId), ...implied.map((r) => r.id)])];
77
- if (roleIds.length === 0) return { roles: [], grants: [] };
78
- const [roles, grants] = await Promise.all([
79
- db.select({ key: authzRoles.key }).from(authzRoles).where(inArray(authzRoles.id, roleIds)),
80
- db.select().from(authzRolePermissions).where(inArray(authzRolePermissions.roleId, roleIds)),
81
- ]);
82
- return {
83
- roles: roles.map((r) => r.key),
84
- grants: grants.map((g) => ({ resource: g.resource, action: g.action, scope: toScope(g.scope) })),
85
- };
86
- },
87
-
88
- async activeOverlays(orgId, userId, now) {
89
- const rows = await db
90
- .select()
91
- .from(authzOverlays)
92
- .where(
93
- and(
94
- eq(authzOverlays.orgId, orgId),
95
- eq(authzOverlays.userId, userId),
96
- or(isNull(authzOverlays.expiresAt), gt(authzOverlays.expiresAt, now)),
97
- ),
98
- );
99
- return rows.map(toOverlay);
100
- },
101
-
102
- async listRoles(orgId) {
103
- const roles = await db.select().from(authzRoles).where(visibleTo(orgId)).orderBy(authzRoles.key);
104
- return hydrate(roles, orgId);
105
- },
106
-
107
- async findRole(orgId, roleId) {
108
- const roles = await db
109
- .select()
110
- .from(authzRoles)
111
- .where(and(eq(authzRoles.id, roleId), visibleTo(orgId)));
112
- return (await hydrate(roles, orgId))[0] ?? null;
113
- },
114
-
115
- async findRoleByKey(orgId, key) {
116
- const roles = await db
117
- .select()
118
- .from(authzRoles)
119
- .where(and(eq(authzRoles.key, key), eq(authzRoles.orgId, orgId)));
120
- return (await hydrate(roles, orgId))[0] ?? null;
121
- },
122
-
123
- async createRole(input) {
124
- const id = newId();
125
- await db.insert(authzRoles).values({
126
- id,
127
- orgId: input.orgId,
128
- key: input.key,
129
- name: input.name,
130
- description: input.description,
131
- isSystem: input.isSystem,
132
- });
133
- await insertGrants(id, input.grants);
134
- return id;
135
- },
136
-
137
- async updateRole(roleId, input) {
138
- const fields = {
139
- ...(input.name === undefined ? {} : { name: input.name }),
140
- ...(input.description === undefined ? {} : { description: input.description }),
141
- };
142
- await db
143
- .update(authzRoles)
144
- .set({ ...fields, updatedAt: new Date() })
145
- .where(eq(authzRoles.id, roleId));
146
- if (input.grants) {
147
- await db.delete(authzRolePermissions).where(eq(authzRolePermissions.roleId, roleId));
148
- await insertGrants(roleId, input.grants);
149
- }
150
- },
151
-
152
- async deleteRole(roleId) {
153
- await db.delete(authzRoles).where(eq(authzRoles.id, roleId));
154
- },
155
-
156
- async memberRoles(orgId, userId) {
157
- const assigned = await db
158
- .select({ roleId: authzMemberRoles.roleId })
159
- .from(authzMemberRoles)
160
- .where(and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId)));
161
- if (assigned.length === 0) return [];
162
- const roles = await db
163
- .select()
164
- .from(authzRoles)
165
- .where(inArray(authzRoles.id, assigned.map((a) => a.roleId)));
166
- return hydrate(roles, orgId);
167
- },
168
-
169
- async assignRole(orgId, userId, roleId, by) {
170
- const existing = await db
171
- .select({ id: authzMemberRoles.id })
172
- .from(authzMemberRoles)
173
- .where(
174
- and(
175
- eq(authzMemberRoles.orgId, orgId),
176
- eq(authzMemberRoles.userId, userId),
177
- eq(authzMemberRoles.roleId, roleId),
178
- ),
179
- );
180
- if (existing.length > 0) return false;
181
- await db.insert(authzMemberRoles).values({ id: newId(), orgId, userId, roleId, assignedBy: by });
182
- return true;
183
- },
184
-
185
- async revokeRole(orgId, userId, roleId) {
186
- const where = and(
187
- eq(authzMemberRoles.orgId, orgId),
188
- eq(authzMemberRoles.userId, userId),
189
- eq(authzMemberRoles.roleId, roleId),
190
- );
191
- const existing = await db.select({ id: authzMemberRoles.id }).from(authzMemberRoles).where(where);
192
- if (existing.length === 0) return false;
193
- await db.delete(authzMemberRoles).where(where);
194
- return true;
195
- },
196
-
197
- async removeMember(orgId, userId) {
198
- const roleWhere = and(eq(authzMemberRoles.orgId, orgId), eq(authzMemberRoles.userId, userId));
199
- const overlayWhere = and(eq(authzOverlays.orgId, orgId), eq(authzOverlays.userId, userId));
200
- // count first: MySQL has no RETURNING
201
- return db.transaction(async (tx) => {
202
- const [roles] = await tx.select({ n: count() }).from(authzMemberRoles).where(roleWhere);
203
- const [overlays] = await tx.select({ n: count() }).from(authzOverlays).where(overlayWhere);
204
- await tx.delete(authzMemberRoles).where(roleWhere);
205
- await tx.delete(authzOverlays).where(overlayWhere);
206
- return { roles: roles?.n ?? 0, overlays: overlays?.n ?? 0 };
207
- });
208
- },
209
-
210
- async listOverlays(orgId, userId) {
211
- const rows = await db
212
- .select()
213
- .from(authzOverlays)
214
- .where(and(eq(authzOverlays.orgId, orgId), eq(authzOverlays.userId, userId)))
215
- .orderBy(desc(authzOverlays.createdAt));
216
- return rows.map(toOverlay);
217
- },
218
-
219
- async addOverlay(input) {
220
- const id = newId();
221
- const scope = input.scope ?? "";
222
- await db
223
- .delete(authzOverlays)
224
- .where(
225
- and(
226
- eq(authzOverlays.orgId, input.orgId),
227
- eq(authzOverlays.userId, input.userId),
228
- eq(authzOverlays.resource, input.resource),
229
- eq(authzOverlays.action, input.action),
230
- eq(authzOverlays.scope, scope),
231
- eq(authzOverlays.effect, input.effect),
232
- ),
233
- );
234
- await db.insert(authzOverlays).values({ ...input, id, scope });
235
- return id;
236
- },
237
-
238
- async removeOverlay(orgId, userId, overlayId) {
239
- const where = and(
240
- eq(authzOverlays.orgId, orgId),
241
- eq(authzOverlays.userId, userId),
242
- eq(authzOverlays.id, overlayId),
243
- );
244
- const [row] = await db.select().from(authzOverlays).where(where);
245
- if (!row) return null;
246
- await db.delete(authzOverlays).where(where);
247
- return toOverlay(row);
248
- },
249
-
250
- async writeAudit(input) {
251
- await db.insert(authzAuditLog).values({
252
- id: newId(),
253
- orgId: input.orgId,
254
- actorId: input.actorId,
255
- targetUserId: input.targetUserId,
256
- changeType: input.changeType,
257
- before: input.before === undefined ? null : JSON.stringify(input.before),
258
- after: input.after === undefined ? null : JSON.stringify(input.after),
259
- });
260
- },
261
-
262
- async listAudit(orgId, limit) {
263
- const rows = await db
264
- .select()
265
- .from(authzAuditLog)
266
- .where(eq(authzAuditLog.orgId, orgId))
267
- .orderBy(desc(authzAuditLog.id))
268
- .limit(limit);
269
- return rows.map((r) => ({
270
- ...r,
271
- before: r.before ? (JSON.parse(r.before) as unknown) : null,
272
- after: r.after ? (JSON.parse(r.after) as unknown) : null,
273
- }));
274
- },
275
- };
@@ -1,236 +0,0 @@
1
- import type { OverlayEffect, Scope } from "@tatara/authz";
2
- import { SYSTEM_ORG_ID } from "@tatara/authz";
3
- import type { AuthzStore, OverlayRow, PermissionGrant, RoleRow } from "@tatara/authz";
4
- import { db } from "#core/db/client";
5
- import { newId } from "@tatara/db";
6
-
7
- const toScope = (scope: string): Scope | null => (scope === "" ? null : (scope as Scope));
8
-
9
- interface OverlayRecord {
10
- id: string;
11
- orgId: string;
12
- userId: string;
13
- resource: string;
14
- action: string;
15
- scope: string;
16
- effect: string;
17
- grantedBy: string | null;
18
- reason: string | null;
19
- expiresAt: Date | null;
20
- createdAt: Date;
21
- }
22
-
23
- function toOverlay(row: OverlayRecord): OverlayRow {
24
- return { ...row, scope: toScope(row.scope), effect: row.effect as OverlayEffect };
25
- }
26
-
27
- const roleInclude = (orgId: string) => ({
28
- permissions: true,
29
- _count: { select: { members: { where: { orgId } } } },
30
- });
31
-
32
- interface RoleRecord {
33
- id: string;
34
- orgId: string;
35
- key: string;
36
- name: string;
37
- description: string | null;
38
- isSystem: boolean;
39
- permissions: { resource: string; action: string; scope: string }[];
40
- _count: { members: number };
41
- }
42
-
43
- function toRole(role: RoleRecord): RoleRow {
44
- return {
45
- id: role.id,
46
- orgId: role.orgId,
47
- key: role.key,
48
- name: role.name,
49
- description: role.description,
50
- isSystem: role.isSystem,
51
- grants: role.permissions.map((g) => ({
52
- resource: g.resource,
53
- action: g.action,
54
- scope: toScope(g.scope),
55
- })),
56
- memberCount: role._count.members,
57
- };
58
- }
59
-
60
- const grantRows = (grants: PermissionGrant[]) =>
61
- grants.map((g) => ({ id: newId(), resource: g.resource, action: g.action, scope: g.scope ?? "" }));
62
-
63
- export const store: AuthzStore = {
64
- async roleGrants(orgId, userId, impliedRoleKeys) {
65
- const roles = await db.authzRole.findMany({
66
- where: {
67
- OR: [
68
- { members: { some: { orgId, userId } } },
69
- ...(impliedRoleKeys.length
70
- ? [{ orgId: SYSTEM_ORG_ID, key: { in: impliedRoleKeys } }]
71
- : []),
72
- ],
73
- },
74
- include: { permissions: true },
75
- });
76
- return {
77
- roles: roles.map((r) => r.key),
78
- grants: roles.flatMap((r) =>
79
- r.permissions.map((g) => ({ resource: g.resource, action: g.action, scope: toScope(g.scope) })),
80
- ),
81
- };
82
- },
83
-
84
- async activeOverlays(orgId, userId, now) {
85
- const rows = await db.authzOverlay.findMany({
86
- where: { orgId, userId, OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
87
- });
88
- return rows.map(toOverlay);
89
- },
90
-
91
- async listRoles(orgId) {
92
- const roles = await db.authzRole.findMany({
93
- where: { orgId: { in: [SYSTEM_ORG_ID, orgId] } },
94
- include: roleInclude(orgId),
95
- orderBy: { key: "asc" },
96
- });
97
- return roles.map(toRole);
98
- },
99
-
100
- async findRole(orgId, roleId) {
101
- const role = await db.authzRole.findFirst({
102
- where: { id: roleId, orgId: { in: [SYSTEM_ORG_ID, orgId] } },
103
- include: roleInclude(orgId),
104
- });
105
- return role ? toRole(role) : null;
106
- },
107
-
108
- async findRoleByKey(orgId, key) {
109
- const role = await db.authzRole.findFirst({ where: { key, orgId }, include: roleInclude(orgId) });
110
- return role ? toRole(role) : null;
111
- },
112
-
113
- async createRole(input) {
114
- const id = newId();
115
- await db.authzRole.create({
116
- data: {
117
- id,
118
- orgId: input.orgId,
119
- key: input.key,
120
- name: input.name,
121
- description: input.description,
122
- isSystem: input.isSystem,
123
- permissions: { create: grantRows(input.grants) },
124
- },
125
- });
126
- return id;
127
- },
128
-
129
- async updateRole(roleId, input) {
130
- await db.$transaction(async (tx) => {
131
- await tx.authzRole.update({
132
- where: { id: roleId },
133
- data: {
134
- ...(input.name === undefined ? {} : { name: input.name }),
135
- ...(input.description === undefined ? {} : { description: input.description }),
136
- },
137
- });
138
- if (input.grants) {
139
- await tx.authzRolePermission.deleteMany({ where: { roleId } });
140
- await tx.authzRolePermission.createMany({
141
- data: grantRows(input.grants).map((g) => ({ ...g, roleId })),
142
- });
143
- }
144
- });
145
- },
146
-
147
- async deleteRole(roleId) {
148
- await db.authzRole.delete({ where: { id: roleId } });
149
- },
150
-
151
- async memberRoles(orgId, userId) {
152
- const roles = await db.authzRole.findMany({
153
- where: { members: { some: { orgId, userId } } },
154
- include: roleInclude(orgId),
155
- });
156
- return roles.map(toRole);
157
- },
158
-
159
- async assignRole(orgId, userId, roleId, by) {
160
- const existing = await db.authzMemberRole.findFirst({ where: { orgId, userId, roleId } });
161
- if (existing) return false;
162
- await db.authzMemberRole.create({ data: { id: newId(), orgId, userId, roleId, assignedBy: by } });
163
- return true;
164
- },
165
-
166
- async revokeRole(orgId, userId, roleId) {
167
- const { count } = await db.authzMemberRole.deleteMany({ where: { orgId, userId, roleId } });
168
- return count > 0;
169
- },
170
-
171
- async removeMember(orgId, userId) {
172
- const [roles, overlays] = await db.$transaction([
173
- db.authzMemberRole.deleteMany({ where: { orgId, userId } }),
174
- db.authzOverlay.deleteMany({ where: { orgId, userId } }),
175
- ]);
176
- return { roles: roles.count, overlays: overlays.count };
177
- },
178
- async listOverlays(orgId, userId) {
179
- const rows = await db.authzOverlay.findMany({
180
- where: { orgId, userId },
181
- orderBy: { createdAt: "desc" },
182
- });
183
- return rows.map(toOverlay);
184
- },
185
-
186
- async addOverlay(input) {
187
- const id = newId();
188
- const scope = input.scope ?? "";
189
- await db.authzOverlay.deleteMany({
190
- where: {
191
- orgId: input.orgId,
192
- userId: input.userId,
193
- resource: input.resource,
194
- action: input.action,
195
- scope,
196
- effect: input.effect,
197
- },
198
- });
199
- await db.authzOverlay.create({ data: { ...input, id, scope } });
200
- return id;
201
- },
202
-
203
- async removeOverlay(orgId, userId, overlayId) {
204
- const row = await db.authzOverlay.findFirst({ where: { id: overlayId, orgId, userId } });
205
- if (!row) return null;
206
- await db.authzOverlay.deleteMany({ where: { id: overlayId, orgId, userId } });
207
- return toOverlay(row);
208
- },
209
-
210
- async writeAudit(input) {
211
- await db.authzAuditLog.create({
212
- data: {
213
- id: newId(),
214
- orgId: input.orgId,
215
- actorId: input.actorId,
216
- targetUserId: input.targetUserId,
217
- changeType: input.changeType,
218
- before: input.before === undefined ? null : JSON.stringify(input.before),
219
- after: input.after === undefined ? null : JSON.stringify(input.after),
220
- },
221
- });
222
- },
223
-
224
- async listAudit(orgId, limit) {
225
- const rows = await db.authzAuditLog.findMany({
226
- where: { orgId },
227
- orderBy: { id: "desc" },
228
- take: limit,
229
- });
230
- return rows.map((r) => ({
231
- ...r,
232
- before: r.before ? (JSON.parse(r.before) as unknown) : null,
233
- after: r.after ? (JSON.parse(r.after) as unknown) : null,
234
- }));
235
- },
236
- };
@@ -1,12 +0,0 @@
1
- import { defineCommand } from "@tatara/core";
2
- import { env } from "#core/config/env";
3
- import { migrateDatabase } from "#core/db/migrate";
4
-
5
- export default defineCommand({
6
- name: "db:migrate",
7
- description: "Apply pending database migrations",
8
- async run(_args, { logger }) {
9
- await migrateDatabase(env.DATABASE_URL);
10
- logger.info("migrations applied");
11
- },
12
- });
@@ -1,19 +0,0 @@
1
- import { z } from "zod";
2
- import { defineCommand, flag } from "@tatara/core";
3
- import { env } from "#core/config/env";
4
- import { dropAllTables } from "#core/db/admin";
5
- import { migrateDatabase } from "#core/db/migrate";
6
-
7
- export default defineCommand({
8
- name: "db:reset",
9
- description: "Drop every table and re-apply migrations (refuses to run in production)",
10
- args: z.object({ force: flag() }),
11
- async run({ force }, { logger }) {
12
- if (env.NODE_ENV === "production" && !force) {
13
- throw new Error("db:reset refuses to run with NODE_ENV=production (pass --force to override)");
14
- }
15
- await dropAllTables(env.DATABASE_URL);
16
- await migrateDatabase(env.DATABASE_URL);
17
- logger.info("database reset");
18
- },
19
- });
@@ -1,13 +0,0 @@
1
- import { defineCommand } from "@tatara/core";
2
- import { seeds } from "#generated/seeds";
3
-
4
- export default defineCommand({
5
- name: "db:seed",
6
- description: "Run every idempotent seed (core first, then modules)",
7
- async run(_args, { logger }) {
8
- for (const seed of [...seeds].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))) {
9
- await seed.run({ logger: logger.child({ seed: seed.name }) });
10
- logger.info({ seed: seed.name }, "seeded");
11
- }
12
- },
13
- });
@@ -1,7 +0,0 @@
1
- import { expect, it } from "vitest";
2
- import { parseFlags } from "@tatara/core";
3
- import reset from "#core/db/commands/reset";
4
-
5
- it("does not treat --force false as forcing db:reset", () => {
6
- expect(reset.args?.parse(parseFlags(["--force", "false"]))).toEqual({ force: false });
7
- });
@@ -1,13 +0,0 @@
1
- import { z } from "zod";
2
- import { defineCommand } from "@tatara/core";
3
- import { sendEmail } from "#core/email/send";
4
-
5
- export default defineCommand({
6
- name: "email:send-test",
7
- description: "Send a test email (see it in Mailpit locally)",
8
- args: z.object({ to: z.email().default("dev@example.com") }),
9
- async run({ to }, { logger }) {
10
- await sendEmail({ to, subject: "Test email", text: "It works." });
11
- logger.info({ to }, "test email sent");
12
- },
13
- });
@@ -1,13 +0,0 @@
1
- import { z } from "zod";
2
- import { defineCommand } from "@tatara/core";
3
- import { enqueue } from "#core/jobs/index";
4
-
5
- export default defineCommand({
6
- name: "jobs:enqueue",
7
- description: "Enqueue a job by topic with a JSON payload (for development and debugging)",
8
- args: z.object({ topic: z.string().min(1), data: z.string().default("{}") }),
9
- async run({ topic, data }, { logger }) {
10
- const id = await enqueue(topic, JSON.parse(data) as unknown);
11
- logger.info({ topic, id }, "job enqueued");
12
- },
13
- });
@@ -1,18 +0,0 @@
1
- import { defineCommand } from "@tatara/core";
2
- import { env } from "#core/config/env";
3
- import { ensureBucket } from "@tatara/storage/s3";
4
- import { s3, storage } from "#core/storage/index";
5
-
6
- export default defineCommand({
7
- name: "storage:check",
8
- description: "Create the bucket if needed, then write, read and delete a probe object",
9
- async run(_args, { logger }) {
10
- if (env.STORAGE_DRIVER === "s3" && (await ensureBucket(s3, env.S3_BUCKET))) logger.info({ bucket: env.S3_BUCKET }, "bucket created");
11
- const key = `tatara-check/${Date.now()}.txt`;
12
- await storage.put(key, "ok", "text/plain");
13
- const body = await storage.get(key);
14
- await storage.delete(key);
15
- if (new TextDecoder().decode(body ?? new Uint8Array()) !== "ok") throw new Error("storage round-trip failed");
16
- logger.info({ driver: env.STORAGE_DRIVER }, "storage OK");
17
- },
18
- });