create-m5kdev 0.34.0 → 0.36.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 (65) hide show
  1. package/package.json +1 -1
  2. package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +1 -0
  3. package/templates/minimal-app/.cursor/rules/server-event-emit.mdc +41 -0
  4. package/templates/minimal-app/AGENTS.md.tpl +1 -0
  5. package/templates/minimal-app/CLAUDE.md.tpl +1 -0
  6. package/templates/minimal-app/README.md.tpl +2 -1
  7. package/templates/minimal-app/apps/e2e/servers.ts.tpl +3 -0
  8. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +2 -34
  9. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +225 -5
  10. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +47 -1
  11. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +290 -8
  12. package/templates/minimal-app/apps/expo/app.json.tpl +1 -0
  13. package/templates/minimal-app/apps/expo/package.json.tpl +4 -0
  14. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +4 -1
  15. package/templates/minimal-app/apps/expo/src/app/notifications.tsx.tpl +70 -0
  16. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +8 -16
  17. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +4 -1
  18. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +11 -2
  19. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +2 -3
  20. package/templates/minimal-app/apps/server/AGENTS.md.tpl +1 -0
  21. package/templates/minimal-app/apps/server/db.e2e.ts.tpl +5 -40
  22. package/templates/minimal-app/apps/server/db.ts.tpl +5 -42
  23. package/templates/minimal-app/apps/server/drizzle/{0000_wealthy_absorbing_man.sql → 0000_whole_echo.sql} +102 -3
  24. package/templates/minimal-app/apps/server/drizzle/0001_yummy_spectrum.sql +26 -0
  25. package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +47 -0
  26. package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +4 -0
  27. package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +99 -0
  28. package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +2 -0
  29. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2625 -1780
  30. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2671 -1796
  31. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2716 -1818
  32. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +715 -164
  33. package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2498 -0
  34. package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2484 -0
  35. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +23 -9
  36. package/templates/minimal-app/apps/server/src/app.ts.tpl +4 -1
  37. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.module.ts.tpl +7 -3
  38. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.service.ts.tpl +12 -4
  39. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +2 -2
  40. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +7 -4
  41. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +2 -2
  42. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +13 -36
  43. package/templates/minimal-app/apps/server/src/schema.ts.tpl +3 -2
  44. package/templates/minimal-app/apps/shared/package.json.tpl +2 -0
  45. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +1 -1
  46. package/templates/minimal-app/apps/shared/src/modules/demo-workflow/demo-workflow.constants.ts.tpl +1 -0
  47. package/templates/minimal-app/apps/shared/src/modules/notification/notification.constants.ts.tpl +5 -0
  48. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +1 -1
  49. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +8 -5
  50. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +17 -1
  51. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +10 -8
  52. package/templates/minimal-app/apps/webapp/src/StarterServerEventProvider.tsx.tpl +38 -0
  53. package/templates/minimal-app/apps/webapp/src/modules/notification/NotificationPreferencesRoute.tsx.tpl +29 -0
  54. package/templates/minimal-app/apps/webapp/src/modules/notification/StarterWebPush.tsx.tpl +44 -0
  55. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +8 -0
  56. package/templates/minimal-app/package.json.tpl +5 -4
  57. package/templates/minimal-app/pnpm-workspace.yaml.tpl +12 -11
  58. package/templates/minimal-app/template.manifest.json +1 -0
  59. package/templates/minimal-app/apps/landing/scripts/fly-deploy.mjs.tpl +0 -75
  60. package/templates/minimal-app/apps/landing/scripts/fly-secrets.mjs.tpl +0 -31
  61. package/templates/minimal-app/apps/server/drizzle/0001_third_ink.sql +0 -1
  62. package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +0 -3
  63. package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +0 -27
  64. package/templates/minimal-app/apps/shared/scripts/fly-deploy.mjs.tpl +0 -75
  65. package/templates/minimal-app/apps/shared/scripts/fly-secrets.mjs.tpl +0 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-m5kdev",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "license": "GPL-3.0-only",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -88,6 +88,7 @@ Mutation input shapes are enforced by the router's inferred types — no
88
88
 
