@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.
Files changed (53) hide show
  1. package/dist/bin.mjs +370 -30
  2. package/package.json +1 -1
  3. package/templates/features/auth-keycloak/files+authz/src/core/auth/tests/keycloak-authz.test.ts +2 -2
  4. package/templates/features/authz/files/src/core/authz/commands/grant-role.ts +5 -5
  5. package/templates/features/authz/files/src/core/authz/commands/sync-permissions.ts +2 -2
  6. package/templates/features/authz/files/src/core/authz/db/seed.ts +2 -2
  7. package/templates/features/authz/files/src/core/authz/index.ts +9 -0
  8. package/templates/features/authz/files/src/core/authz/routes/authorization.routes.ts +9 -280
  9. package/templates/features/authz/files/src/core/authz/system-roles.ts +0 -2
  10. package/templates/features/authz/files/src/core/authz/tests/authz.test.ts +1 -2
  11. package/templates/features/authz/files/tests/helpers/authz.ts +6 -8
  12. package/templates/features/authz/files+drizzle/src/core/authz/db/store.ts +3 -3
  13. package/templates/features/authz/files+prisma/src/core/authz/db/store.ts +3 -3
  14. package/templates/features/email/files/src/core/email/send.ts +8 -17
  15. package/templates/features/i18n/files/src/core/i18n/index.ts +5 -35
  16. package/templates/features/jobs/files/src/apps/cron/index.ts +4 -21
  17. package/templates/features/jobs/files/src/apps/worker/index.ts +11 -19
  18. package/templates/features/jobs/files/src/core/jobs/commands/enqueue.ts +1 -1
  19. package/templates/features/jobs/files/src/core/jobs/index.ts +10 -0
  20. package/templates/features/jobs/files/src/modules/example/consumers/send-welcome.ts +1 -1
  21. package/templates/features/jobs/files/src/modules/example/schedules/heartbeat.ts +1 -1
  22. package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +4 -4
  23. package/templates/features/storage/files/src/core/storage/commands/check.ts +3 -3
  24. package/templates/features/storage/files/src/core/storage/index.ts +19 -5
  25. package/templates/features/storage/files/src/core/storage/tests/storage.test.ts +1 -1
  26. package/templates/generators/consumer/files/src/modules/__moduleKebab__/consumers/__nameKebab__.ts +1 -1
  27. package/templates/generators/consumer/files/src/modules/__moduleKebab__/tests/__nameKebab__.consumer.test.ts +1 -1
  28. package/templates/generators/permission/files/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  29. package/templates/generators/resource/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  30. package/templates/generators/route/files+authz/src/modules/__moduleKebab__/permissions/__nameKebab__.permissions.ts +1 -1
  31. package/templates/generators/schedule/files/src/modules/__moduleKebab__/schedules/__nameKebab__.ts +1 -1
  32. package/templates/versions.json +6 -1
  33. package/templates/features/authz/files/src/core/authz/cache.ts +0 -31
  34. package/templates/features/authz/files/src/core/authz/catalog.ts +0 -85
  35. package/templates/features/authz/files/src/core/authz/constants.ts +0 -32
  36. package/templates/features/authz/files/src/core/authz/middleware.ts +0 -21
  37. package/templates/features/authz/files/src/core/authz/permissions.ts +0 -31
  38. package/templates/features/authz/files/src/core/authz/resolver.ts +0 -98
  39. package/templates/features/authz/files/src/core/authz/services/audit.ts +0 -24
  40. package/templates/features/authz/files/src/core/authz/services/grants.ts +0 -29
  41. package/templates/features/authz/files/src/core/authz/services/members.ts +0 -84
  42. package/templates/features/authz/files/src/core/authz/services/roles.ts +0 -73
  43. package/templates/features/authz/files/src/core/authz/services/sync.ts +0 -34
  44. package/templates/features/authz/files/src/core/authz/types.ts +0 -87
  45. package/templates/features/authz/files/src/core/authz/validators.ts +0 -99
  46. package/templates/features/jobs/files/src/core/jobs/define.ts +0 -37
  47. package/templates/features/jobs/files/src/core/jobs/lock.ts +0 -17
  48. package/templates/features/jobs/files/src/core/jobs/process.ts +0 -19
  49. package/templates/features/jobs/files/src/core/jobs/queue.ts +0 -29
  50. package/templates/features/jobs/files/src/core/jobs/redis.ts +0 -17
  51. package/templates/features/storage/files/src/core/storage/local.ts +0 -34
  52. package/templates/features/storage/files/src/core/storage/s3.ts +0 -66
  53. 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 \"#core/jobs/define\";",
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 \"#core/jobs/define\";",
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 "#core/authz/catalog";
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(corePermissions${names.map((n) => `, ${n}`).join("")});
278
+ export const catalog = mergeCatalogs(authzPermissions${names.map((n) => `, ${n}`).join("")});
280
279
 
281
280
  export type PermissionKey =
282
- | PermissionKeysOf<typeof corePermissions>${names.map((n) => `\n\t| PermissionKeysOf<typeof ${n}>`).join("")};
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/middleware\`
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/queue\`; a module handles it in
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: ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"],
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/services/members\";"
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/middleware\";");
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$2(ctx, path) {
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 from = new RegExp(`import\\s+(?:type\\s+)?([^;]*?)\\s+from\\s+["']${escapeRegExp$1(entry.specifier)}["']`, "g");
3463
+ const names = importsOf(text, entry.specifier);
3464
+ if (!names) return false;
3272
3465
  const gone = entry.names;
3273
- for (const match of text.matchAll(from)) {
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$2(ctx, path);
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$2(ctx, path) === void 0) continue;
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: target,
4083
+ version: reached,
3745
4084
  generated: {
3746
- cli: target,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, it } from "vitest";
2
- import { syncSystemRoles } from "#core/authz/services/sync";
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 "#core/authz/constants";
3
- import { store } from "#core/authz/db/store";
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)) ?? (await store.findRoleByKey(SYSTEM_ORG_ID, 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 { syncSystemRoles } from "#core/authz/services/sync";
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 { syncSystemRoles } from "#core/authz/services/sync";
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;