create-m5kdev 0.35.0 → 0.37.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.
Files changed (61) hide show
  1. package/package.json +3 -3
  2. package/templates/minimal-app/.dockerignore.tpl +6 -0
  3. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +3 -0
  4. package/templates/minimal-app/apps/e2e/servers.ts.tpl +4 -0
  5. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +2 -34
  6. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +225 -5
  7. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +47 -1
  8. package/templates/minimal-app/apps/e2e/tests/mcp-helpers.ts.tpl +406 -0
  9. package/templates/minimal-app/apps/e2e/tests/mcp.spec.ts.tpl +108 -0
  10. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +290 -8
  11. package/templates/minimal-app/apps/expo/app.json.tpl +1 -0
  12. package/templates/minimal-app/apps/expo/package.json.tpl +4 -0
  13. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +4 -1
  14. package/templates/minimal-app/apps/expo/src/app/notifications.tsx.tpl +70 -0
  15. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +8 -16
  16. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +4 -1
  17. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +11 -2
  18. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +2 -3
  19. package/templates/minimal-app/apps/server/AGENTS.md.tpl +10 -0
  20. package/templates/minimal-app/apps/server/db.e2e.ts.tpl +6 -40
  21. package/templates/minimal-app/apps/server/db.ts.tpl +6 -42
  22. package/templates/minimal-app/apps/server/drizzle/{0000_wealthy_absorbing_man.sql → 0000_whole_echo.sql} +102 -3
  23. package/templates/minimal-app/apps/server/drizzle/0001_yummy_spectrum.sql +26 -0
  24. package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +47 -0
  25. package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +4 -0
  26. package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +99 -0
  27. package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +2 -0
  28. package/templates/minimal-app/apps/server/drizzle/0006_lazy_blue_marvel.sql +159 -0
  29. package/templates/minimal-app/apps/server/drizzle/0007_abnormal_ultimo.sql +2 -0
  30. package/templates/minimal-app/apps/server/drizzle/0008_steady_nekra.sql +87 -0
  31. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2625 -1780
  32. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2671 -1796
  33. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2716 -1818
  34. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +715 -164
  35. package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2498 -0
  36. package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2484 -0
  37. package/templates/minimal-app/apps/server/drizzle/meta/0006_snapshot.json.tpl +3603 -0
  38. package/templates/minimal-app/apps/server/drizzle/meta/0007_snapshot.json.tpl +3620 -0
  39. package/templates/minimal-app/apps/server/drizzle/meta/0008_snapshot.json.tpl +3620 -0
  40. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +44 -9
  41. package/templates/minimal-app/apps/server/src/app.ts.tpl +12 -2
  42. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +2 -2
  43. package/templates/minimal-app/apps/server/src/modules/posts/posts.mcp.ts.tpl +17 -0
  44. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +11 -0
  45. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +14 -5
  46. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +13 -36
  47. package/templates/minimal-app/apps/server/src/schema.ts.tpl +16 -2
  48. package/templates/minimal-app/apps/shared/package.json.tpl +1 -0
  49. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +1 -1
  50. package/templates/minimal-app/apps/shared/src/modules/notification/notification.constants.ts.tpl +5 -0
  51. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +1 -1
  52. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +17 -1
  53. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +10 -8
  54. package/templates/minimal-app/apps/webapp/src/modules/notification/NotificationPreferencesRoute.tsx.tpl +29 -0
  55. package/templates/minimal-app/apps/webapp/src/modules/notification/StarterWebPush.tsx.tpl +44 -0
  56. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +8 -0
  57. package/templates/minimal-app/pnpm-workspace.yaml.tpl +14 -13
  58. package/templates/minimal-app/template.manifest.json +9 -0
  59. package/templates/minimal-app/apps/server/drizzle/0001_third_ink.sql +0 -1
  60. package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +0 -3
  61. package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +0 -27