89
89
  - Invalidate intentionally with the matching `queryFilter()` after mutations.
90
90
  - Never call `queryClient.clear()` or invalidate everything.
91
+ - Background Workflow completions: invalidate from a `ServerEventProvider` handler. The trigger mutation only returns `jobId`. See `.cursor/rules/server-event-emit.mdc`.
91
92
 
92
93
  ## Pending state
93
94
 
@@ -0,0 +1,41 @@
1
+ ---
2
+ description: Emit Server events from background jobs, not from mutations that already return the entity
3
+ globs: **/*.service.ts,**/*.jobs.ts,**/App.tsx,**/*ServerEventProvider.tsx
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Server event emit
8
+
9
+ Emit through Auth (`userEmit`, `batchUserEmit`, `organizationEmit`, `emitServerEvent`) when work finishes **off the request** that started it. Typical case: a Workflow job. The UI will not get a completion payload, so SSE tells it to refetch or patch cache.
10
+
11
+ The acting User already receives the mutation result. They do not need an SSE for that write.
12
+
13
+ ## Emit from
14
+
15
+ - Workflow job `.handle` after the job creates or updates a resource
16
+ - Other background work the User did not wait on (cron, inbound callback that persisted something)
17
+
18
+ ```typescript
19
+ .handle(async (payload) => {
20
+ // ... job work ...
21
+ this.service.auth.userEmit({
22
+ userId: payload.userId,
23
+ resource: "workflow",
24
+ id: jobName,
25
+ change: "updated",
26
+ organizationId: null,
27
+ });
28
+ });
29
+ ```
30
+
31
+ Call Auth directly. Do not wrap `userEmit` in a private helper that only forwards.
32
+
33
+ ## Organization fan-out
34
+
35
+ `organizationEmit` (or `emitServerEvent` with an Organization id) notifies **other Members**. Use it when a background job (or similar off-request work) creates or changes an org-scoped entity those Members must see. Skip it for tRPC writes that already return the entity, including publish.
36
+
37
+ ## Client
38
+
39
+ Mount `ServerEventProvider` in the app shell. Register a handler that invalidates the matching `queryFilter()`. Reconnect has no replay: `onReconnect` should invalidate the same queries. Keep polling only as a fallback for in-progress status (queued → running) if you emit only when the job finishes.
40
+
41
+ Starter: `DemoWorkflowService` `demo.ping` + `trpc.workflow.list`.
@@ -25,6 +25,7 @@
25
25
  - Do not add service methods that merely call another service. Call the owning service directly unless the method adds a business rule, authorization, validation, orchestration, a transaction boundary, or meaningful domain translation.
26
26
  - Renaming a dependency method, repackaging arguments, constructing a prompt, or forwarding actor/context data alone does not justify a service wrapper.
27
27
  - Do not create Drizzle migrations by hand. Use the scaffolded config and your project migration workflow later if you need generated migrations.
28
+ - Server event: emit through Auth from Workflow job handlers (and similar background work) when the UI must refresh a change the triggering mutation did not return. See `.cursor/rules/server-event-emit.mdc`.
28
29
 
29
30
  ## Frontend Conventions
30
31
 
@@ -117,6 +117,7 @@ The framework's detailed conventions live in `.cursor/rules/*.mdc`. Cursor appli
117
117
  | `.cursor/rules/module-dto-guide.mdc` | `apps/server/src/modules/**/*.dto.ts` |
118
118
  | `.cursor/rules/module-repository-guide.mdc` | `apps/server/src/modules/**/*.repository.ts` |
119
119
  | `.cursor/rules/module-service-guide.mdc` | `apps/server/src/modules/**/*.service.ts` |
120
+ | `.cursor/rules/server-event-emit.mdc` | `*.service.ts`, `*.jobs.ts`, webapp `App.tsx` / `*ServerEventProvider.tsx` |
120
121
  | `.cursor/rules/module-trpc-guide.mdc` | `apps/server/src/modules/**/*.trpc.ts` |
121
122
  | `.cursor/rules/module-grants-guide.mdc` | `apps/server/src/modules/**/*.grants.ts` |
122
123
  | `.cursor/rules/module-module-guide.mdc` | `apps/server/src/modules/**/*.module.ts` |
@@ -76,7 +76,8 @@ pnpm landing:deploy
76
76
  ```
