@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 +273 -62
- 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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
3094
|
-
|
|
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
|
-
|
|
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.
|
|
4024
|
-
id: "
|
|
4025
|
-
description: "
|
|
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
|
|
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);
|
|
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
|
-
|
|
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
|
@@ -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
|
-
});
|