@tatara/cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.mjs +240 -59
- package/package.json +1 -1
- package/templates/features/authz/files+drizzle/src/core/authz/index.ts +15 -0
- package/templates/features/authz/files+prisma/src/core/authz/index.ts +14 -0
- package/templates/versions.json +5 -5
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +0 -21
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +0 -11
- package/templates/features/authz/files/src/core/authz/db/seed.ts +0 -10
- package/templates/features/authz/files/src/core/authz/index.ts +0 -9
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +0 -275
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +0 -236
- package/templates/features/db/files/src/core/db/commands/migrate.ts +0 -12
- package/templates/features/db/files/src/core/db/commands/reset.ts +0 -19
- package/templates/features/db/files/src/core/db/commands/seed.ts +0 -13
- package/templates/features/db/files/src/core/db/tests/reset.test.ts +0 -7
- package/templates/features/email/files/src/core/email/commands/send-test.ts +0 -13
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +0 -13
- package/templates/features/storage/files/src/core/storage/commands/check.ts +0 -18
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"),
|
|
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"),
|
|
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, {
|
|
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) {
|
|
@@ -3384,10 +3447,7 @@ async function blockersOf$1(ctx, group, sources) {
|
|
|
3384
3447
|
return blockers;
|
|
3385
3448
|
}
|
|
3386
3449
|
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
|
-
}
|
|
3450
|
+
for (const path of group.regenerate) await ctx.regenerate(path);
|
|
3391
3451
|
for (const path of group.remove) await ctx.removeIfUnchanged(path);
|
|
3392
3452
|
for (const rewrite of group.rewrites) await ctx.rewriteImports(rewrite.from, rewrite.to);
|
|
3393
3453
|
}
|
|
@@ -3633,8 +3693,7 @@ const authPackage = {
|
|
|
3633
3693
|
for (const group of ready) {
|
|
3634
3694
|
for (const path of group.regenerate) {
|
|
3635
3695
|
if (await read$3(ctx, path) === void 0) continue;
|
|
3636
|
-
|
|
3637
|
-
if (next !== void 0) await ctx.write(path, next);
|
|
3696
|
+
await ctx.regenerate(path);
|
|
3638
3697
|
}
|
|
3639
3698
|
for (const path of group.remove) await ctx.removeIfUnchanged(path);
|
|
3640
3699
|
}
|
|
@@ -3779,15 +3838,43 @@ const authzPackage = {
|
|
|
3779
3838
|
ctx.report.blocked.push("authz-package");
|
|
3780
3839
|
return;
|
|
3781
3840
|
}
|
|
3782
|
-
for (const path of regenerate)
|
|
3783
|
-
const next = await ctx.renderCurrent(path);
|
|
3784
|
-
if (next !== void 0) await ctx.write(path, next);
|
|
3785
|
-
}
|
|
3841
|
+
for (const path of regenerate) await ctx.regenerate(path);
|
|
3786
3842
|
for (const path of removed) await ctx.removeIfUnchanged(path);
|
|
3787
3843
|
for (const move of MOVES) await ctx.rewriteImports(move.from, move.to);
|
|
3788
3844
|
}
|
|
3789
3845
|
};
|
|
3790
3846
|
//#endregion
|
|
3847
|
+
//#region src/upgrade/codemods/authz-stores.ts
|
|
3848
|
+
const group = {
|
|
3849
|
+
name: "authz store",
|
|
3850
|
+
regenerate: ["src/core/authz/index.ts"],
|
|
3851
|
+
remove: ["src/core/authz/db/store.ts"],
|
|
3852
|
+
rewrites: [{
|
|
3853
|
+
from: "#core/authz/db/store",
|
|
3854
|
+
to: "#core/authz/index",
|
|
3855
|
+
names: []
|
|
3856
|
+
}]
|
|
3857
|
+
};
|
|
3858
|
+
const authzStores = {
|
|
3859
|
+
version: "0.9.0",
|
|
3860
|
+
id: "authz-stores",
|
|
3861
|
+
description: "Use the Prisma or Drizzle authz store from @tatara/authz",
|
|
3862
|
+
async run(ctx) {
|
|
3863
|
+
if (!ctx.project.config.addons.includes("authz")) return;
|
|
3864
|
+
if (ejectedPackages(ctx.project.config).includes("authz")) {
|
|
3865
|
+
ctx.report.notes.push("@tatara/authz is ejected; its store stays in src/core/authz/db/store.ts");
|
|
3866
|
+
return;
|
|
3867
|
+
}
|
|
3868
|
+
const blockers = await blockersOf$1(ctx, group, await sourceFiles(ctx));
|
|
3869
|
+
if (blockers.length > 0) {
|
|
3870
|
+
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.`);
|
|
3871
|
+
ctx.report.blocked.push("authz-stores");
|
|
3872
|
+
return;
|
|
3873
|
+
}
|
|
3874
|
+
await applyGroup(ctx, group);
|
|
3875
|
+
}
|
|
3876
|
+
};
|
|
3877
|
+
//#endregion
|
|
3791
3878
|
//#region src/upgrade/codemods/core-package.ts
|
|
3792
3879
|
const CORE = "@tatara/core";
|
|
3793
3880
|
/** Files @tatara/core replaces, with the import specifier apps used for each. */
|
|
@@ -3952,6 +4039,20 @@ const dbTesting = {
|
|
|
3952
4039
|
}
|
|
3953
4040
|
};
|
|
3954
4041
|
//#endregion
|
|
4042
|
+
//#region src/upgrade/codemods/framework-tests.ts
|
|
4043
|
+
const frameworkTests = {
|
|
4044
|
+
version: "0.9.0",
|
|
4045
|
+
id: "framework-tests",
|
|
4046
|
+
description: "Remove tatara's own wiring tests from src/core; the packages and tatara's e2e cover them",
|
|
4047
|
+
async run(ctx) {
|
|
4048
|
+
for (const path of await sourceFiles(ctx)) {
|
|
4049
|
+
if (!isFrameworkTest(path)) continue;
|
|
4050
|
+
if (!await ctx.baseline.isUnchanged(path, await ctx.read(path))) continue;
|
|
4051
|
+
await ctx.removeIfUnchanged(path);
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
};
|
|
4055
|
+
//#endregion
|
|
3955
4056
|
//#region src/upgrade/codemods/http-package.ts
|
|
3956
4057
|
const HTTP = "@tatara/http";
|
|
3957
4058
|
/** Wiring files the app keeps; replaced by what this CLI generates when the app never changed them. */
|
|
@@ -4014,58 +4115,94 @@ async function outsideImporters(ctx) {
|
|
|
4014
4115
|
}
|
|
4015
4116
|
return out;
|
|
4016
4117
|
}
|
|
4118
|
+
const httpPackage = {
|
|
4119
|
+
version: "0.3.0",
|
|
4120
|
+
id: "http-package",
|
|
4121
|
+
description: "Use @tatara/http for app setup, errors, the envelope, OpenAPI docs and the server",
|
|
4122
|
+
async run(ctx) {
|
|
4123
|
+
await ctx.addDependency(HTTP, versionOf(HTTP));
|
|
4124
|
+
const blockers = [];
|
|
4125
|
+
const replace = [];
|
|
4126
|
+
for (const path of [
|
|
4127
|
+
APP,
|
|
4128
|
+
ROUTES,
|
|
4129
|
+
...SERVERS
|
|
4130
|
+
]) {
|
|
4131
|
+
const text = await read(ctx, path);
|
|
4132
|
+
if (text === void 0) continue;
|
|
4133
|
+
if (await ctx.baseline.isUnchanged(path, text)) replace.push(path);
|
|
4134
|
+
else blockers.push(`${path} is customised`);
|
|
4135
|
+
}
|
|
4136
|
+
const present = [];
|
|
4137
|
+
for (const entry of MOVED) {
|
|
4138
|
+
const text = await read(ctx, entry.path);
|
|
4139
|
+
if (text === void 0) continue;
|
|
4140
|
+
present.push(entry);
|
|
4141
|
+
if (!await ctx.baseline.isUnchanged(entry.path, text)) blockers.push(`${entry.path} is customised`);
|
|
4142
|
+
}
|
|
4143
|
+
for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
|
|
4144
|
+
if (blockers.length > 0) {
|
|
4145
|
+
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.`);
|
|
4146
|
+
ctx.report.blocked.push("http-package");
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
for (const path of replace) await ctx.regenerate(path);
|
|
4150
|
+
for (const entry of present) await ctx.removeIfUnchanged(entry.path);
|
|
4151
|
+
for (const specifier of [
|
|
4152
|
+
"#core/http/errors",
|
|
4153
|
+
"#core/http/response",
|
|
4154
|
+
"#core/http/middleware/validation"
|
|
4155
|
+
]) await ctx.rewriteImports(specifier, HTTP);
|
|
4156
|
+
}
|
|
4157
|
+
};
|
|
4158
|
+
//#endregion
|
|
4159
|
+
//#region src/upgrade/codemods/package-commands.ts
|
|
4160
|
+
/** Copies whose commands and seeds the packages contribute now (wired by `tatara sync`). */
|
|
4161
|
+
const COPIES = [
|
|
4162
|
+
"src/core/db/commands/migrate.ts",
|
|
4163
|
+
"src/core/db/commands/reset.ts",
|
|
4164
|
+
"src/core/db/commands/seed.ts",
|
|
4165
|
+
"src/core/authz/commands/grant-role.ts",
|
|
4166
|
+
"src/core/authz/commands/sync-permissions.ts",
|
|
4167
|
+
"src/core/authz/db/seed.ts",
|
|
4168
|
+
"src/core/email/commands/send-test.ts",
|
|
4169
|
+
"src/core/storage/commands/check.ts",
|
|
4170
|
+
"src/core/jobs/commands/enqueue.ts"
|
|
4171
|
+
];
|
|
4172
|
+
const specifierOf = (path) => `#${path.replace(/^src\//, "").replace(/\.ts$/, "")}`;
|
|
4017
4173
|
//#endregion
|
|
4018
4174
|
//#region src/upgrade/codemods.ts
|
|
4019
4175
|
/** Every codemod tatara ships, oldest first. Each step that moves code into a package adds one. */
|
|
4020
4176
|
const CODEMODS = [
|
|
4021
4177
|
corePackage,
|
|
4178
|
+
httpPackage,
|
|
4179
|
+
dbTesting,
|
|
4180
|
+
authPackage,
|
|
4181
|
+
authzPackage,
|
|
4182
|
+
addonPackages,
|
|
4183
|
+
frameworkTests,
|
|
4184
|
+
authzStores,
|
|
4022
4185
|
{
|
|
4023
|
-
version: "0.
|
|
4024
|
-
id: "
|
|
4025
|
-
description: "
|
|
4186
|
+
version: "0.9.0",
|
|
4187
|
+
id: "package-commands",
|
|
4188
|
+
description: "Take db, authz, email, storage and jobs commands and seeds from their packages",
|
|
4026
4189
|
async run(ctx) {
|
|
4027
|
-
await ctx
|
|
4028
|
-
const
|
|
4029
|
-
const
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
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);
|
|
4190
|
+
const sources = await sourceFiles(ctx);
|
|
4191
|
+
const texts = /* @__PURE__ */ new Map();
|
|
4192
|
+
for (const path of sources) texts.set(path, await ctx.read(path));
|
|
4193
|
+
const ejected = ejectedPackages(ctx.project.config);
|
|
4194
|
+
for (const path of COPIES) {
|
|
4195
|
+
if (!texts.has(path)) continue;
|
|
4196
|
+
if (ejected.includes(path.split("/")[2] ?? "")) continue;
|
|
4197
|
+
const importers = [...texts].filter(([other, text]) => other !== path && importsOf(text, specifierOf(path)));
|
|
4198
|
+
if (importers.length > 0) {
|
|
4199
|
+
ctx.report.notes.push(`${path} was kept: ${importers.map(([p]) => p).join(", ")} import it.`);
|
|
4200
|
+
continue;
|
|
4201
|
+
}
|
|
4202
|
+
await ctx.removeIfUnchanged(path);
|
|
4056
4203
|
}
|
|
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
4204
|
}
|
|
4064
|
-
}
|
|
4065
|
-
dbTesting,
|
|
4066
|
-
authPackage,
|
|
4067
|
-
authzPackage,
|
|
4068
|
-
addonPackages
|
|
4205
|
+
}
|
|
4069
4206
|
];
|
|
4070
4207
|
//#endregion
|
|
4071
4208
|
//#region src/upgrade/context.ts
|
|
@@ -4094,7 +4231,7 @@ function createContext(project, baseline, opts = {}) {
|
|
|
4094
4231
|
await mkdir(dirname(abs(path)), { recursive: true });
|
|
4095
4232
|
await writeFile(abs(path), text);
|
|
4096
4233
|
};
|
|
4097
|
-
|
|
4234
|
+
const ctx = {
|
|
4098
4235
|
project,
|
|
4099
4236
|
baseline,
|
|
4100
4237
|
report,
|
|
@@ -4144,6 +4281,24 @@ function createContext(project, baseline, opts = {}) {
|
|
|
4144
4281
|
});
|
|
4145
4282
|
return (await rendered).get(path);
|
|
4146
4283
|
},
|
|
4284
|
+
async regenerate(path) {
|
|
4285
|
+
const next = await ctx.renderCurrent(path);
|
|
4286
|
+
if (next === void 0) {
|
|
4287
|
+
const spec = `#${path.replace(/^src\//, "").replace(/\.ts$/, "")}`;
|
|
4288
|
+
const importers = [];
|
|
4289
|
+
for (const other of await glob(["src/**/*.ts", "tests/**/*.ts"], {
|
|
4290
|
+
cwd: project.root,
|
|
4291
|
+
ignore: ["src/generated/**"]
|
|
4292
|
+
})) if (other !== path && importsOf(await readFile(abs(other), "utf8"), spec)) importers.push(other);
|
|
4293
|
+
if (importers.length > 0) {
|
|
4294
|
+
report.notes.push(`${path} was kept: ${importers.join(", ")} import it.`);
|
|
4295
|
+
return "kept";
|
|
4296
|
+
}
|
|
4297
|
+
return ctx.removeIfUnchanged(path);
|
|
4298
|
+
}
|
|
4299
|
+
await write(path, next);
|
|
4300
|
+
return "written";
|
|
4301
|
+
},
|
|
4147
4302
|
async addDependency(name, range, { dev = false } = {}) {
|
|
4148
4303
|
if (ejected.includes(name.replace(/^@tatara\//, ""))) {
|
|
4149
4304
|
const note = `${name} is ejected; it was not added (its code is in src/ejected)`;
|
|
@@ -4159,9 +4314,33 @@ function createContext(project, baseline, opts = {}) {
|
|
|
4159
4314
|
await write("package.json", `${JSON.stringify(pkg, null, " ")}\n`);
|
|
4160
4315
|
}
|
|
4161
4316
|
};
|
|
4317
|
+
return ctx;
|
|
4162
4318
|
}
|
|
4163
4319
|
//#endregion
|
|
4164
4320
|
//#region src/commands/upgrade.ts
|
|
4321
|
+
async function pmInstall(project) {
|
|
4322
|
+
const [cmd = "pnpm", ...args] = pmCommands(project.config.packageManager).install;
|
|
4323
|
+
if ((await x(cmd, args, {
|
|
4324
|
+
throwOnError: false,
|
|
4325
|
+
nodeOptions: {
|
|
4326
|
+
cwd: project.root,
|
|
4327
|
+
stdio: "inherit"
|
|
4328
|
+
}
|
|
4329
|
+
})).exitCode !== 0) throw new Error(`${cmd} ${args.join(" ")} failed; fix it, then run tatara upgrade again`);
|
|
4330
|
+
}
|
|
4331
|
+
/** Sets every @tatara/* library the app depends on to the range this CLI ships with. */
|
|
4332
|
+
async function bumpPackages(ctx) {
|
|
4333
|
+
const pkg = JSON.parse(await ctx.read("package.json"));
|
|
4334
|
+
const known = templateVersions();
|
|
4335
|
+
for (const dev of [false, true]) for (const [name, range] of Object.entries((dev ? pkg.devDependencies : pkg.dependencies) ?? {})) {
|
|
4336
|
+
if (!name.startsWith("@tatara/") || name === "@tatara/cli" || !(name in known)) continue;
|
|
4337
|
+
const current = /^\^(\d+\.\d+\.\d+)$/.exec(range)?.[1];
|
|
4338
|
+
const next = versionOf(name);
|
|
4339
|
+
const shipped = /^\^(\d+\.\d+\.\d+)$/.exec(next)?.[1];
|
|
4340
|
+
if (!current || !shipped || compareVersions(current, shipped) >= 0) continue;
|
|
4341
|
+
await ctx.addDependency(name, next, { dev });
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4165
4344
|
async function biomeFormat(file, project) {
|
|
4166
4345
|
const biome = join(project.root, "node_modules", ".bin", "biome");
|
|
4167
4346
|
if (!existsSync(biome)) return;
|
|
@@ -4208,9 +4387,11 @@ async function upgrade(project, opts = {}) {
|
|
|
4208
4387
|
ctx.report.ran.push(mod.id);
|
|
4209
4388
|
if (ctx.report.blocked.length > blockedBefore && reached === target) reached = due.slice(0, due.indexOf(mod)).at(-1)?.version ?? from;
|
|
4210
4389
|
}
|
|
4390
|
+
await bumpPackages(ctx);
|
|
4211
4391
|
if (opts.dryRun) return ctx.report;
|
|
4212
4392
|
const format = opts.formatFile ?? biomeFormat;
|
|
4213
4393
|
for (const path of ctx.report.written) await format(path, project);
|
|
4394
|
+
await (opts.runInstall ?? pmInstall)(project);
|
|
4214
4395
|
await (opts.runSync ?? sync)(project);
|
|
4215
4396
|
const path = join(project.root, "tatara.json");
|
|
4216
4397
|
const config = JSON.parse(await readFile(path, "utf8"));
|
|
@@ -4243,7 +4424,7 @@ function printUpgrade(report, from, to) {
|
|
|
4243
4424
|
//#endregion
|
|
4244
4425
|
//#region src/bin.ts
|
|
4245
4426
|
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) => {
|
|
4427
|
+
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
4428
|
await newApp(dir, flags);
|
|
4248
4429
|
});
|
|
4249
4430
|
program.command("sync").description("Regenerate src/generated from discovered module files").option("--quiet", "no output").action(async (flags) => {
|
package/package.json
CHANGED
|
@@ -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;
|
package/templates/versions.json
CHANGED
|
@@ -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.
|
|
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.
|
|
41
|
-
"@tatara/storage": "0.
|
|
42
|
-
"@tatara/email": "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.
|
|
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
|
-
});
|