77
77
 
78
78
  `app:deploy` / `landing:deploy` read that app’s `.env.production` and forward
79
- every key as `fly deploy --build-secret`. A missing env file errors with a
79
+ every key as `fly deploy --build-secret` via Kernel bins
80
+ (`m5kdev-fly-deploy` / `m5kdev-fly-secrets`). A missing env file errors with a
80
81
  copy-the-example message. Product data lives on the `libsql_data` volume at
81
82
  `/app/data` (`DATABASE_URL=file:/app/data/local.db`). Redis is a runtime secret,
82
83
  not an image layer.
@@ -61,6 +61,9 @@ function serverEnv(profile: E2EProfile, expo: boolean): Record<string, string> {
61
61
  BETTER_AUTH_SECRET: "auth-e2e-local-secret-auth-e2e-local-secret",
62
62
  SYSTEM_NOTIFICATION_EMAIL: "ops@auth-e2e.local",
63
63
  NODE_ENV: "development",
64
+ // hermetic: shared/.env Turso credentials must not leak into drizzle-kit
65
+ TURSO_DATABASE_URL: "",
66
+ TURSO_AUTH_TOKEN: "",
64
67
  // hermetic: a real key in the invoking shell would flip email mode to
65
68
  // "send" and the stored-email assertions would find nothing
66
69
  RESEND_API_KEY: "",
@@ -1,40 +1,8 @@
1
- import type { BackendTRPCRouter } from "@m5kdev/backend/types";
2
- import { transformer } from "@m5kdev/commons/utils/trpc";
3
1
  import type { Page } from "@playwright/test";
4
2
  import { expect } from "@playwright/test";
5
- import { createTRPCClient, httpBatchLink } from "@trpc/client";
6
- import { type Profile, profiles } from "./helpers";
3
+ import { createTrpcClient } from "./helpers";
7
4
 
8
- type FetchInit = NonNullable<Parameters<typeof fetch>[1]>;
9
-
10
- async function cookieHeader(page: Page, profile: Profile) {
11
- const cookies = await page.context().cookies(profiles[profile].serverUrl);
12
- return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
13
- }
14
-
15
- export async function createExpoTrpcClient(page: Page, profile: Profile) {
16
- const cookie = await cookieHeader(page, profile);
17
-
18
- return createTRPCClient<BackendTRPCRouter>({
19
- links: [
20
- httpBatchLink({
21
- url: `${profiles[profile].serverUrl}/trpc`,
22
- fetch(url, options) {
23
- const headers = new Headers(options?.headers);
24
- if (cookie) {
25
- headers.set("cookie", cookie);
26
- }
27
- return fetch(url, {
28
- ...options,
29
- body: options?.body ? (options.body as FetchInit["body"]) : undefined,
30
- headers,
31
- });
32
- },
33
- transformer,
34
- }),
35
- ],
36
- });
37
- }
5
+ export const createExpoTrpcClient = createTrpcClient;
38
6
 
39
7
  export async function expoSignUp(page: Page, email: string, password: string) {
40
8
  await page.goto("/signup");
@@ -11,6 +11,7 @@ import {
11
11
  clearEmails,
12
12
  createProvisionedClaimUser,
13
13
  emailUrl,
14
+ findMemberByEmail,
14
15
  getUserState,
15
16
  latestEmail,
16
17
  profiles,
@@ -37,15 +38,26 @@ async function expectAuthOk(response: Awaited<ReturnType<typeof authFetch>>) {
37
38
  }
38
39
 
39
40
  async function inviteOrganizationMember(page: Page, email: string, role: "member" | "admin") {
41
+ const trpc = await createExpoTrpcClient(page, profile);
42
+ return trpc.auth.inviteOrganizationMember.mutate({ email, role });
43
+ }
44
+
45
+ async function listOrganizationMembers(page: Page) {
46
+ const trpc = await createExpoTrpcClient(page, profile);
47
+ return trpc.auth.listOrganizationMembers.query();
48
+ }
49
+
50
+ async function expectBetterAuthInviteForbidden(page: Page, email: string) {
40
51
  const response = await authFetch(page, profile, "/api/auth/organization/invite-member", {
41
52
  method: "POST",
42
53
  body: {
43
54
  organizationId: enterpriseOrgId,
44
55
  email,
45
- role,
56
+ role: "member",
46
57
  },
47
58
  });
48
- await expectAuthOk(response);
59
+ expect(response.ok, `${response.status} ${response.statusText}: ${response.text}`).toBe(false);
60
+ expect(response.status).toBe(403);
49
61
  }
50
62
 
51
63
  test.beforeEach(async ({ request }) => {
@@ -151,12 +163,21 @@ test("organization invite email lets a new user join without creating a separate
151
163
  const invitee = `expo.member.${Date.now()}@auth-e2e.local`;
152
164
 
153
165
  await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
154
- await inviteOrganizationMember(page, invitee, "member");
166
+ const invited = await inviteOrganizationMember(page, invitee, "member");
167
+ expect(invited.member.userId).toBeNull();
168
+ expect(invited.invitation.memberId).toBe(invited.member.id);
169
+ expect(invited.invitation.status).toBe("pending");
170
+
171
+ const listed = findMemberByEmail(await listOrganizationMembers(page), invitee);
172
+ expect(listed?.id).toBe(invited.member.id);
173
+ expect(listed?.userId).toBeNull();
174
+ expect(listed?.invitationId).toBe(invited.invitation.id);
155
175
 
156
176
  const invite = await latestEmail(request, profile, {
157
177
  to: invitee,
158
178
  templateId: "organization-invite",
159
179
  });
180
+ expect(emailUrl(invite)).toContain(`/organization/accept-invitation?id=${invited.invitation.id}`);
160
181
 
161
182
  await expoLogoutNative(page);
162
183
  await page.goto(emailUrl(invite));
@@ -168,7 +189,9 @@ test("organization invite email lets a new user join without creating a separate
168
189
  const state = await getUserState(request, profile, invitee);
169
190
  expect(state.user.emailVerified).toBe(true);
170
191
  expect(state.organizations.map((organization) => organization.id)).toEqual([enterpriseOrgId]);
192
+ expect(state.organizations[0]?.memberId).toBe(invited.member.id);
171
193
  expect(state.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
194
+ expect(state.latestSession?.activeOrganizationMemberId).toBe(invited.member.id);
172
195
  });
173
196
 
174
197
  test("organization invite lets an existing user join an additional org", async ({
@@ -184,12 +207,14 @@ test("organization invite lets an existing user join an additional org", async (
184
207
  expect(beforeInvite.organizations[0]?.id).not.toBe(enterpriseOrgId);
185
208
 
186
209
  await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
187
- await inviteOrganizationMember(page, email, "member");
210
+ const invited = await inviteOrganizationMember(page, email, "member");
211
+ expect(invited.member.userId).toBeNull();
188
212
 
189
213
  const invite = await latestEmail(request, profile, {
190
214
  to: email,
191
215
  templateId: "organization-invite",
192
216
  });
217
+ expect(emailUrl(invite)).toContain("/organization/accept-invitation?id=");
193
218
 
194
219
  await expoLogoutNative(page);
195
220
  await expoLoginNative(page, email, password);
@@ -206,7 +231,156 @@ test("organization invite lets an existing user join an additional org", async (
206
231
  expect(afterInvite.organizations.map((organization) => organization.id)).toContain(
207
232
  enterpriseOrgId
208
233
  );
234
+ expect(
235
+ afterInvite.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId
236
+ ).toBe(invited.member.id);
209
237
  expect(afterInvite.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
238
+ expect(afterInvite.latestSession?.activeOrganizationMemberId).toBe(invited.member.id);
239
+ });
240
+
241
+ test("invited membership role change applies before accept", async ({ page, request }) => {
242
+ const invitee = `expo.invite-role.${Date.now()}@auth-e2e.local`;
243
+
244
+ await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
245
+ const invited = await inviteOrganizationMember(page, invitee, "member");
246
+ expect(invited.member.role).toBe("member");
247
+ expect(invited.member.userId).toBeNull();
248
+
249
+ const trpc = await createExpoTrpcClient(page, profile);
250
+ const updated = await trpc.auth.updateMemberRole.mutate({
251
+ memberId: invited.member.id,
252
+ role: "admin",
253
+ });
254
+ expect(updated).toEqual({ id: invited.member.id, role: "admin" });
255
+
256
+ const afterRole = findMemberByEmail(await listOrganizationMembers(page), invitee);
257
+ expect(afterRole?.id).toBe(invited.member.id);
258
+ expect(afterRole?.role).toBe("admin");
259
+ expect(afterRole?.userId).toBeNull();
260
+ expect(afterRole?.invitationId).toBe(invited.invitation.id);
261
+
262
+ const invite = await latestEmail(request, profile, {
263
+ to: invitee,
264
+ templateId: "organization-invite",
265
+ });
266
+ await expoLogoutNative(page);
267
+ await page.goto(emailUrl(invite));
268
+ await expect(page).toHaveURL(/\/signup/);
269
+ await page.getByTestId("signup-password").fill("password1234");
270
+ await page.getByTestId("signup-submit").click();
271
+ await expect(page.getByTestId("session-email")).toContainText(invitee);
272
+
273
+ const state = await getUserState(request, profile, invitee);
274
+ expect(state.organizations).toEqual([
275
+ expect.objectContaining({
276
+ id: enterpriseOrgId,
277
+ memberId: invited.member.id,
278
+ role: "admin",
279
+ }),
280
+ ]);
281
+ });
282
+
283
+ test("invited membership can be resent, role-updated, canceled, and revived", async ({ page }) => {
284
+ const invitee = `expo.invite-manage.${Date.now()}@auth-e2e.local`;
285
+
286
+ await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
287
+ const invited = await inviteOrganizationMember(page, invitee, "member");
288
+ const resent = await inviteOrganizationMember(page, invitee, "member");
289
+ expect(resent.member.id).toBe(invited.member.id);
290
+ expect(resent.invitation.id).toBe(invited.invitation.id);
291
+
292
+ const trpc = await createExpoTrpcClient(page, profile);
293
+ await trpc.auth.updateMemberRole.mutate({ memberId: invited.member.id, role: "admin" });
294
+ const afterRole = findMemberByEmail(await listOrganizationMembers(page), invitee);
295
+ expect(afterRole?.role).toBe("admin");
296
+ expect(afterRole?.userId).toBeNull();
297
+
298
+ await trpc.auth.cancelOrganizationInvitation.mutate({
299
+ invitationId: invited.invitation.id,
300
+ });
301
+ expect(findMemberByEmail(await listOrganizationMembers(page), invitee)).toBeUndefined();
302
+
303
+ const revived = await inviteOrganizationMember(page, invitee, "member");
304
+ expect(revived.member.id).toBe(invited.member.id);
305
+ expect(revived.member.userId).toBeNull();
306
+ });
307
+
308
+ test("live member invite is rejected; remove and leave drop the seat", async ({
309
+ page,
310
+ request,
311
+ }) => {
312
+ const email = `expo.live-member.${Date.now()}@auth-e2e.local`;
313
+ const password = "password1234";
314
+
315
+ await createVerifiedExpoAccount(page, request, email, password);
316
+ await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
317
+ const invited = await inviteOrganizationMember(page, email, "member");
318
+
319
+ const invite = await latestEmail(request, profile, {
320
+ to: email,
321
+ templateId: "organization-invite",
322
+ });
323
+ await expoLogoutNative(page);
324
+ await expoLoginNative(page, email, password);
325
+ await page.goto(emailUrl(invite));
326
+ await expect
327
+ .poll(async () => {
328
+ const state = await getUserState(request, profile, email);
329
+ return (
330
+ state.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId ??
331
+ null
332
+ );
333
+ })
334
+ .toBe(invited.member.id);
335
+
336
+ await expoLogoutNative(page);
337
+ await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
338
+ const adminTrpc = await createExpoTrpcClient(page, profile);
339
+ await expect(
340
+ adminTrpc.auth.inviteOrganizationMember.mutate({ email, role: "member" })
341
+ ).rejects.toThrow(/live membership already exists/i);
342
+
343
+ await adminTrpc.auth.removeOrganizationMember.mutate({ memberId: invited.member.id });
344
+ await expect
345
+ .poll(async () => {
346
+ const state = await getUserState(request, profile, email);
347
+ return state.organizations.some((organization) => organization.id === enterpriseOrgId);
348
+ })
349
+ .toBe(false);
350
+
351
+ const revived = await inviteOrganizationMember(page, email, "member");
352
+ expect(revived.member.id).toBe(invited.member.id);
353
+
354
+ const reinvite = await latestEmail(request, profile, {
355
+ to: email,
356
+ templateId: "organization-invite",
357
+ });
358
+ await expoLogoutNative(page);
359
+ await expoLoginNative(page, email, password);
360
+ await page.goto(emailUrl(reinvite));
361
+ await expect
362
+ .poll(async () => {
363
+ const state = await getUserState(request, profile, email);
364
+ return (
365
+ state.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId ??
366
+ null
367
+ );
368
+ })
369
+ .toBe(invited.member.id);
370
+
371
+ const memberTrpc = await createExpoTrpcClient(page, profile);
372
+ await memberTrpc.auth.leaveOrganization.mutate();
373
+ await expect
374
+ .poll(async () => {
375
+ const state = await getUserState(request, profile, email);
376
+ return state.organizations.some((organization) => organization.id === enterpriseOrgId);
377
+ })
378
+ .toBe(false);
379
+ });
380
+
381
+ test("Better Auth organization invite HTTP is not a public invite path", async ({ page }) => {
382
+ await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
383
+ await expectBetterAuthInviteForbidden(page, `expo.ba-invite.${Date.now()}@auth-e2e.local`);
210
384
  });
211
385
 
212
386
  test("admin creates, bans, unbans, and authenticates a user", async ({ page, request }) => {
@@ -316,7 +490,7 @@ test("admin manages organization members from organization admin", async ({ page
316
490
  const membersAfterAdd = await trpc.auth.listAdminOrganizationMembers.query({
317
491
  organizationId: enterpriseOrgId,
318
492
  });
319
- const member = membersAfterAdd.members.find((row) => row.user.email === email);
493
+ const member = membersAfterAdd.members.find((row) => row.user?.email === email);
320
494
  expect(member).toBeTruthy();
321
495
  await expect
322
496
  .poll(async () => {
@@ -355,6 +529,52 @@ test("admin manages organization members from organization admin", async ({ page
355
529
  .toBe(false);
356
530
  });
357
531
 
532
+ test("admin transfers organization ownership to an active member", async ({ page, request }) => {
533
+ const email = `expo.org-owner.${Date.now()}@auth-e2e.local`;
534
+ const password = "password1234";
535
+ const childOrgName = `Expo Transfer Org ${Date.now()}`;
536
+
537
+ await createVerifiedExpoAccount(page, request, email, password);
538
+ await expoLoginNative(page, profiles[profile].adminEmail, profiles[profile].adminPassword);
539
+ const trpc = await createExpoTrpcClient(page, profile);
540
+ const childOrg = await trpc.auth.createOrganization.mutate({ name: childOrgName });
541
+
542
+ const users = await trpc.auth.searchAdminUsers.query({ q: email, limit: 10 });
543
+ const user = users.rows.find((row) => row.email === email);
544
+ expect(user).toBeTruthy();
545
+
546
+ await trpc.auth.addAdminOrganizationMember.mutate({
547
+ organizationId: childOrg.id,
548
+ userId: user?.id ?? "",
549
+ role: "member",
550
+ });
551
+ const membersAfterAdd = await trpc.auth.listAdminOrganizationMembers.query({
552
+ organizationId: childOrg.id,
553
+ });
554
+ const member = membersAfterAdd.members.find((row) => row.user?.email === email);
555
+ expect(member).toBeTruthy();
556
+
557
+ const transferred = await trpc.auth.transferOrganizationOwner.mutate({
558
+ organizationId: childOrg.id,
559
+ memberId: member?.id ?? "",
560
+ });
561
+ expect(transferred.owner).toEqual({ id: member?.id, role: "owner" });
562
+ expect(transferred.previousOwner.role).toBe("admin");
563
+
564
+ await expect
565
+ .poll(async () => {
566
+ const state = await getUserState(request, profile, email);
567
+ return state.organizations.find((organization) => organization.id === childOrg.id)?.role;
568
+ })
569
+ .toBe("owner");
570
+ await expect
571
+ .poll(async () => {
572
+ const state = await getUserState(request, profile, profiles[profile].adminEmail);
573
+ return state.organizations.find((organization) => organization.id === childOrg.id)?.role;
574
+ })
575
+ .toBe("admin");
576
+ });
577
+
358
578
  test("admin organization pagination stays on the selected page", async ({ page, request }) => {
359
579
  const prefix = `Expo Pagination Org ${Date.now()}`;
360
580
  await seedOrganizations(request, profile, { prefix, count: 12 });
@@ -1,5 +1,8 @@
1
+ import type { BackendTRPCRouter } from "@m5kdev/backend/types";
2
+ import { transformer } from "@m5kdev/commons/utils/trpc";
1
3
  import type { APIRequestContext, Page, TestInfo } from "@playwright/test";
2
4
  import { expect } from "@playwright/test";
5
+ import { createTRPCClient, httpBatchLink } from "@trpc/client";
3
6
 
4
7
  export type Profile = "standard" | "waitlist" | "expoStandard" | "expoWaitlist";
5
8
 
@@ -29,6 +32,7 @@ export type UserHarnessState = {
29
32
  type: string | null;
30
33
  parentId: string | null;
31
34
  role: string;
35
+ memberId: string;
32
36
  }>;
33
37
  credentialAccount: boolean;
34
38
  accountClaims: Array<{
@@ -48,7 +52,6 @@ export type UserHarnessState = {
48
52
  activeOrganizationMemberId: string | null;
49
53
  activeOrganizationRole: string | null;
50
54
  activeOrganizationType: string | null;
51
- activeTeamId: string | null;
52
55
  impersonatedBy: string | null;
53
56
  createdAt: string;
54
57
  } | null;
@@ -287,3 +290,46 @@ export function resetTokenFrom(url: string) {
287
290
  if (!token) throw new Error(`Could not read reset token from ${url}`);
288
291
  return token;
289
292
  }
293
+
294
+ export function memberEmail(member: {
295
+ email?: string | null;
296
+ user?: { email?: string | null } | null;
297
+ }): string {
298
+ return (member.email ?? member.user?.email ?? "").trim().toLowerCase();
299
+ }
300
+
301
+ export function findMemberByEmail<
302
+ T extends {
303
+ email?: string | null;
304
+ user?: { email?: string | null } | null;
305
+ },
306
+ >(members: readonly T[], email: string): T | undefined {
307
+ const needle = email.trim().toLowerCase();
308
+ return members.find((member) => memberEmail(member) === needle);
309
+ }
310
+
311
+ export async function createTrpcClient(page: Page, profile: Profile) {
312
+ const cookies = await page.context().cookies(profiles[profile].serverUrl);
313
+ const cookie = cookies.map((entry) => `${entry.name}=${entry.value}`).join("; ");
314
+ type FetchInit = NonNullable<Parameters<typeof fetch>[1]>;
315
+
316
+ return createTRPCClient<BackendTRPCRouter>({
317
+ links: [
318
+ httpBatchLink({
319
+ url: `${profiles[profile].serverUrl}/trpc`,
320
+ fetch(url, options) {
321
+ const headers = new Headers(options?.headers);
322
+ if (cookie) {
323
+ headers.set("cookie", cookie);
324
+ }
325
+ return fetch(url, {
326
+ ...options,
327
+ body: options?.body ? (options.body as FetchInit["body"]) : undefined,
328
+ headers,
329
+ });
330
+ },
331
+ transformer,
332
+ }),
333
+ ],
334
+ });
335
+ }