@@ -3,8 +3,10 @@ import {
3
3
  authFetch,
4
4
  clearEmails,
5
5
  createProvisionedClaimUser,
6
+ createTrpcClient,
6
7
  emailUrl,
7
8
  expectLoginRejected,
9
+ findMemberByEmail,
8
10
  getUserState,
9
11
  latestEmail,
10
12
  login,
@@ -29,6 +31,43 @@ async function createVerifiedAccount(
29
31
  await verifyLatestEmail(request, profile, email);
30
32
  }
31
33
 
34
+ async function inviteFromMembersPage(page: Page, email: string) {
35
+ await page.goto("/organization/members");
36
+ await expect(page.getByRole("button", { name: /^invite$/i })).toBeEnabled();
37
+ await page.locator('input[type="email"]').fill(email);
38
+ const inviteResponsePromise = page.waitForResponse(
39
+ (response) =>
40
+ response.request().method() === "POST" && response.url().includes("inviteOrganizationMember")
41
+ );
42
+ await page.getByRole("button", { name: /^invite$/i }).click();
43
+ const inviteResponse = await inviteResponsePromise;
44
+ expect(
45
+ inviteResponse.ok(),
46
+ `${inviteResponse.status()} ${inviteResponse.statusText()}: ${await inviteResponse.text()}`
47
+ ).toBe(true);
48
+ const invitedRow = page.getByRole("row").filter({ hasText: email });
49
+ await expect(invitedRow.getByText(/^invited$/i)).toBeVisible();
50
+ return invitedRow;
51
+ }
52
+
53
+ async function listOrganizationMembers(page: Page) {
54
+ const trpc = await createTrpcClient(page, profile);
55
+ return trpc.auth.listOrganizationMembers.query();
56
+ }
57
+
58
+ async function expectBetterAuthInviteForbidden(page: Page, email: string) {
59
+ const response = await authFetch(page, profile, "/api/auth/organization/invite-member", {
60
+ method: "POST",
61
+ body: {
62
+ organizationId: enterpriseOrgId,
63
+ email,
64
+ role: "member",
65
+ },
66
+ });
67
+ expect(response.ok, `${response.status} ${response.statusText}: ${response.text}`).toBe(false);
68
+ expect(response.status).toBe(403);
69
+ }
70
+
32
71
  test.beforeEach(async ({ request }) => {
33
72
  await clearEmails(request, profile);
34
73
  });
@@ -52,7 +91,7 @@ test("signup, verification email, login, protected app, and logout", async ({ pa
52
91
 
53
92
  await login(page, email, password);
54
93
  await expect(page.getByTestId("posts-route")).toBeVisible();
55
- await expect(page.getByText(/small blog that protects auth changes/i).first()).toBeVisible();
94
+ await expect(page.getByRole("button", { name: /create post/i })).toBeVisible();
56
95
 
57
96
  await logout(page);
58
97
  });
@@ -144,14 +183,19 @@ test("organization invite email lets a new user join without creating a separate
144
183
  const invitee = `member.${Date.now()}@auth-e2e.local`;
145
184
 
146
185
  await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
147
- await page.goto("/organization/members");
148
- await page.locator('input[type="email"]').fill(invitee);
149
- await page.getByRole("button", { name: /invite/i }).click();
186
+ await inviteFromMembersPage(page, invitee);
187
+
188
+ const invited = findMemberByEmail(await listOrganizationMembers(page), invitee);
189
+ expect(invited?.userId).toBeNull();
190
+ expect(invited?.invitationId).toBeTruthy();
191
+ const invitedMemberId = invited?.id;
192
+ expect(invitedMemberId).toBeTruthy();
150
193
 
151
194
  const invite = await latestEmail(request, profile, {
152
195
  to: invitee,
153
196
  templateId: "organization-invite",
154
197
  });
198
+ expect(emailUrl(invite)).toContain("/organization/accept-invitation?id=");
155
199
 
156
200
  await logout(page);
157
201
  await page.goto(emailUrl(invite));
@@ -163,7 +207,9 @@ test("organization invite email lets a new user join without creating a separate
163
207
  const state = await getUserState(request, profile, invitee);
164
208
  expect(state.user.emailVerified).toBe(true);
165
209
  expect(state.organizations.map((organization) => organization.id)).toEqual([enterpriseOrgId]);
210
+ expect(state.organizations[0]?.memberId).toBe(invitedMemberId);
166
211
  expect(state.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
212
+ expect(state.latestSession?.activeOrganizationMemberId).toBe(invitedMemberId);
167
213
  });
168
214
 
169
215
  test("organization invite lets an existing user join an additional org", async ({
@@ -179,14 +225,16 @@ test("organization invite lets an existing user join an additional org", async (
179
225
  expect(beforeInvite.organizations[0]?.id).not.toBe(enterpriseOrgId);
180
226
 
181
227
  await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
182
- await page.goto("/organization/members");
183
- await page.locator('input[type="email"]').fill(email);
184
- await page.getByRole("button", { name: /invite/i }).click();
228
+ await inviteFromMembersPage(page, email);
229
+ const invited = findMemberByEmail(await listOrganizationMembers(page), email);
230
+ expect(invited?.userId).toBeNull();
231
+ const invitedMemberId = invited?.id;
185
232
 
186
233
  const invite = await latestEmail(request, profile, {
187
234
  to: email,
188
235
  templateId: "organization-invite",
189
236
  });
237
+ expect(emailUrl(invite)).toContain("/organization/accept-invitation?id=");
190
238
 
191
239
  await logout(page);
192
240
  await login(page, email, password);
@@ -203,7 +251,184 @@ test("organization invite lets an existing user join an additional org", async (
203
251
  expect(afterInvite.organizations.map((organization) => organization.id)).toContain(
204
252
  enterpriseOrgId
205
253
  );
254
+ expect(
255
+ afterInvite.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId
256
+ ).toBe(invitedMemberId);
206
257
  expect(afterInvite.latestSession?.activeOrganizationId).toBe(enterpriseOrgId);
258
+ expect(afterInvite.latestSession?.activeOrganizationMemberId).toBe(invitedMemberId);
259
+ });
260
+
261
+ test("organization members page changes an invited member role before they accept", async ({
262
+ page,
263
+ request,
264
+ }) => {
265
+ const invitee = `invite-role.${Date.now()}@auth-e2e.local`;
266
+
267
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
268
+ await inviteFromMembersPage(page, invitee);
269
+ const invited = findMemberByEmail(await listOrganizationMembers(page), invitee);
270
+ expect(invited?.role).toBe("member");
271
+ expect(invited?.userId).toBeNull();
272
+ const invitedMemberId = invited?.id;
273
+ expect(invitedMemberId).toBeTruthy();
274
+
275
+ const invitedRow = page.getByRole("row").filter({ hasText: invitee });
276
+ await invitedRow.getByRole("button", { name: `Role for ${invitee}` }).click();
277
+ const roleResponsePromise = page.waitForResponse(
278
+ (response) =>
279
+ response.request().method() === "POST" && response.url().includes("updateMemberRole")
280
+ );
281
+ await page.getByRole("option", { name: /^Admin$/ }).click();
282
+ const roleResponse = await roleResponsePromise;
283
+ expect(
284
+ roleResponse.ok(),
285
+ `${roleResponse.status()} ${roleResponse.statusText()}: ${await roleResponse.text()}`
286
+ ).toBe(true);
287
+
288
+ const afterRole = findMemberByEmail(await listOrganizationMembers(page), invitee);
289
+ expect(afterRole?.id).toBe(invitedMemberId);
290
+ expect(afterRole?.role).toBe("admin");
291
+ expect(afterRole?.userId).toBeNull();
292
+ expect(afterRole?.invitationId).toBeTruthy();
293
+
294
+ const invite = await latestEmail(request, profile, {
295
+ to: invitee,
296
+ templateId: "organization-invite",
297
+ });
298
+ await logout(page);
299
+ await page.goto(emailUrl(invite));
300
+ await expect(page).toHaveURL(/\/signup/);
301
+ await page.locator('[name="signup-password"]').fill("password1234");
302
+ await page.getByRole("button", { name: /sign up/i }).click();
303
+ await expect(page.getByTestId("session-email")).toContainText(invitee);
304
+
305
+ const state = await getUserState(request, profile, invitee);
306
+ expect(state.organizations).toEqual([
307
+ expect.objectContaining({
308
+ id: enterpriseOrgId,
309
+ memberId: invitedMemberId,
310
+ role: "admin",
311
+ }),
312
+ ]);
313
+ });
314
+
315
+ test("organization members page resends, updates, cancels, and revives an invited seat", async ({
316
+ page,
317
+ }) => {
318
+ const invitee = `invite-manage.${Date.now()}@auth-e2e.local`;
319
+
320
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
321
+ await inviteFromMembersPage(page, invitee);
322
+ const firstSeat = findMemberByEmail(await listOrganizationMembers(page), invitee);
323
+ const memberId = firstSeat?.id;
324
+ expect(memberId).toBeTruthy();
325
+
326
+ await inviteFromMembersPage(page, invitee);
327
+ const resent = findMemberByEmail(await listOrganizationMembers(page), invitee);
328
+ expect(resent?.id).toBe(memberId);
329
+ expect(resent?.userId).toBeNull();
330
+
331
+ const invitedRow = page.getByRole("row").filter({ hasText: invitee });
332
+ await invitedRow.getByRole("button", { name: `Role for ${invitee}` }).click();
333
+ await page.getByRole("option", { name: /^Admin$/ }).click();
334
+ await expect(page.getByText(/member role updated/i)).toBeVisible();
335
+ await expect
336
+ .poll(async () => findMemberByEmail(await listOrganizationMembers(page), invitee)?.role)
337
+ .toBe("admin");
338
+
339
+ await invitedRow.getByRole("button", { name: /cancel invitation/i }).click();
340
+ await expect(page.getByText(/invitation canceled/i)).toBeVisible();
341
+ await expect(invitedRow).toBeHidden();
342
+ expect(findMemberByEmail(await listOrganizationMembers(page), invitee)).toBeUndefined();
343
+
344
+ await inviteFromMembersPage(page, invitee);
345
+ const revived = findMemberByEmail(await listOrganizationMembers(page), invitee);
346
+ expect(revived?.id).toBe(memberId);
347
+ expect(revived?.userId).toBeNull();
348
+ });
349
+
350
+ test("organization members page refuses a live member invite, then remove and leave drop the seat", async ({
351
+ page,
352
+ request,
353
+ }) => {
354
+ const email = `live-member.${Date.now()}@auth-e2e.local`;
355
+ const password = "password1234";
356
+
357
+ await createVerifiedAccount(page, request, email, password);
358
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
359
+ await inviteFromMembersPage(page, email);
360
+ const invitedMemberId = findMemberByEmail(await listOrganizationMembers(page), email)?.id;
361
+
362
+ const invite = await latestEmail(request, profile, {
363
+ to: email,
364
+ templateId: "organization-invite",
365
+ });
366
+ await logout(page);
367
+ await login(page, email, password);
368
+ await page.goto(emailUrl(invite));
369
+ await expect
370
+ .poll(async () => {
371
+ const state = await getUserState(request, profile, email);
372
+ return (
373
+ state.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId ??
374
+ null
375
+ );
376
+ })
377
+ .toBe(invitedMemberId);
378
+
379
+ await logout(page);
380
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
381
+ await page.goto("/organization/members");
382
+ await page.locator('input[type="email"]').fill(email);
383
+ await page.getByRole("button", { name: /^invite$/i }).click();
384
+ await expect(page.getByText(/live membership already exists/i)).toBeVisible();
385
+
386
+ const memberRow = page.getByRole("row").filter({ hasText: email });
387
+ await expect(memberRow.getByText(/^active$/i)).toBeVisible();
388
+ await memberRow.getByRole("button", { name: /remove member/i }).click();
389
+ await expect(page.getByText(/member removed/i)).toBeVisible();
390
+ await expect(memberRow).toBeHidden();
391
+ await expect
392
+ .poll(async () => {
393
+ const state = await getUserState(request, profile, email);
394
+ return state.organizations.some((organization) => organization.id === enterpriseOrgId);
395
+ })
396
+ .toBe(false);
397
+
398
+ await inviteFromMembersPage(page, email);
399
+ const revived = findMemberByEmail(await listOrganizationMembers(page), email);
400
+ expect(revived?.id).toBe(invitedMemberId);
401
+
402
+ const reinvite = await latestEmail(request, profile, {
403
+ to: email,
404
+ templateId: "organization-invite",
405
+ });
406
+ await logout(page);
407
+ await login(page, email, password);
408
+ await page.goto(emailUrl(reinvite));
409
+ await expect
410
+ .poll(async () => {
411
+ const state = await getUserState(request, profile, email);
412
+ return (
413
+ state.organizations.find((organization) => organization.id === enterpriseOrgId)?.memberId ??
414
+ null
415
+ );
416
+ })
417
+ .toBe(invitedMemberId);
418
+
419
+ const memberTrpc = await createTrpcClient(page, profile);
420
+ await memberTrpc.auth.leaveOrganization.mutate();
421
+ await expect
422
+ .poll(async () => {
423
+ const state = await getUserState(request, profile, email);
424
+ return state.organizations.some((organization) => organization.id === enterpriseOrgId);
425
+ })
426
+ .toBe(false);
427
+ });
428
+
429
+ test("Better Auth organization invite HTTP is not a public invite path", async ({ page }) => {
430
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
431
+ await expectBetterAuthInviteForbidden(page, `ba-invite.${Date.now()}@auth-e2e.local`);
207
432
  });
208
433
 
209
434
  test("admin creates, bans, unbans, and authenticates a user", async ({ page, request }) => {
@@ -318,7 +543,7 @@ test("admin manages organization members from organization admin", async ({ page
318
543
  })
319
544
  .toBe("member");
320
545
 
321
- await memberRow.getByRole("button", { name: `Member Role for ${email}` }).click();
546
+ await memberRow.getByRole("button", { name: `Role for ${email}` }).click();
322
547
  await page.getByRole("option", { name: /^Admin$/ }).click();
323
548
  await expect
324
549
  .poll(async () => {
@@ -340,6 +565,63 @@ test("admin manages organization members from organization admin", async ({ page
340
565
  .toBe(false);
341
566
  });
342
567
 
568
+ test("admin transfers organization ownership to an active member", async ({ page, request }) => {
569
+ const email = `org-owner.${Date.now()}@auth-e2e.local`;
570
+ const password = "password1234";
571
+ const childOrgName = `Transfer Org ${Date.now()}`;
572
+
573
+ await createVerifiedAccount(page, request, email, password);
574
+ await login(page, profiles.standard.adminEmail, profiles.standard.adminPassword);
575
+ await page.goto("/organization/manage");
576
+ await page.getByRole("button", { name: /new organization/i }).click();
577
+ const createDialog = page.getByRole("dialog");
578
+ await createDialog.getByPlaceholder(/acme subsidiary/i).fill(childOrgName);
579
+ await createDialog.getByRole("button", { name: /create organization/i }).click();
580
+ await expect(page.getByText(childOrgName)).toBeVisible();
581
+
582
+ await page.goto("/admin/organizations");
583
+ await page.locator('input[name="search"]').fill(childOrgName);
584
+ const organizationRow = page.getByRole("row").filter({ hasText: childOrgName });
585
+ await expect(organizationRow).toBeVisible();
586
+ await organizationRow.getByLabel(/manage members/i).click();
587
+
588
+ const dialog = page.getByRole("dialog").filter({ hasText: "Manage Members" });
589
+ await expect(dialog.getByText(childOrgName)).toBeVisible();
590
+ await dialog.getByText("Select user", { exact: true }).click();
591
+ await page.getByPlaceholder("Search users...").fill(email);
592
+ await page.getByRole("option", { name: new RegExp(email) }).click();
593
+ await dialog.getByRole("button", { name: /add member/i }).click();
594
+
595
+ const memberRow = dialog.getByRole("row").filter({ hasText: email });
596
+ await expect(memberRow).toBeVisible();
597
+ await expect(memberRow.getByRole("button", { name: `Transfer owner to ${email}` })).toBeVisible();
598
+
599
+ const transferResponsePromise = page.waitForResponse(
600
+ (response) =>
601
+ response.request().method() === "POST" && response.url().includes("transferOrganizationOwner")
602
+ );
603
+ await memberRow.getByRole("button", { name: `Transfer owner to ${email}` }).click();
604
+ const transferResponse = await transferResponsePromise;
605
+ expect(
606
+ transferResponse.ok(),
607
+ `${transferResponse.status()} ${transferResponse.statusText()}: ${await transferResponse.text()}`
608
+ ).toBe(true);
609
+
610
+ await expect
611
+ .poll(async () => {
612
+ const state = await getUserState(request, profile, email);
613
+ return state.organizations.find((organization) => organization.name === childOrgName)?.role;
614
+ })
615
+ .toBe("owner");
616
+ await expect
617
+ .poll(async () => {
618
+ const state = await getUserState(request, profile, profiles.standard.adminEmail);
619
+ return state.organizations.find((organization) => organization.name === childOrgName)?.role;
620
+ })
621
+ .toBe("admin");
622
+ await expect(memberRow.getByRole("button", { name: `Transfer owner to ${email}` })).toBeHidden();
623
+ });
624
+
343
625
  test("admin organization pagination stays on the selected page", async ({ page, request }) => {
344
626
  const prefix = `Pagination Org ${Date.now()}`;
345
627
  await seedOrganizations(request, profile, { prefix, count: 12 });
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "plugins": [
21
21
  "expo-router",
22
+ "expo-notifications",
22
23
  [
23
24
  "expo-splash-screen",
24
25
  {
@@ -21,11 +21,14 @@
21
21
  "@expo/metro-runtime": "~56.0.13",
22
22
  "@gorhom/bottom-sheet": "^5.2.14",
23
23
  "@m5kdev/commons": "catalog:m5kdev",
24
+ "@m5kdev/expo": "catalog:m5kdev",
24
25
  "@m5kdev/frontend": "catalog:m5kdev",
25
26
  "@trpc/client": "catalog:m5kdev",
27
+ "@tanstack/react-query": "catalog:m5kdev",
26
28
  "expo": "~56.0.5",
27
29
  "expo-constants": "~56.0.16",
28
30
  "expo-linking": "~56.0.12",
31
+ "expo-notifications": "~56.0.18",
29
32
  "expo-router": "~56.2.7",
30
33
  "expo-splash-screen": "~56.0.10",
31
34
  "expo-status-bar": "~56.0.4",
@@ -47,6 +50,7 @@
47
50
  "uniwind": "^1.5.0"
48
51
  },
49
52
  "devDependencies": {
53
+ "{{PACKAGE_SCOPE}}/server": "workspace:*",
50
54
  "@biomejs/biome": "catalog:m5kdev",
51
55
  "@m5kdev/backend": "catalog:m5kdev",
52
56
  "@m5kdev/config": "catalog:m5kdev",
@@ -1,4 +1,5 @@
1
1
  import { AppConfigProvider } from "@m5kdev/frontend/modules/app/components/AppConfigProvider";
2
+ import { AppTrpcQueryProvider } from "@m5kdev/frontend/modules/app/components/AppTrpcQueryProvider";
2
3
  import { AuthProvider } from "@m5kdev/frontend/modules/auth/components/AuthProvider";
3
4
  import { APP_NAME } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
4
5
  import { Stack } from "expo-router";
@@ -22,7 +23,9 @@ export default function RootLayout(): JSX.Element {
22
23
  }}
23
24
  >
24
25
  <AuthProvider loader={<LoadingScreen />}>
25
- <Stack screenOptions={{ headerShown: false }} />
26
+ <AppTrpcQueryProvider>
27
+ <Stack screenOptions={{ headerShown: false }} />
28
+ </AppTrpcQueryProvider>
26
29
  </AuthProvider>
27
30
  </AppConfigProvider>
28
31
  </HeroUINativeProvider>
@@ -0,0 +1,70 @@
1
+ import { useNativePush } from "@m5kdev/expo/hooks/useNativePush";
2
+ import { NotificationInbox } from "@m5kdev/expo/modules/notification/components/NotificationInbox";
3
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
4
+ import { Link, Redirect } from "expo-router";
5
+ import { Button, Card, Typography } from "heroui-native";
6
+ import { ScrollView, Text, View } from "react-native";
7
+ import { useTRPC } from "../lib/trpc";
8
+
9
+ export default function NotificationsScreen() {
10
+ const { data: session } = useSession();
11
+ const trpc = useTRPC();
12
+ const nativePush = useNativePush({
13
+ enabled: Boolean(session?.user?.id),
14
+ messages: {
15
+ unsupported: "Native push is not available on this platform.",
16
+ denied: "Notification permission was denied.",
17
+ noToken: "Could not read a device push token.",
18
+ failed: "Could not register this device.",
19
+ enabled: "This device is registered for push.",
20
+ },
21
+ registerDeviceMutation: trpc.notification.registerDevice.mutationOptions(),
22
+ });
23
+
24
+ if (!session) {
25
+ return <Redirect href="/login" />;
26
+ }
27
+
28
+ const pushLabel =
29
+ nativePush.flowStatus === "done"
30
+ ? "This device is registered for push."
31
+ : nativePush.permission === "denied"
32
+ ? "Notification permission was denied."
33
+ : "Enable push for this device";
34
+
35
+ return (
36
+ <ScrollView className="flex-1 bg-background" contentContainerClassName="px-5 py-8">
37
+ <View className="gap-5">
38
+ <View className="gap-2">
39
+ <Text className="text-3xl font-semibold text-foreground">Notifications</Text>
40
+ <Typography.Paragraph className="text-default-600">
41
+ Inbox for this membership. Push registration is this User's device.
42
+ </Typography.Paragraph>
43
+ </View>
44
+ <Card className="gap-3 p-5">
45
+ <Text className="text-lg font-semibold text-foreground">This device</Text>
46
+ <Button
47
+ isDisabled={!nativePush.canSubscribe || nativePush.isWorking}
48
+ onPress={() => {
49
+ void nativePush.subscribe();
50
+ }}
51
+ >
52
+ {pushLabel}
53
+ </Button>
54
+ {nativePush.feedback ? (
55
+ <Typography.Paragraph className="text-default-600">
56
+ {nativePush.feedback}
57
+ </Typography.Paragraph>
58
+ ) : null}
59
+ </Card>
60
+ <Card className="gap-3 p-5">
61
+ <Text className="text-lg font-semibold text-foreground">Inbox</Text>
62
+ <NotificationInbox />
63
+ </Card>
64
+ <Link href="/posts" asChild>
65
+ <Button variant="outline">Back to posts</Button>
66
+ </Link>
67
+ </View>
68
+ </ScrollView>
69
+ );
70
+ }
@@ -46,22 +46,14 @@ export default function AcceptInvitationScreen() {
46
46
  return;
47
47
  }
48
48
 
49
- authClient.organization
50
- .acceptInvitation({ invitationId })
51
- .then(async ({ data, error }) => {
52
- if (error) {
53
- throw new Error(error.message ?? "Unable to accept invitation");
54
- }
55
-
56
- const organizationId =
57
- (data as { invitation?: { organizationId?: string } | null } | null)?.invitation
58
- ?.organizationId ?? null;
59
-
60
- if (organizationId) {
61
- const activeResult = await authClient.organization.setActive({ organizationId });
62
- if (activeResult.error) {
63
- throw new Error(activeResult.error.message ?? "Unable to activate organization");
64
- }
49
+ trpcClient.auth.acceptOrganizationInvitation
50
+ .mutate({ id: invitationId })
51
+ .then(async (accepted) => {
52
+ const activeResult = await authClient.organization.setActive({
53
+ organizationId: accepted.organizationId,
54
+ });
55
+ if (activeResult.error) {
56
+ throw new Error(activeResult.error.message ?? "Unable to activate organization");
65
57
  }
66
58
 
67
59
  if (!cancelled) {
@@ -1,5 +1,5 @@
1
1
  import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
2
- import { Link, Redirect } from "expo-router";
2
+ import { type Href, Link, Redirect } from "expo-router";
3
3
  import { Button, Card, Typography } from "heroui-native";
4
4
  import { ScrollView, Text, View } from "react-native";
5
5
 
@@ -29,6 +29,9 @@ export default function PostsScreen() {
29
29
  @m5kdev/frontend.
30
30
  </Typography.Paragraph>
31
31
  </Card>
32
+ <Link href={"/notifications" as Href} asChild>
33
+ <Button variant="outline">Notifications</Button>
34
+ </Link>
32
35
  <Link href="/logout" asChild>
33
36
  <Button testID="logout-submit" variant="outline">
34
37
  Logout
@@ -1,9 +1,10 @@
1
- import type { BackendTRPCRouter } from "@m5kdev/backend/types";
2
1
  import { transformer } from "@m5kdev/commons/utils/trpc";
2
+ import { useAppTRPC, useAppTRPCClient } from "@m5kdev/frontend/modules/app/hooks/useAppTrpc";
3
+ import type { AppRouter } from "{{PACKAGE_SCOPE}}/server/types";
3
4
  import { createTRPCClient, httpBatchLink } from "@trpc/client";
4
5
  import { serverUrl } from "../config";
5
6
 
6
- export const trpcClient = createTRPCClient<BackendTRPCRouter>({
7
+ export const trpcClient = createTRPCClient<AppRouter>({
7
8
  links: [
8
9
  httpBatchLink({
9
10
  url: `${serverUrl}/trpc`,
@@ -18,3 +19,11 @@ export const trpcClient = createTRPCClient<BackendTRPCRouter>({
18
19
  }),
19
20
  ],
20
21
  });
22
+
23
+ export function useTRPC() {
24
+ return useAppTRPC<AppRouter>();
25
+ }
26
+
27
+ export function useTRPCClient() {
28
+ return useAppTRPCClient<AppRouter>();
29
+ }
@@ -18,8 +18,7 @@ export default defineConfig({
18
18
  dedupe: ["react", "react-dom", "react-router"],
19
19
  },
20
20
  envDir: "../shared",
21
- preview: {
22
- host: "0.0.0.0",
23
- port: 8080,
21
+ server: {
22
+ port: 5174,
24
23
  },
25
24
  });
@@ -20,6 +20,9 @@ apps/server/src/modules/<module>/
20
20
  ├── <module>.repository.ts
21
21
  ├── <module>.service.ts
22
22
  ├── <module>.trpc.ts
23
+ // m5k:mcp:start
24
+ ├── <module>.mcp.ts
25
+ // m5k:mcp:end
23
26
  └── <module>.module.ts
24
27
  ```
25
28
 
@@ -28,6 +31,9 @@ apps/server/src/modules/<module>/
28
31
  - Repositories own persistence and query construction.
29
32
  - Services own business rules, orchestration, and context-aware defaults.
30
33
  - tRPC files own transport only and must delegate to services.
34
+ // m5k:mcp:start
35
+ - MCP call maps live in `<module>.mcp.ts` and are contributed from `mcp()` / `mcpUser()`. Delegate to Procedures or unguarded service methods; do not scrape Service properties.
36
+ // m5k:mcp:end
31
37
  - Register Core Modules and app modules in `apps/server/src/app.ts` via `createBackendApp(config, [modules])`. Do not use `backendApp.use`. Optional Backend Modules (`@m5kdev/module-*`) are not scaffolded; add them only when the product needs Clay, Docx, Pdf, Social, or Video.
32
38
  // m5k:ai:start
33
39
  - `AIModule` is registered in `app.ts` with an app-owned Mastra Agent (`assistant`). Conversation HTTP lives on `/ai`. Set `OPENROUTER_API_KEY` for live replies. Do not add a Starter service wrapper around `AIService` chat.
@@ -50,6 +56,10 @@ apps/server/src/modules/<module>/
50
56
  - `index.ts` calls `builtBackendApp.start()`, which listens and handles SIGINT/SIGTERM. Extra shutdown work (telemetry) is `onShutdown` on `createBackendApp`.
51
57
  - After changing Drizzle tables, run `pnpm --filter ./apps/server drizzle:generate` then `drizzle:migrate` — do not hand-edit SQL migrations in this repo.
52
58
 
59
+ // m5k:mcp:start
60
+ - `McpModule` is registered in `app.ts`. MCP clients authenticate with Better Auth MCP OAuth (not API keys). Compose `auth.oauth.db` plus `mcpAllowlistEntries`. Contribute Organization-scoped calls from `posts.mcp.ts`. Omit the module to disable MCP.
61
+ // m5k:mcp:end
62
+
53
63
  // m5k:notifications:start
54
64
  - `NotificationModule` is registered in `app.ts` when this feature is selected. Push-related server env vars are documented in `apps/shared/.env.example`.
55
65
  // m5k:notifications:end