@tatara/cli 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs CHANGED
@@ -84,6 +84,18 @@ const authz = {
84
84
  label: "Permission system",
85
85
  dependencies: ["@tatara/authz"],
86
86
  slots: [
87
+ {
88
+ file: "src/core/authz/system-roles.ts",
89
+ slot: "admin",
90
+ unless: ["auth-keycloak"],
91
+ content: "{ key: \"admin\", name: \"Admin\", description: \"Full access, including roles and members.\" },"
92
+ },
93
+ {
94
+ file: "src/core/authz/system-roles.ts",
95
+ slot: "admin",
96
+ when: ["auth-keycloak"],
97
+ content: "{ key: \"admin\", name: \"Admin\", description: \"Full access, including roles and members.\", heldBy: \"identity\" },"
98
+ },
87
99
  {
88
100
  file: "src/core/authz/index.ts",
89
101
  slot: "options",
@@ -136,8 +148,11 @@ const authz = {
136
148
  - Guard routes with \`middleware: [requirePermission("project:read")]\` from \`#core/authz/index\`
137
149
  and declare \`security: [{ bearerAuth: ["project:read"] }]\`. For scoped actions, call
138
150
  \`resolveScope(principal, "doc:read")\` in the service and filter rows by the returned scope.
139
- - Effective permissions = role grants (assigned roles + built-in roles whose key matches an
140
- identity-provider role) + grant overlays − deny overlays. Deny always wins.
151
+ - Effective permissions = grants of the caller's roles + grant overlays − deny overlays. Deny
152
+ always wins. Roles are assigned in authz (the org's creator gets \`admin\`); a built-in role with
153
+ \`heldBy: "members"\` goes to every member, \`heldBy: "identity"\` to holders of that identity role.
154
+ - better-auth's member role is membership only (\`owner\` or \`member\`): \`authzPlugin\` in
155
+ \`src/modules/users/services/auth.ts\` refuses others and gives the creator \`admin\`.
141
156
  - Custom roles and grant overlays can never grant \`role\`, \`member\` or \`audit_log\`.
142
157
  - Built-in roles live in \`src/core/authz/system-roles.ts\`. \`authz:sync-permissions\` brings the database
143
158
  in line with them; \`deploy\` runs it every deploy, \`db:seed\` locally.
@@ -476,7 +491,7 @@ const admin = {
476
491
  {
477
492
  file: "src/core/authz/system-roles.ts",
478
493
  slot: "roles",
479
- content: "{ key: \"staff\", name: \"Staff\", description: \"Internal staff: platform permissions on the admin app.\" },"
494
+ content: "{ key: \"staff\", name: \"Staff\", description: \"Internal staff: platform permissions on the admin app.\", heldBy: \"identity\" },"
480
495
  },
481
496
  {
482
497
  file: "src/modules/staff/services/auth.ts",
@@ -516,22 +531,6 @@ const admin = {
516
531
  }
517
532
  ]
518
533
  };
519
- const MEMBERS = `/** The org's members, a page at a time. */
520
- async function* members(orgId: string) {
521
- const { adapter } = await auth.$context;
522
- for (let offset = 0; ; offset += 100) {
523
- const page = await adapter.findMany<{ userId: string; role: string }>({
524
- model: "member",
525
- where: [{ field: "organizationId", value: orgId }],
526
- limit: 100,
527
- offset,
528
- sortBy: { field: "id", direction: "asc" },
529
- });
530
- yield* page;
531
- if (page.length < 100) return;
532
- }
533
- }`;
534
- const hasRole = "m.role.split(\",\").map((r) => r.trim()).includes(role)";
535
534
  const notifications = {
536
535
  id: "notifications",
537
536
  kind: "addon",
@@ -606,7 +605,21 @@ const notifications = {
606
605
  file: "src/modules/notifications/services/notifier.ts",
607
606
  slot: "helpers",
608
607
  when: ["auth-orgs"],
609
- content: MEMBERS
608
+ content: `/** The org's members, a page at a time. */
609
+ async function* members(orgId: string) {
610
+ const { adapter } = await auth.$context;
611
+ for (let offset = 0; ; offset += 100) {
612
+ const page = await adapter.findMany<{ userId: string; role: string }>({
613
+ model: "member",
614
+ where: [{ field: "organizationId", value: orgId }],
615
+ limit: 100,
616
+ offset,
617
+ sortBy: { field: "id", direction: "asc" },
618
+ });
619
+ yield* page;
620
+ if (page.length < 100) return;
621
+ }
622
+ }`
610
623
  },
611
624
  {
612
625
  file: "src/modules/notifications/services/notifier.ts",
@@ -616,7 +629,7 @@ const notifications = {
616
629
  content: [
617
630
  "recipients: {",
618
631
  " async *role(orgId, role) {",
619
- `\t\tfor await (const m of members(orgId)) if (${hasRole}) yield m.userId;`,
632
+ `\t\tfor await (const m of members(orgId)) if (m.role.split(",").map((r) => r.trim()).includes(role)) yield m.userId;`,
620
633
  " },",
621
634
  " permission() {",
622
635
  " throw new Error(\"Notifying by permission needs the authz add-on\");",
@@ -630,8 +643,12 @@ const notifications = {
630
643
  when: ["auth-orgs", "authz"],
631
644
  content: [
632
645
  "recipients: {",
646
+ " // authz roles (assigned, or held by every member): better-auth's member role is only owner or member",
633
647
  " async *role(orgId, role) {",
634
- `\t\tfor await (const m of members(orgId)) if (${hasRole}) yield m.userId;`,
648
+ " for await (const m of members(orgId)) {",
649
+ " const who = { userId: m.userId, orgId, orgRole: m.role, roles: [], kind: \"user\" as const, claims: {} };",
650
+ " if ((await authz.effectivePermissions(who)).roles.includes(role)) yield m.userId;",
651
+ " }",
635
652
  " },",
636
653
  " // the same check a request makes: built-in roles, assigned roles and overlays",
637
654
  " async *permission(orgId, permission) {",
@@ -647,7 +664,7 @@ const notifications = {
647
664
  file: "src/modules/notifications/services/notifier.ts",
648
665
  slot: "imports",
649
666
  when: ["auth-orgs", "authz"],
650
- content: "import { checkPermission } from \"#core/authz/index\";\nimport type { PermissionKey } from \"#generated/permissions\";"
667
+ content: "import { authz, checkPermission } from \"#core/authz/index\";\nimport type { PermissionKey } from \"#generated/permissions\";"
651
668
  },
652
669
  {
653
670
  file: "src/modules/notifications/services/notifier.ts",
@@ -1546,16 +1563,15 @@ const authOrgs = {
1546
1563
  slot: "plugins",
1547
1564
  when: ["authz"],
1548
1565
  content: [
1549
- "organization({",
1550
- " organizationHooks: {",
1551
- " // whoever creates an org administers it (pick another built-in role, or drop this)",
1552
- " afterCreateOrganization: async ({ organization, member }) => {",
1553
- " await authz.grantSystemRole(organization.id, member.userId, \"admin\", member.userId);",
1554
- " },",
1555
- " // authz roles and overlays go with the membership, so re-adding someone starts clean",
1556
- " afterRemoveMember: async ({ member }) => {",
1557
- " await authz.removeMemberAccess(member.organizationId, member.userId);",
1558
- " },",
1566
+ "organization(),",
1567
+ "// better-auth keeps who is in which org (owner or member); authz decides what they may do.",
1568
+ "// The creator gets admin (`creatorRole`). better-auth shows these lists, emails included,",
1569
+ "// to any member: `endpoints` puts them behind an authz permission",
1570
+ "authzPlugin(authz, {",
1571
+ " endpoints: {",
1572
+ " \"/organization/list-members\": \"member:read\",",
1573
+ " \"/organization/get-full-organization\": \"member:read\",",
1574
+ " \"/organization/list-invitations\": \"member:read\",",
1559
1575
  " },",
1560
1576
  "}),",
1561
1577
  "admin(),"
@@ -1566,7 +1582,7 @@ const authOrgs = {
1566
1582
  file: "src/modules/users/services/auth.ts",
1567
1583
  slot: "imports",
1568
1584
  when: ["authz"],
1569
- content: "import { authz } from \"#core/authz/index\";"
1585
+ content: "import { authzPlugin } from \"@tatara/authz/better-auth\";\nimport { authz } from \"#core/authz/index\";"
1570
1586
  },
1571
1587
  {
1572
1588
  file: "tests/helpers/auth.ts",
@@ -3484,6 +3500,41 @@ const authzPackage = {
3484
3500
  }
3485
3501
  };
3486
3502
  //#endregion
3503
+ //#region src/upgrade/codemods/authz-roles.ts
3504
+ const PATH = "src/core/authz/system-roles.ts";
3505
+ /** Adds `heldBy` to the role literal `{ key: "<key>", … }`, unless it has one. */
3506
+ function holdRole(text, key, heldBy) {
3507
+ const start = text.search(new RegExp(`\\{\\s*key:\\s*"${key}"`));
3508
+ if (start === -1) return text;
3509
+ const end = text.indexOf("}", start);
3510
+ if (end === -1) return text;
3511
+ const literal = text.slice(start, end);
3512
+ if (literal.includes("heldBy")) return text;
3513
+ const body = literal.replace(/,?\s*$/, "");
3514
+ return `${text.slice(0, start)}${body}, heldBy: "${heldBy}" ${text.slice(end)}`;
3515
+ }
3516
+ /** Roles only follow better-auth's member or user role where the app says so (`heldBy`). */
3517
+ const authzRoles = {
3518
+ version: "0.13.0",
3519
+ id: "authz-roles",
3520
+ description: "Built-in roles say who holds them (heldBy); better-auth's member role is membership only",
3521
+ async run(ctx) {
3522
+ const { config } = ctx.project;
3523
+ if (!config.addons.includes("authz")) return;
3524
+ const text = await ctx.read(PATH).catch(() => void 0);
3525
+ if (text !== void 0) {
3526
+ let next = holdRole(text, "member", "members");
3527
+ next = holdRole(next, "staff", "identity");
3528
+ if (config.authMethods.includes("keycloak")) {
3529
+ for (const [, key] of text.matchAll(/\{\s*key:\s*"([^"]+)"/g)) if (key && key !== "member") next = holdRole(next, key, "identity");
3530
+ }
3531
+ if (next !== text) await ctx.write(PATH, next);
3532
+ }
3533
+ ctx.report.notes.push("Built-in roles no longer follow better-auth's member role (or the user role, unless the role has heldBy: \"identity\"). Give access with authz roles (grantSystemRole, assignRole); `member` is held by every member (heldBy: \"members\").");
3534
+ if (config.authMethods.includes("orgs")) ctx.report.notes.push("In src/modules/users/services/auth.ts, replace the organizationHooks that grant the creator's role and remove member access with authzPlugin(authz, { endpoints: { \"/organization/list-members\": \"member:read\", \"/organization/get-full-organization\": \"member:read\", \"/organization/list-invitations\": \"member:read\" } }) from @tatara/authz/better-auth, after organization(). It also keeps member roles to owner and member.");
3535
+ }
3536
+ };
3537
+ //#endregion
3487
3538
  //#region src/upgrade/codemods/authz-stores.ts
3488
3539
  const group = {
3489
3540
  name: "authz store",
@@ -3934,6 +3985,30 @@ const jobsDrivers = {
3934
3985
  }
3935
3986
  };
3936
3987
  //#endregion
3988
+ //#region src/upgrade/codemods/notifier-roles.ts
3989
+ const NOTIFIER = "src/modules/notifications/services/notifier.ts";
3990
+ const TEST = "src/modules/notifications/tests/role-recipients.test.ts";
3991
+ /** `recipients.role` reads authz roles: better-auth's member role is only owner or member. */
3992
+ const notifierRoles = {
3993
+ version: "0.13.1",
3994
+ id: "notifier-roles",
3995
+ description: "Notifying by role reaches holders of the authz role",
3996
+ async run(ctx) {
3997
+ const { config } = ctx.project;
3998
+ const addons = config.addons;
3999
+ if (!addons.includes("notifications") || !addons.includes("authz")) return;
4000
+ if (!config.authMethods.includes("orgs")) return;
4001
+ const text = await ctx.read(NOTIFIER).catch(() => void 0);
4002
+ if (text === void 0) return;
4003
+ if (await ctx.baseline.isUnchanged(NOTIFIER, text)) await ctx.regenerate(NOTIFIER);
4004
+ else ctx.report.notes.push(`In ${NOTIFIER}, recipients.role should check authz roles: \`(await authz.effectivePermissions(who)).roles.includes(role)\` for each member, with who as in recipients.permission. Matching member.role finds nobody now that it is only owner or member.`);
4005
+ if (await ctx.read(TEST).catch(() => void 0) === void 0) {
4006
+ const test = await ctx.renderCurrent(TEST);
4007
+ if (test !== void 0) await ctx.write(TEST, test);
4008
+ }
4009
+ }
4010
+ };
4011
+ //#endregion
3937
4012
  //#region src/upgrade/codemods/package-commands.ts
3938
4013
  /** Copies whose commands and seeds the packages contribute now (wired by `tatara sync`). */
3939
4014
  const COPIES = [
@@ -3985,7 +4060,9 @@ const CODEMODS = [
3985
4060
  httpLimits,
3986
4061
  jobsDrivers,
3987
4062
  emailV2,
3988
- deployLedger
4063
+ deployLedger,
4064
+ authzRoles,
4065
+ notifierRoles
3989
4066
  ];
3990
4067
  //#endregion
3991
4068
  //#region src/upgrade/context.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,7 +1,10 @@
1
- /** Built-in roles. Add a key here, then grant it permissions in module permission files. */
1
+ /**
2
+ * Built-in roles. Add a key here, then grant it permissions in module permission files. A role is
3
+ * held only by those it's assigned to, unless `heldBy` says every org member or an identity role.
4
+ */
2
5
  export const SYSTEM_ROLES = [
3
- { key: "admin", name: "Admin", description: "Full access, including roles and members." },
4
- { key: "member", name: "Member", description: "Default role for every member." },
6
+ // tatara:slot admin
7
+ { key: "member", name: "Member", description: "Every member of the org.", heldBy: "members" },
5
8
  // tatara:slot roles
6
9
  ] as const;
7
10
 
@@ -0,0 +1,34 @@
1
+ import type { InlineDriver } from "@tatara/jobs";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { jobs } from "#core/jobs/index";
4
+ import { notify } from "#modules/notifications/services/notifier";
5
+ import { api } from "#tests/helpers/api";
6
+ import { authenticate } from "#tests/helpers/auth";
7
+ import { applyTestAccess } from "#tests/helpers/authz";
8
+ import { resetDatabase } from "#tests/helpers/db";
9
+
10
+ const drain = () => (jobs.driver as InlineDriver).drain();
11
+
12
+ beforeEach(async () => {
13
+ await resetDatabase();
14
+ });
15
+
16
+ describe("notifying by role", () => {
17
+ it("reaches the members who hold the authz role, not better-auth's member role", async () => {
18
+ const admin = await authenticate();
19
+ await applyTestAccess(admin.userId, { systemRoles: ["admin"], orgId: admin.orgId });
20
+ const plain = await authenticate();
21
+ await notify({
22
+ orgId: admin.orgId as string,
23
+ to: { role: "admin" },
24
+ notification: "example.welcome",
25
+ data: { name: "Ada" },
26
+ });
27
+ await drain();
28
+ const count = async (headers: Record<string, string>) =>
29
+ (await api.get<{ data: { count: number } }>("/api/notifications/unread-count", { headers }))
30
+ .body.data.count;
31
+ expect(await count(admin.headers)).toBe(1);
32
+ expect(await count(plain.headers)).toBe(0);
33
+ });
34
+ });
@@ -35,9 +35,9 @@
35
35
  "@tatara/http": "0.1.2",
36
36
  "@tatara/db": "0.2.2",
37
37
  "@tatara/testing": "0.1.1",
38
- "@tatara/auth": "0.2.0",
38
+ "@tatara/auth": "0.2.1",
39
39
  "@tatara/keycloak": "0.1.2",
40
- "@tatara/authz": "0.4.0",
40
+ "@tatara/authz": "0.5.0",
41
41
  "@tatara/storage": "0.2.0",
42
42
  "@tatara/email": "0.3.0",
43
43
  "@tatara/i18n": "0.1.0",