@tatara/cli 0.13.0 → 0.13.2

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
@@ -531,22 +531,6 @@ const admin = {
531
531
  }
532
532
  ]
533
533
  };
534
- const MEMBERS = `/** The org's members, a page at a time. */
535
- async function* members(orgId: string) {
536
- const { adapter } = await auth.$context;
537
- for (let offset = 0; ; offset += 100) {
538
- const page = await adapter.findMany<{ userId: string; role: string }>({
539
- model: "member",
540
- where: [{ field: "organizationId", value: orgId }],
541
- limit: 100,
542
- offset,
543
- sortBy: { field: "id", direction: "asc" },
544
- });
545
- yield* page;
546
- if (page.length < 100) return;
547
- }
548
- }`;
549
- const hasRole = "m.role.split(\",\").map((r) => r.trim()).includes(role)";
550
534
  const notifications = {
551
535
  id: "notifications",
552
536
  kind: "addon",
@@ -621,7 +605,21 @@ const notifications = {
621
605
  file: "src/modules/notifications/services/notifier.ts",
622
606
  slot: "helpers",
623
607
  when: ["auth-orgs"],
624
- 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
+ }`
625
623
  },
626
624
  {
627
625
  file: "src/modules/notifications/services/notifier.ts",
@@ -631,7 +629,7 @@ const notifications = {
631
629
  content: [
632
630
  "recipients: {",
633
631
  " async *role(orgId, role) {",
634
- `\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;`,
635
633
  " },",
636
634
  " permission() {",
637
635
  " throw new Error(\"Notifying by permission needs the authz add-on\");",
@@ -645,8 +643,12 @@ const notifications = {
645
643
  when: ["auth-orgs", "authz"],
646
644
  content: [
647
645
  "recipients: {",
646
+ " // authz roles (assigned, or held by every member): better-auth's member role is only owner or member",
648
647
  " async *role(orgId, role) {",
649
- `\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
+ " }",
650
652
  " },",
651
653
  " // the same check a request makes: built-in roles, assigned roles and overlays",
652
654
  " async *permission(orgId, permission) {",
@@ -662,7 +664,7 @@ const notifications = {
662
664
  file: "src/modules/notifications/services/notifier.ts",
663
665
  slot: "imports",
664
666
  when: ["auth-orgs", "authz"],
665
- 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\";"
666
668
  },
667
669
  {
668
670
  file: "src/modules/notifications/services/notifier.ts",
@@ -3983,6 +3985,30 @@ const jobsDrivers = {
3983
3985
  }
3984
3986
  };
3985
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
3986
4012
  //#region src/upgrade/codemods/package-commands.ts
3987
4013
  /** Copies whose commands and seeds the packages contribute now (wired by `tatara sync`). */
3988
4014
  const COPIES = [
@@ -4035,7 +4061,8 @@ const CODEMODS = [
4035
4061
  jobsDrivers,
4036
4062
  emailV2,
4037
4063
  deployLedger,
4038
- authzRoles
4064
+ authzRoles,
4065
+ notifierRoles
4039
4066
  ];
4040
4067
  //#endregion
4041
4068
  //#region src/upgrade/context.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/cli",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Create and grow opinionated Hono backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,4 +1,5 @@
1
- import { createApp, handleErrors, mountRoutes, serveOpenApi } from "@tatara/http";
1
+ import { corsOrigin, createApp, handleErrors, mountRoutes, serveOpenApi } from "@tatara/http";
2
+ import { cors } from "hono/cors";
2
3
  import { env } from "#core/config/env";
3
4
  import type { AppEnv } from "#core/http/context";
4
5
  import type { AppName } from "#core/http/routes";
@@ -12,13 +13,6 @@ export const API_PREFIX = "/api";
12
13
  export function createHttpApp(name: AppName, options: { title: string }) {
13
14
  const app = createApp<AppEnv>({
14
15
  logger,
15
- cors: {
16
- origins: env.CORS_ORIGINS,
17
- exposeHeaders: [
18
- "X-Request-Id",
19
- // tatara:slot expose-headers
20
- ],
21
- },
22
16
  bodyLimitBytes: env.BODY_LIMIT_BYTES,
23
17
  timeouts: {
24
18
  defaultMs: env.REQUEST_TIMEOUT_MS,
@@ -28,6 +22,25 @@ export function createHttpApp(name: AppName, options: { title: string }) {
28
22
  },
29
23
  defaults: { principal: null, language: "en" },
30
24
  });
25
+ // browsers on other origins (Hono's own middleware); corsOrigin matches exact origins and
26
+ // one-label patterns like https://*.example.com
27
+ app.use(
28
+ cors({
29
+ origin: corsOrigin(env.CORS_ORIGINS),
30
+ credentials: true,
31
+ allowHeaders: [
32
+ "Content-Type",
33
+ "Authorization",
34
+ "X-Request-Id",
35
+ "Accept-Language",
36
+ // tatara:slot allow-headers
37
+ ],
38
+ exposeHeaders: [
39
+ "X-Request-Id",
40
+ // tatara:slot expose-headers
41
+ ],
42
+ }),
43
+ );
31
44
  // tatara:slot middleware
32
45
 
33
46
  // tatara:slot routes
@@ -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
+ });
@@ -32,12 +32,12 @@
32
32
  "nodemailer": "10.1.1",
33
33
  "@types/nodemailer": "8.0.2",
34
34
  "@tatara/core": "0.1.3",
35
- "@tatara/http": "0.1.2",
35
+ "@tatara/http": "0.1.4",
36
36
  "@tatara/db": "0.2.2",
37
37
  "@tatara/testing": "0.1.1",
38
38
  "@tatara/auth": "0.2.1",
39
39
  "@tatara/keycloak": "0.1.2",
40
- "@tatara/authz": "0.5.0",
40
+ "@tatara/authz": "0.5.2",
41
41
  "@tatara/storage": "0.2.0",
42
42
  "@tatara/email": "0.3.0",
43
43
  "@tatara/i18n": "0.1.0",