create-m5kdev 0.35.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 (47) hide show
  1. package/package.json +1 -1
  2. package/templates/minimal-app/apps/e2e/servers.ts.tpl +3 -0
  3. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +2 -34
  4. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +225 -5
  5. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +47 -1
  6. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +290 -8
  7. package/templates/minimal-app/apps/expo/app.json.tpl +1 -0
  8. package/templates/minimal-app/apps/expo/package.json.tpl +4 -0
  9. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +4 -1
  10. package/templates/minimal-app/apps/expo/src/app/notifications.tsx.tpl +70 -0
  11. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +8 -16
  12. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +4 -1
  13. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +11 -2
  14. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +2 -3
  15. package/templates/minimal-app/apps/server/db.e2e.ts.tpl +5 -40
  16. package/templates/minimal-app/apps/server/db.ts.tpl +5 -42
  17. package/templates/minimal-app/apps/server/drizzle/{0000_wealthy_absorbing_man.sql → 0000_whole_echo.sql} +102 -3
  18. package/templates/minimal-app/apps/server/drizzle/0001_yummy_spectrum.sql +26 -0
  19. package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +47 -0
  20. package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +4 -0
  21. package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +99 -0
  22. package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +2 -0
  23. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2625 -1780
  24. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2671 -1796
  25. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2716 -1818
  26. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +715 -164
  27. package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2498 -0
  28. package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2484 -0
  29. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +23 -9
  30. package/templates/minimal-app/apps/server/src/app.ts.tpl +4 -1
  31. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +2 -2
  32. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +1 -1
  33. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +13 -36
  34. package/templates/minimal-app/apps/server/src/schema.ts.tpl +3 -2
  35. package/templates/minimal-app/apps/shared/package.json.tpl +1 -0
  36. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +1 -1
  37. package/templates/minimal-app/apps/shared/src/modules/notification/notification.constants.ts.tpl +5 -0
  38. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +1 -1
  39. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +17 -1
  40. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +10 -8
  41. package/templates/minimal-app/apps/webapp/src/modules/notification/NotificationPreferencesRoute.tsx.tpl +29 -0
  42. package/templates/minimal-app/apps/webapp/src/modules/notification/StarterWebPush.tsx.tpl +44 -0
  43. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +8 -0
  44. package/templates/minimal-app/pnpm-workspace.yaml.tpl +12 -11
  45. package/templates/minimal-app/apps/server/drizzle/0001_third_ink.sql +0 -1
  46. package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +0 -3
  47. package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +0 -27
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-m5kdev",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "license": "GPL-3.0-only",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -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
+ }