@tatara/cli 0.5.0 → 0.7.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 +370 -30
- package/package.json +1 -1
- package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +2 -2
- package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +5 -5
- package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +2 -2
- package/templates/features/authz/files/src/core/authz/db/seed.ts +2 -2
- package/templates/features/authz/files/src/core/authz/index.ts +9 -0
- package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +9 -280
- package/templates/features/authz/files/src/core/authz/system-roles.ts +0 -2
- package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +1 -2
- package/templates/features/authz/files/tests/helpers/authz.ts +6 -8
- package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +3 -3
- package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +3 -3
- package/templates/features/email/files/src/core/email/send.ts +8 -17
- package/templates/features/i18n/files/src/core/i18n/index.ts +5 -35
- package/templates/features/jobs/files/src/apps/cron/index.ts +4 -21
- package/templates/features/jobs/files/src/apps/worker/index.ts +11 -19
- package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
- package/templates/features/jobs/files/src/core/jobs/index.ts +10 -0
- package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +1 -1
- package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +1 -1
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +4 -4
- package/templates/features/storage/files/src/core/storage/commands/check.ts +3 -3
- package/templates/features/storage/files/src/core/storage/index.ts +19 -5
- package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +1 -1
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +1 -1
- package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +1 -1
- package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
- package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +1 -1
- package/templates/versions.json +6 -1
- package/templates/features/authz/files/src/core/authz/cache.ts +0 -31
- package/templates/features/authz/files/src/core/authz/catalog.ts +0 -85
- package/templates/features/authz/files/src/core/authz/constants.ts +0 -32
- package/templates/features/authz/files/src/core/authz/middleware.ts +0 -21
- package/templates/features/authz/files/src/core/authz/permissions.ts +0 -31
- package/templates/features/authz/files/src/core/authz/resolver.ts +0 -98
- package/templates/features/authz/files/src/core/authz/services/audit.ts +0 -24
- package/templates/features/authz/files/src/core/authz/services/grants.ts +0 -29
- package/templates/features/authz/files/src/core/authz/services/members.ts +0 -84
- package/templates/features/authz/files/src/core/authz/services/roles.ts +0 -73
- package/templates/features/authz/files/src/core/authz/services/sync.ts +0 -34
- package/templates/features/authz/files/src/core/authz/types.ts +0 -87
- package/templates/features/authz/files/src/core/authz/validators.ts +0 -99
- package/templates/features/jobs/files/src/core/jobs/define.ts +0 -37
- package/templates/features/jobs/files/src/core/jobs/lock.ts +0 -17
- package/templates/features/jobs/files/src/core/jobs/process.ts +0 -19
- package/templates/features/jobs/files/src/core/jobs/queue.ts +0 -29
- package/templates/features/jobs/files/src/core/jobs/redis.ts +0 -17
- package/templates/features/storage/files/src/core/storage/local.ts +0 -34
- package/templates/features/storage/files/src/core/storage/s3.ts +0 -66
- package/templates/features/storage/files/src/core/storage/storage.ts +0 -21
package/dist/bin.mjs
CHANGED
|
@@ -257,14 +257,14 @@ async function sync(project = loadProject()) {
|
|
|
257
257
|
const consumers = await find(root, dirs.map((d) => `${d}/consumers/*.ts`));
|
|
258
258
|
await writeIfChanged(join(out, "consumers.ts"), listModule(consumers, {
|
|
259
259
|
prefix: "consumer",
|
|
260
|
-
typeImport: "import type { ConsumerDefinition } from \"
|
|
260
|
+
typeImport: "import type { ConsumerDefinition } from \"@tatara/jobs\";",
|
|
261
261
|
typeName: "ConsumerDefinition",
|
|
262
262
|
exportName: "consumers"
|
|
263
263
|
}), written);
|
|
264
264
|
const schedules = await find(root, dirs.map((d) => `${d}/schedules/*.ts`));
|
|
265
265
|
await writeIfChanged(join(out, "schedules.ts"), listModule(schedules, {
|
|
266
266
|
prefix: "schedule",
|
|
267
|
-
typeImport: "import type { ScheduleDefinition } from \"
|
|
267
|
+
typeImport: "import type { ScheduleDefinition } from \"@tatara/jobs\";",
|
|
268
268
|
typeName: "ScheduleDefinition",
|
|
269
269
|
exportName: "schedules"
|
|
270
270
|
}), written);
|
|
@@ -273,13 +273,12 @@ async function sync(project = loadProject()) {
|
|
|
273
273
|
const permissions = await find(root, ["src/modules/*/permissions/*.permissions.ts"]);
|
|
274
274
|
const imports = permissions.map((f) => `import ${identifier(f, "permissions")} from "${specifier(f)}";`);
|
|
275
275
|
const names = permissions.map((f) => identifier(f, "permissions"));
|
|
276
|
-
await writeIfChanged(join(out, "permissions.ts"), `${HEADER}import { mergeCatalogs, type PermissionKeysOf } from "
|
|
277
|
-
import corePermissions from "#core/authz/permissions";
|
|
276
|
+
await writeIfChanged(join(out, "permissions.ts"), `${HEADER}import { authzPermissions, mergeCatalogs, type PermissionKeysOf } from "@tatara/authz";
|
|
278
277
|
${imports.join("\n")}${imports.length ? "\n" : ""}
|
|
279
|
-
export const catalog = mergeCatalogs(
|
|
278
|
+
export const catalog = mergeCatalogs(authzPermissions${names.map((n) => `, ${n}`).join("")});
|
|
280
279
|
|
|
281
280
|
export type PermissionKey =
|
|
282
|
-
| PermissionKeysOf<typeof
|
|
281
|
+
| PermissionKeysOf<typeof authzPermissions>${names.map((n) => `\n\t| PermissionKeysOf<typeof ${n}>`).join("")};
|
|
283
282
|
`, written);
|
|
284
283
|
}
|
|
285
284
|
if (project.config.database !== "none") {
|
|
@@ -682,6 +681,7 @@ const authz = {
|
|
|
682
681
|
id: "authz",
|
|
683
682
|
kind: "addon",
|
|
684
683
|
label: "Permission system",
|
|
684
|
+
dependencies: ["@tatara/authz"],
|
|
685
685
|
slots: [
|
|
686
686
|
{
|
|
687
687
|
file: "tests/helpers/auth.ts",
|
|
@@ -711,9 +711,9 @@ const authz = {
|
|
|
711
711
|
|
|
712
712
|
- Permissions are \`resource:action\` (optionally scoped \`own | team | org\`, where a wider scope
|
|
713
713
|
satisfies a narrower one). Each module declares its resources and the grants built-in roles get
|
|
714
|
-
in \`permissions/<name>.permissions.ts\` (\`definePermissions\`); \`tatara sync\` merges them into a
|
|
714
|
+
in \`permissions/<name>.permissions.ts\` (\`definePermissions\` from \`@tatara/authz\`); \`tatara sync\` merges them into a
|
|
715
715
|
typed catalog.
|
|
716
|
-
- Guard routes with \`middleware: [requirePermission("project:read")]\` from \`#core/authz/
|
|
716
|
+
- Guard routes with \`middleware: [requirePermission("project:read")]\` from \`#core/authz/index\`
|
|
717
717
|
and declare \`security: [{ bearerAuth: ["project:read"] }]\`. For scoped actions, call
|
|
718
718
|
\`resolveScope(principal, "doc:read")\` in the service and filter rows by the returned scope.
|
|
719
719
|
- Effective permissions = role grants (assigned roles + built-in roles whose key matches an
|
|
@@ -731,6 +731,7 @@ const jobs = {
|
|
|
731
731
|
kind: "addon",
|
|
732
732
|
label: "Jobs + cron (BullMQ)",
|
|
733
733
|
dependencies: [
|
|
734
|
+
"@tatara/jobs",
|
|
734
735
|
"bullmq",
|
|
735
736
|
"ioredis",
|
|
736
737
|
"croner"
|
|
@@ -803,8 +804,8 @@ const jobs = {
|
|
|
803
804
|
slot: "sections",
|
|
804
805
|
content: `## Jobs and schedules
|
|
805
806
|
|
|
806
|
-
- Background work: \`enqueue(topic, payload)\` from \`#core/jobs/
|
|
807
|
-
\`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\`). Payloads are validated by the
|
|
807
|
+
- Background work: \`enqueue(topic, payload)\` from \`#core/jobs/index\`; a module handles it in
|
|
808
|
+
\`consumers/<name>.ts\` (\`defineConsumer({ topic, schema, handle })\` from \`@tatara/jobs\`). Payloads are validated by the
|
|
808
809
|
consumer's zod schema. Handlers must be idempotent: jobs retry.
|
|
809
810
|
- Recurring work: \`schedules/<name>.ts\` (\`defineSchedule({ name, cron, run })\`). A Redis lock makes
|
|
810
811
|
each tick run once across every cron process.
|
|
@@ -871,7 +872,11 @@ const storage = {
|
|
|
871
872
|
id: "storage",
|
|
872
873
|
kind: "addon",
|
|
873
874
|
label: "File storage",
|
|
874
|
-
dependencies: [
|
|
875
|
+
dependencies: [
|
|
876
|
+
"@tatara/storage",
|
|
877
|
+
"@aws-sdk/client-s3",
|
|
878
|
+
"@aws-sdk/s3-request-presigner"
|
|
879
|
+
],
|
|
875
880
|
env: [
|
|
876
881
|
{
|
|
877
882
|
key: "STORAGE_DRIVER",
|
|
@@ -973,7 +978,7 @@ const email = {
|
|
|
973
978
|
id: "email",
|
|
974
979
|
kind: "addon",
|
|
975
980
|
label: "Email",
|
|
976
|
-
dependencies: ["nodemailer"],
|
|
981
|
+
dependencies: ["@tatara/email", "nodemailer"],
|
|
977
982
|
devDependencies: ["@types/nodemailer"],
|
|
978
983
|
env: [
|
|
979
984
|
{
|
|
@@ -1033,6 +1038,7 @@ const i18n = {
|
|
|
1033
1038
|
id: "i18n",
|
|
1034
1039
|
kind: "addon",
|
|
1035
1040
|
label: "i18n",
|
|
1041
|
+
dependencies: ["@tatara/i18n"],
|
|
1036
1042
|
env: [{
|
|
1037
1043
|
key: "DEFAULT_LANGUAGE",
|
|
1038
1044
|
schema: "z.enum([\"en\", \"ar\"]).default(\"en\")",
|
|
@@ -1454,7 +1460,7 @@ const authOrgs = {
|
|
|
1454
1460
|
file: "src/core/auth/auth.ts",
|
|
1455
1461
|
slot: "imports",
|
|
1456
1462
|
when: ["authz"],
|
|
1457
|
-
content: "import { removeMemberAccess } from \"#core/authz/
|
|
1463
|
+
content: "import { removeMemberAccess } from \"#core/authz/index\";"
|
|
1458
1464
|
},
|
|
1459
1465
|
{
|
|
1460
1466
|
file: "tests/helpers/auth.ts",
|
|
@@ -2452,7 +2458,7 @@ function guardSlots(project, n) {
|
|
|
2452
2458
|
order: fills.length
|
|
2453
2459
|
});
|
|
2454
2460
|
if (project.has("authz")) {
|
|
2455
|
-
add("imports", "import { requirePermission } from \"#core/authz/
|
|
2461
|
+
add("imports", "import { requirePermission } from \"#core/authz/index\";");
|
|
2456
2462
|
for (const [route, action] of Object.entries(ACTIONS)) add(`guard-${route}`, `middleware: [requirePermission("${n.kebab}:${action}")] as const,\nsecurity: [{ bearerAuth: ["${n.kebab}:${action}"] }],`);
|
|
2457
2463
|
add("auth-errors", "...errorResponses(401, 403),");
|
|
2458
2464
|
} else if (project.has("auth")) {
|
|
@@ -3186,6 +3192,193 @@ function createBaseline(project, deps) {
|
|
|
3186
3192
|
} };
|
|
3187
3193
|
}
|
|
3188
3194
|
//#endregion
|
|
3195
|
+
//#region src/upgrade/codemods/imports.ts
|
|
3196
|
+
const escapeRegExp$1 = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3197
|
+
/**
|
|
3198
|
+
* The names `text` imports (or re-exports) from `specifier`, or null when it doesn't use it.
|
|
3199
|
+
* A namespace, default, side-effect or dynamic import gives ["*"]: it could reach any name.
|
|
3200
|
+
*/
|
|
3201
|
+
function importsOf(text, specifier) {
|
|
3202
|
+
const spec = `["']${escapeRegExp$1(specifier)}["']`;
|
|
3203
|
+
const names = [];
|
|
3204
|
+
let found = false;
|
|
3205
|
+
const fromClause = new RegExp(`(?:import|export)\\s+(?:type\\s+)?([^;]*?)\\s+from\\s+${spec}`, "g");
|
|
3206
|
+
for (const match of text.matchAll(fromClause)) {
|
|
3207
|
+
found = true;
|
|
3208
|
+
const braces = (match[1] ?? "").match(/^\{([^}]*)\}$/);
|
|
3209
|
+
if (!braces) {
|
|
3210
|
+
names.push("*");
|
|
3211
|
+
continue;
|
|
3212
|
+
}
|
|
3213
|
+
for (const part of (braces[1] ?? "").split(",")) {
|
|
3214
|
+
const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim();
|
|
3215
|
+
if (name) names.push(name);
|
|
3216
|
+
}
|
|
3217
|
+
}
|
|
3218
|
+
if (new RegExp(`import\\s*\\(\\s*${spec}\\s*\\)|import\\s+${spec}`).test(text)) {
|
|
3219
|
+
found = true;
|
|
3220
|
+
names.push("*");
|
|
3221
|
+
}
|
|
3222
|
+
return found ? names : null;
|
|
3223
|
+
}
|
|
3224
|
+
//#endregion
|
|
3225
|
+
//#region src/upgrade/codemods/groups.ts
|
|
3226
|
+
async function read$4(ctx, path) {
|
|
3227
|
+
return ctx.read(path).catch(() => void 0);
|
|
3228
|
+
}
|
|
3229
|
+
function sourceFiles(ctx) {
|
|
3230
|
+
return glob(["src/**/*.ts", "tests/**/*.ts"], {
|
|
3231
|
+
cwd: ctx.project.root,
|
|
3232
|
+
ignore: ["src/generated/**"]
|
|
3233
|
+
});
|
|
3234
|
+
}
|
|
3235
|
+
/** Why the group can't move ([] when it can). Changes nothing. */
|
|
3236
|
+
async function blockersOf$1(ctx, group, sources) {
|
|
3237
|
+
const blockers = [];
|
|
3238
|
+
for (const path of [...group.regenerate, ...group.remove]) {
|
|
3239
|
+
const text = await read$4(ctx, path);
|
|
3240
|
+
if (text === void 0) continue;
|
|
3241
|
+
if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
|
|
3242
|
+
}
|
|
3243
|
+
const own = /* @__PURE__ */ new Set([...group.regenerate, ...group.remove]);
|
|
3244
|
+
for (const path of sources) {
|
|
3245
|
+
if (own.has(path)) continue;
|
|
3246
|
+
const text = await ctx.read(path);
|
|
3247
|
+
for (const rewrite of group.rewrites) {
|
|
3248
|
+
const names = importsOf(text, rewrite.from);
|
|
3249
|
+
if (!names) continue;
|
|
3250
|
+
const allowed = rewrite.names;
|
|
3251
|
+
const missing = names.filter((n) => n === "*" || allowed !== void 0 && !allowed.includes(n));
|
|
3252
|
+
if (missing.length > 0) blockers.push(`${path} imports ${missing.join(", ")} from ${rewrite.from}`);
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
return blockers;
|
|
3256
|
+
}
|
|
3257
|
+
async function applyGroup(ctx, group) {
|
|
3258
|
+
for (const path of group.regenerate) {
|
|
3259
|
+
const next = await ctx.renderCurrent(path);
|
|
3260
|
+
if (next !== void 0) await ctx.write(path, next);
|
|
3261
|
+
}
|
|
3262
|
+
for (const path of group.remove) await ctx.removeIfUnchanged(path);
|
|
3263
|
+
for (const rewrite of group.rewrites) await ctx.rewriteImports(rewrite.from, rewrite.to);
|
|
3264
|
+
}
|
|
3265
|
+
//#endregion
|
|
3266
|
+
//#region src/upgrade/codemods/addon-packages.ts
|
|
3267
|
+
const groups$1 = {
|
|
3268
|
+
storage: {
|
|
3269
|
+
name: "storage",
|
|
3270
|
+
regenerate: [
|
|
3271
|
+
"src/core/storage/index.ts",
|
|
3272
|
+
"src/core/storage/commands/check.ts",
|
|
3273
|
+
"src/core/storage/tests/storage.test.ts"
|
|
3274
|
+
],
|
|
3275
|
+
remove: [
|
|
3276
|
+
"storage.ts",
|
|
3277
|
+
"local.ts",
|
|
3278
|
+
"s3.ts"
|
|
3279
|
+
].map((f) => `src/core/storage/${f}`),
|
|
3280
|
+
rewrites: [
|
|
3281
|
+
{
|
|
3282
|
+
from: "#core/storage/storage",
|
|
3283
|
+
to: "@tatara/storage"
|
|
3284
|
+
},
|
|
3285
|
+
{
|
|
3286
|
+
from: "#core/storage/local",
|
|
3287
|
+
to: "@tatara/storage",
|
|
3288
|
+
names: []
|
|
3289
|
+
},
|
|
3290
|
+
{
|
|
3291
|
+
from: "#core/storage/s3",
|
|
3292
|
+
to: "#core/storage/index",
|
|
3293
|
+
names: ["s3"]
|
|
3294
|
+
}
|
|
3295
|
+
]
|
|
3296
|
+
},
|
|
3297
|
+
email: {
|
|
3298
|
+
name: "email",
|
|
3299
|
+
regenerate: ["src/core/email/send.ts"],
|
|
3300
|
+
remove: [],
|
|
3301
|
+
rewrites: []
|
|
3302
|
+
},
|
|
3303
|
+
i18n: {
|
|
3304
|
+
name: "i18n",
|
|
3305
|
+
regenerate: ["src/core/i18n/index.ts"],
|
|
3306
|
+
remove: [],
|
|
3307
|
+
rewrites: []
|
|
3308
|
+
},
|
|
3309
|
+
jobs: {
|
|
3310
|
+
name: "jobs",
|
|
3311
|
+
regenerate: [
|
|
3312
|
+
"src/core/jobs/index.ts",
|
|
3313
|
+
"src/apps/worker/index.ts",
|
|
3314
|
+
"src/apps/cron/index.ts",
|
|
3315
|
+
"src/core/jobs/commands/enqueue.ts",
|
|
3316
|
+
"src/modules/example/tests/jobs.test.ts"
|
|
3317
|
+
],
|
|
3318
|
+
remove: [
|
|
3319
|
+
"define.ts",
|
|
3320
|
+
"process.ts",
|
|
3321
|
+
"lock.ts",
|
|
3322
|
+
"redis.ts",
|
|
3323
|
+
"queue.ts"
|
|
3324
|
+
].map((f) => `src/core/jobs/${f}`),
|
|
3325
|
+
rewrites: [
|
|
3326
|
+
{
|
|
3327
|
+
from: "#core/jobs/define",
|
|
3328
|
+
to: "@tatara/jobs"
|
|
3329
|
+
},
|
|
3330
|
+
{
|
|
3331
|
+
from: "#core/jobs/process",
|
|
3332
|
+
to: "@tatara/jobs"
|
|
3333
|
+
},
|
|
3334
|
+
{
|
|
3335
|
+
from: "#core/jobs/queue",
|
|
3336
|
+
to: "#core/jobs/index",
|
|
3337
|
+
names: ["enqueue", "QUEUE_NAME"]
|
|
3338
|
+
},
|
|
3339
|
+
{
|
|
3340
|
+
from: "#core/jobs/redis",
|
|
3341
|
+
to: "#core/jobs/index",
|
|
3342
|
+
names: ["redis"]
|
|
3343
|
+
},
|
|
3344
|
+
{
|
|
3345
|
+
from: "#core/jobs/lock",
|
|
3346
|
+
to: "@tatara/jobs",
|
|
3347
|
+
names: []
|
|
3348
|
+
}
|
|
3349
|
+
]
|
|
3350
|
+
}
|
|
3351
|
+
};
|
|
3352
|
+
/** Written even though the app has no copy yet; other files are only replaced when present. */
|
|
3353
|
+
const NEW = /* @__PURE__ */ new Set(["src/core/jobs/index.ts"]);
|
|
3354
|
+
const addonPackages = {
|
|
3355
|
+
version: "0.7.0",
|
|
3356
|
+
id: "addon-packages",
|
|
3357
|
+
description: "Use @tatara/storage, @tatara/email, @tatara/i18n and @tatara/jobs",
|
|
3358
|
+
async run(ctx) {
|
|
3359
|
+
const addons = ctx.project.config.addons;
|
|
3360
|
+
const sources = await sourceFiles(ctx);
|
|
3361
|
+
const ready = [];
|
|
3362
|
+
for (const [addon, group] of Object.entries(groups$1)) {
|
|
3363
|
+
if (!addons.includes(addon)) continue;
|
|
3364
|
+
await ctx.addDependency(`@tatara/${addon}`, versionOf(`@tatara/${addon}`));
|
|
3365
|
+
const blockers = await blockersOf$1(ctx, group, sources);
|
|
3366
|
+
if (blockers.length > 0) {
|
|
3367
|
+
ctx.report.notes.push(`${addon} was not moved onto @tatara/${addon}: ${blockers.join("; ")}. Compare with a newly generated app and move it by hand.`);
|
|
3368
|
+
if (!ctx.report.blocked.includes("addon-packages")) ctx.report.blocked.push("addon-packages");
|
|
3369
|
+
continue;
|
|
3370
|
+
}
|
|
3371
|
+
const present = [];
|
|
3372
|
+
for (const path of group.regenerate) if (await ctx.read(path).then(() => true, () => false) || NEW.has(path)) present.push(path);
|
|
3373
|
+
ready.push({
|
|
3374
|
+
...group,
|
|
3375
|
+
regenerate: present
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
3378
|
+
for (const group of ready) await applyGroup(ctx, group);
|
|
3379
|
+
}
|
|
3380
|
+
};
|
|
3381
|
+
//#endregion
|
|
3189
3382
|
//#region src/upgrade/codemods/auth-package.ts
|
|
3190
3383
|
const AUTH = "@tatara/auth";
|
|
3191
3384
|
const KEYCLOAK = "@tatara/keycloak";
|
|
@@ -3262,27 +3455,21 @@ function groups(keycloak) {
|
|
|
3262
3455
|
});
|
|
3263
3456
|
return all;
|
|
3264
3457
|
}
|
|
3265
|
-
async function read$
|
|
3458
|
+
async function read$3(ctx, path) {
|
|
3266
3459
|
return ctx.read(path).catch(() => void 0);
|
|
3267
3460
|
}
|
|
3268
|
-
const escapeRegExp$1 = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3269
3461
|
/** True when `text` imports `entry` (or one of its gone names). */
|
|
3270
3462
|
function usesExport(text, entry) {
|
|
3271
|
-
const
|
|
3463
|
+
const names = importsOf(text, entry.specifier);
|
|
3464
|
+
if (!names) return false;
|
|
3272
3465
|
const gone = entry.names;
|
|
3273
|
-
|
|
3274
|
-
if (!gone) return true;
|
|
3275
|
-
const braces = (match[1] ?? "").match(/\{([^}]*)\}/);
|
|
3276
|
-
if (!braces) return true;
|
|
3277
|
-
if ((braces[1] ?? "").split(",").map((part) => part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0]?.trim()).some((name) => name && gone.includes(name))) return true;
|
|
3278
|
-
}
|
|
3279
|
-
return false;
|
|
3466
|
+
return !gone || names.some((name) => name === "*" || gone.includes(name));
|
|
3280
3467
|
}
|
|
3281
3468
|
/** Why the group can't move, or [] when it can. */
|
|
3282
3469
|
async function blockersOf(ctx, group, sources) {
|
|
3283
3470
|
const blockers = [];
|
|
3284
3471
|
for (const path of [...group.regenerate, ...group.remove]) {
|
|
3285
|
-
const text = await read$
|
|
3472
|
+
const text = await read$3(ctx, path);
|
|
3286
3473
|
if (text === void 0) continue;
|
|
3287
3474
|
if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
|
|
3288
3475
|
}
|
|
@@ -3316,7 +3503,7 @@ const authPackage = {
|
|
|
3316
3503
|
}
|
|
3317
3504
|
for (const group of ready) {
|
|
3318
3505
|
for (const path of group.regenerate) {
|
|
3319
|
-
if (await read$
|
|
3506
|
+
if (await read$3(ctx, path) === void 0) continue;
|
|
3320
3507
|
const next = await ctx.renderCurrent(path);
|
|
3321
3508
|
if (next !== void 0) await ctx.write(path, next);
|
|
3322
3509
|
}
|
|
@@ -3327,6 +3514,151 @@ const authPackage = {
|
|
|
3327
3514
|
}
|
|
3328
3515
|
};
|
|
3329
3516
|
//#endregion
|
|
3517
|
+
//#region src/upgrade/codemods/authz-package.ts
|
|
3518
|
+
const AUTHZ = "@tatara/authz";
|
|
3519
|
+
const INDEX = "#core/authz/index";
|
|
3520
|
+
const DIR = "src/core/authz";
|
|
3521
|
+
/** Copies the package replaces. */
|
|
3522
|
+
const REMOVED = [
|
|
3523
|
+
"cache.ts",
|
|
3524
|
+
"catalog.ts",
|
|
3525
|
+
"constants.ts",
|
|
3526
|
+
"middleware.ts",
|
|
3527
|
+
"permissions.ts",
|
|
3528
|
+
"resolver.ts",
|
|
3529
|
+
"types.ts",
|
|
3530
|
+
"validators.ts",
|
|
3531
|
+
"services/audit.ts",
|
|
3532
|
+
"services/grants.ts",
|
|
3533
|
+
"services/members.ts",
|
|
3534
|
+
"services/roles.ts",
|
|
3535
|
+
"services/sync.ts"
|
|
3536
|
+
].map((f) => `${DIR}/${f}`);
|
|
3537
|
+
/** App files that stay (or are new), and become what this CLI generates. system-roles.ts is the app's config and stays as it is. */
|
|
3538
|
+
const REGENERATED$1 = [
|
|
3539
|
+
`${DIR}/index.ts`,
|
|
3540
|
+
`${DIR}/db/seed.ts`,
|
|
3541
|
+
`${DIR}/commands/grant-role.ts`,
|
|
3542
|
+
`${DIR}/commands/sync-permissions.ts`,
|
|
3543
|
+
`${DIR}/routes/authorization.routes.ts`,
|
|
3544
|
+
`${DIR}/tests/authz.test.ts`,
|
|
3545
|
+
"src/core/auth/tests/keycloak-authz.test.ts",
|
|
3546
|
+
"tests/helpers/authz.ts"
|
|
3547
|
+
];
|
|
3548
|
+
/** Where other files' imports go, and the names that exist there. undefined: every name. */
|
|
3549
|
+
const MOVES = [
|
|
3550
|
+
{
|
|
3551
|
+
from: "#core/authz/constants",
|
|
3552
|
+
to: AUTHZ
|
|
3553
|
+
},
|
|
3554
|
+
{
|
|
3555
|
+
from: "#core/authz/types",
|
|
3556
|
+
to: AUTHZ
|
|
3557
|
+
},
|
|
3558
|
+
{
|
|
3559
|
+
from: "#core/authz/catalog",
|
|
3560
|
+
to: AUTHZ
|
|
3561
|
+
},
|
|
3562
|
+
{
|
|
3563
|
+
from: "#core/authz/validators",
|
|
3564
|
+
to: AUTHZ
|
|
3565
|
+
},
|
|
3566
|
+
{
|
|
3567
|
+
from: "#core/authz/middleware",
|
|
3568
|
+
to: INDEX,
|
|
3569
|
+
names: ["requirePermission"]
|
|
3570
|
+
},
|
|
3571
|
+
{
|
|
3572
|
+
from: "#core/authz/resolver",
|
|
3573
|
+
to: INDEX,
|
|
3574
|
+
names: ["checkPermission", "resolveScope"]
|
|
3575
|
+
},
|
|
3576
|
+
{
|
|
3577
|
+
from: "#core/authz/services/members",
|
|
3578
|
+
to: INDEX,
|
|
3579
|
+
names: ["removeMemberAccess"]
|
|
3580
|
+
},
|
|
3581
|
+
{
|
|
3582
|
+
from: "#core/authz/cache",
|
|
3583
|
+
to: INDEX,
|
|
3584
|
+
names: []
|
|
3585
|
+
},
|
|
3586
|
+
{
|
|
3587
|
+
from: "#core/authz/permissions",
|
|
3588
|
+
to: INDEX,
|
|
3589
|
+
names: []
|
|
3590
|
+
},
|
|
3591
|
+
...[
|
|
3592
|
+
"audit",
|
|
3593
|
+
"grants",
|
|
3594
|
+
"roles",
|
|
3595
|
+
"sync"
|
|
3596
|
+
].map((s) => ({
|
|
3597
|
+
from: `#core/authz/services/${s}`,
|
|
3598
|
+
to: INDEX,
|
|
3599
|
+
names: []
|
|
3600
|
+
}))
|
|
3601
|
+
];
|
|
3602
|
+
/** Names the 0.5 constants file had that the package dropped. */
|
|
3603
|
+
const DROPPED_CONSTANTS = ["CACHE_TTL_MS"];
|
|
3604
|
+
async function read$2(ctx, path) {
|
|
3605
|
+
return ctx.read(path).catch(() => void 0);
|
|
3606
|
+
}
|
|
3607
|
+
const authzPackage = {
|
|
3608
|
+
version: "0.6.0",
|
|
3609
|
+
id: "authz-package",
|
|
3610
|
+
description: "Use @tatara/authz (catalog, resolver, guards, services, routes)",
|
|
3611
|
+
async run(ctx) {
|
|
3612
|
+
if (!ctx.project.config.addons.includes("authz")) return;
|
|
3613
|
+
await ctx.addDependency(AUTHZ, versionOf(AUTHZ));
|
|
3614
|
+
const blockers = [];
|
|
3615
|
+
const regenerate = [];
|
|
3616
|
+
for (const path of REGENERATED$1) {
|
|
3617
|
+
const text = await read$2(ctx, path);
|
|
3618
|
+
if (text === void 0) {
|
|
3619
|
+
if (path === `${DIR}/index.ts`) regenerate.push(path);
|
|
3620
|
+
continue;
|
|
3621
|
+
}
|
|
3622
|
+
if (await ctx.baseline.isUnchanged(path, text)) regenerate.push(path);
|
|
3623
|
+
else blockers.push(`${path} is customised`);
|
|
3624
|
+
}
|
|
3625
|
+
const removed = [];
|
|
3626
|
+
for (const path of REMOVED) {
|
|
3627
|
+
const text = await read$2(ctx, path);
|
|
3628
|
+
if (text === void 0) continue;
|
|
3629
|
+
removed.push(path);
|
|
3630
|
+
if (!await ctx.baseline.isUnchanged(path, text)) blockers.push(`${path} is customised`);
|
|
3631
|
+
}
|
|
3632
|
+
const own = /* @__PURE__ */ new Set([...REGENERATED$1, ...REMOVED]);
|
|
3633
|
+
const sources = await glob(["src/**/*.ts", "tests/**/*.ts"], {
|
|
3634
|
+
cwd: ctx.project.root,
|
|
3635
|
+
ignore: ["src/generated/**"]
|
|
3636
|
+
});
|
|
3637
|
+
for (const path of sources) {
|
|
3638
|
+
if (own.has(path)) continue;
|
|
3639
|
+
const text = await ctx.read(path);
|
|
3640
|
+
for (const move of MOVES) {
|
|
3641
|
+
const names = importsOf(text, move.from);
|
|
3642
|
+
if (!names) continue;
|
|
3643
|
+
const allowed = move.names;
|
|
3644
|
+
const missing = names.filter((n) => n === "*" || (allowed ? !allowed.includes(n) : move.from.endsWith("/constants") && DROPPED_CONSTANTS.includes(n)));
|
|
3645
|
+
if (missing.length > 0) blockers.push(`${path} imports ${missing.join(", ")} from ${move.from}`);
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
if (blockers.length > 0) {
|
|
3649
|
+
ctx.report.notes.push(`Nothing in ${DIR} was changed: ${blockers.join("; ")}. Compare with a newly generated app and move onto @tatara/authz (createAuthz in ${DIR}/index.ts) by hand.`);
|
|
3650
|
+
ctx.report.blocked.push("authz-package");
|
|
3651
|
+
return;
|
|
3652
|
+
}
|
|
3653
|
+
for (const path of regenerate) {
|
|
3654
|
+
const next = await ctx.renderCurrent(path);
|
|
3655
|
+
if (next !== void 0) await ctx.write(path, next);
|
|
3656
|
+
}
|
|
3657
|
+
for (const path of removed) await ctx.removeIfUnchanged(path);
|
|
3658
|
+
for (const move of MOVES) await ctx.rewriteImports(move.from, move.to);
|
|
3659
|
+
}
|
|
3660
|
+
};
|
|
3661
|
+
//#endregion
|
|
3330
3662
|
//#region src/upgrade/codemods/core-package.ts
|
|
3331
3663
|
const CORE = "@tatara/core";
|
|
3332
3664
|
/** Files @tatara/core replaces, with the import specifier apps used for each. */
|
|
@@ -3586,6 +3918,7 @@ const CODEMODS = [
|
|
|
3586
3918
|
for (const path of await outsideImporters(ctx)) blockers.push(`${path} imports one of the moved files`);
|
|
3587
3919
|
if (blockers.length > 0) {
|
|
3588
3920
|
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.`);
|
|
3921
|
+
ctx.report.blocked.push("http-package");
|
|
3589
3922
|
return;
|
|
3590
3923
|
}
|
|
3591
3924
|
for (const path of replace) {
|
|
@@ -3601,7 +3934,9 @@ const CODEMODS = [
|
|
|
3601
3934
|
}
|
|
3602
3935
|
},
|
|
3603
3936
|
dbTesting,
|
|
3604
|
-
authPackage
|
|
3937
|
+
authPackage,
|
|
3938
|
+
authzPackage,
|
|
3939
|
+
addonPackages
|
|
3605
3940
|
];
|
|
3606
3941
|
//#endregion
|
|
3607
3942
|
//#region src/upgrade/context.ts
|
|
@@ -3613,7 +3948,8 @@ function createContext(project, baseline, opts = {}) {
|
|
|
3613
3948
|
regenerated: [],
|
|
3614
3949
|
removed: [],
|
|
3615
3950
|
kept: [],
|
|
3616
|
-
notes: []
|
|
3951
|
+
notes: [],
|
|
3952
|
+
blocked: []
|
|
3617
3953
|
};
|
|
3618
3954
|
const abs = (path) => join(project.root, path);
|
|
3619
3955
|
let rendered;
|
|
@@ -3725,13 +4061,16 @@ async function upgrade(project, opts = {}) {
|
|
|
3725
4061
|
const from = project.config.version;
|
|
3726
4062
|
const ctx = createContext(project, opts.baseline ?? createBaseline(project), { dryRun: opts.dryRun });
|
|
3727
4063
|
const due = [...opts.codemods ?? CODEMODS].filter((m) => compareVersions(m.version, from) > 0 && compareVersions(m.version, target) <= 0).sort((a, b) => compareVersions(a.version, b.version));
|
|
4064
|
+
let reached = target;
|
|
3728
4065
|
for (const mod of due) {
|
|
4066
|
+
const blockedBefore = ctx.report.blocked.length;
|
|
3729
4067
|
try {
|
|
3730
4068
|
await mod.run(ctx);
|
|
3731
4069
|
} catch (error) {
|
|
3732
4070
|
throw new Error(`codemod ${mod.id} (${mod.version}) failed: ${error.message}`, { cause: error });
|
|
3733
4071
|
}
|
|
3734
4072
|
ctx.report.ran.push(mod.id);
|
|
4073
|
+
if (ctx.report.blocked.length > blockedBefore && reached === target) reached = due.slice(0, due.indexOf(mod)).at(-1)?.version ?? from;
|
|
3735
4074
|
}
|
|
3736
4075
|
if (opts.dryRun) return ctx.report;
|
|
3737
4076
|
const format = opts.formatFile ?? biomeFormat;
|
|
@@ -3741,9 +4080,9 @@ async function upgrade(project, opts = {}) {
|
|
|
3741
4080
|
const config = JSON.parse(await readFile(path, "utf8"));
|
|
3742
4081
|
const next = {
|
|
3743
4082
|
...config,
|
|
3744
|
-
version:
|
|
4083
|
+
version: reached,
|
|
3745
4084
|
generated: {
|
|
3746
|
-
cli:
|
|
4085
|
+
cli: reached,
|
|
3747
4086
|
files: await nextRecord(project, ctx.report)
|
|
3748
4087
|
}
|
|
3749
4088
|
};
|
|
@@ -3763,6 +4102,7 @@ function printUpgrade(report, from, to) {
|
|
|
3763
4102
|
for (const path of report.removed) console.log(pc.dim(` removed ${path} (now provided by a package)`));
|
|
3764
4103
|
for (const { path } of report.kept) console.log(pc.yellow(` kept ${path}: changed in this app; move the change into options or eject`));
|
|
3765
4104
|
for (const note of report.notes) console.log(` ${note}`);
|
|
4105
|
+
if (report.blocked.length > 0) console.log(pc.yellow(` ${report.blocked.join(", ")} did not run: make the changes above, then run tatara upgrade again`));
|
|
3766
4106
|
}
|
|
3767
4107
|
//#endregion
|
|
3768
4108
|
//#region src/bin.ts
|
package/package.json
CHANGED
package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { authz } from "#core/authz/index";
|
|
3
3
|
import { api } from "#tests/helpers/api";
|
|
4
4
|
import { keycloakToken } from "#tests/helpers/auth";
|
|
5
5
|
import { resetDatabase } from "#tests/helpers/db";
|
|
@@ -12,7 +12,7 @@ beforeEach(async () => {
|
|
|
12
12
|
|
|
13
13
|
describe("Keycloak access tokens with authz", () => {
|
|
14
14
|
it("lets token roles match built-in authz roles", async () => {
|
|
15
|
-
await syncSystemRoles();
|
|
15
|
+
await authz.syncSystemRoles();
|
|
16
16
|
const admin = await keycloakToken({ roles: ["admin"] });
|
|
17
17
|
const user = await keycloakToken({ roles: ["user"] });
|
|
18
18
|
expect((await api.get("/api/authorization/roles", { headers: bearer(admin) })).status).toBe(
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "
|
|
3
|
-
import {
|
|
4
|
-
import { assignRole } from "#core/authz/services/members";
|
|
2
|
+
import { DEFAULT_ORG_ID, SYSTEM_ORG_ID } from "@tatara/authz";
|
|
3
|
+
import { authz } from "#core/authz/index";
|
|
5
4
|
import { defineCommand } from "@tatara/core";
|
|
6
5
|
|
|
7
6
|
export default defineCommand({
|
|
@@ -13,9 +12,10 @@ export default defineCommand({
|
|
|
13
12
|
org: z.string().default(DEFAULT_ORG_ID),
|
|
14
13
|
}),
|
|
15
14
|
async run({ user, role, org }, { logger }) {
|
|
16
|
-
const found = (await store.findRoleByKey(org, role)) ??
|
|
15
|
+
const found = (await authz.store.findRoleByKey(org, role)) ??
|
|
16
|
+
(await authz.store.findRoleByKey(SYSTEM_ORG_ID, role));
|
|
17
17
|
if (!found) throw new Error(`No role "${role}" (run authz:sync-permissions first for built-in roles)`);
|
|
18
|
-
const added = await assignRole(null, org, user, found.id);
|
|
18
|
+
const added = await authz.assignRole(null, org, user, found.id);
|
|
19
19
|
logger.info({ user, role, org, added }, added ? "role assigned" : "role already assigned");
|
|
20
20
|
},
|
|
21
21
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { defineCommand } from "@tatara/core";
|
|
2
|
-
import {
|
|
2
|
+
import { authz } from "#core/authz/index";
|
|
3
3
|
|
|
4
4
|
export default defineCommand({
|
|
5
5
|
name: "authz:sync-permissions",
|
|
6
6
|
description: "Create/update built-in roles so their grants match the code (run on every deploy)",
|
|
7
7
|
async run(_args, { logger }) {
|
|
8
|
-
const result = await syncSystemRoles();
|
|
8
|
+
const result = await authz.syncSystemRoles();
|
|
9
9
|
logger.info(result, "built-in roles in sync");
|
|
10
10
|
},
|
|
11
11
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { authz } from "#core/authz/index";
|
|
2
2
|
import { defineSeed } from "@tatara/db";
|
|
3
3
|
|
|
4
4
|
export default defineSeed({
|
|
5
5
|
name: "authz:system-roles",
|
|
6
6
|
order: -10,
|
|
7
7
|
async run({ logger }) {
|
|
8
|
-
logger.info(await syncSystemRoles(), "built-in roles in sync");
|
|
8
|
+
logger.info(await authz.syncSystemRoles(), "built-in roles in sync");
|
|
9
9
|
},
|
|
10
10
|
});
|
|
@@ -0,0 +1,9 @@
|
|
|
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;
|