create-m5kdev 0.33.0 → 0.35.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 (147) hide show
  1. package/dist/src/catalog.cjs +21 -1
  2. package/dist/src/catalog.cjs.map +1 -1
  3. package/dist/src/catalog.d.cts +3 -1
  4. package/dist/src/create.cjs +6 -2
  5. package/dist/src/create.cjs.map +1 -1
  6. package/dist/src/doctor.cjs.map +1 -1
  7. package/dist/src/fs.cjs +10 -5
  8. package/dist/src/fs.cjs.map +1 -1
  9. package/dist/src/prompts.cjs +32 -0
  10. package/dist/src/prompts.cjs.map +1 -1
  11. package/dist/src/prompts.d.cts +7 -1
  12. package/dist/src/template.cjs +23 -4
  13. package/dist/src/template.cjs.map +1 -1
  14. package/dist/src/template.d.cts +17 -3
  15. package/dist/src/types.d.cts +11 -4
  16. package/package.json +4 -1
  17. package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +1 -0
  18. package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +1 -1
  19. package/templates/minimal-app/.cursor/rules/module-grants-guide.mdc +3 -3
  20. package/templates/minimal-app/.cursor/rules/module-module-guide.mdc +3 -2
  21. package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +1 -1
  22. package/templates/minimal-app/.cursor/rules/server-event-emit.mdc +41 -0
  23. package/templates/minimal-app/.dockerignore.tpl +31 -0
  24. package/templates/minimal-app/.gitignore.tpl +1 -0
  25. package/templates/minimal-app/AGENTS.md.tpl +14 -10
  26. package/templates/minimal-app/CLAUDE.md.tpl +133 -131
  27. package/templates/minimal-app/README.md.tpl +51 -19
  28. package/templates/minimal-app/apps/e2e/package.json.tpl +23 -23
  29. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +51 -39
  30. package/templates/minimal-app/apps/e2e/playwright.expo.config.ts.tpl +38 -38
  31. package/templates/minimal-app/apps/e2e/servers.ts.tpl +136 -112
  32. package/templates/minimal-app/apps/e2e/tests/conversation.spec.ts.tpl +22 -0
  33. package/templates/minimal-app/apps/e2e/tests/email.spec.ts.tpl +47 -47
  34. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +73 -73
  35. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +517 -517
  36. package/templates/minimal-app/apps/e2e/tests/expo-waitlist.spec.ts.tpl +94 -94
  37. package/templates/minimal-app/apps/e2e/tests/files.spec.ts.tpl +35 -0
  38. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +289 -289
  39. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +516 -516
  40. package/templates/minimal-app/apps/e2e/tests/waitlist.spec.ts.tpl +107 -107
  41. package/templates/minimal-app/apps/e2e/tests/workflow.spec.ts.tpl +26 -0
  42. package/templates/minimal-app/apps/e2e/tsconfig.json.tpl +10 -10
  43. package/templates/minimal-app/apps/email/package.json.tpl +34 -34
  44. package/templates/minimal-app/apps/email/src/emails/trialEndingEmail.tsx.tpl +40 -0
  45. package/templates/minimal-app/apps/email/src/index.ts.tpl +7 -0
  46. package/templates/minimal-app/apps/email/src/resources.ts.tpl +72 -65
  47. package/templates/minimal-app/apps/expo/app.json.tpl +36 -36
  48. package/templates/minimal-app/apps/expo/eslint.config.js.tpl +8 -8
  49. package/templates/minimal-app/apps/expo/metro.config.js.tpl +16 -16
  50. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +31 -31
  51. package/templates/minimal-app/apps/expo/src/app/claim-account.tsx.tpl +151 -151
  52. package/templates/minimal-app/apps/expo/src/app/forgot-password.tsx.tpl +53 -53
  53. package/templates/minimal-app/apps/expo/src/app/index.tsx.tpl +8 -8
  54. package/templates/minimal-app/apps/expo/src/app/login.tsx.tpl +71 -71
  55. package/templates/minimal-app/apps/expo/src/app/logout.tsx.tpl +19 -19
  56. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +85 -85
  57. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +40 -40
  58. package/templates/minimal-app/apps/expo/src/app/reset-password.tsx.tpl +75 -75
  59. package/templates/minimal-app/apps/expo/src/app/signup.tsx.tpl +193 -193
  60. package/templates/minimal-app/apps/expo/src/components/AuthScaffold.tsx.tpl +34 -34
  61. package/templates/minimal-app/apps/expo/src/components/FormTextField.tsx.tpl +37 -37
  62. package/templates/minimal-app/apps/expo/src/components/LoadingScreen.tsx.tpl +10 -10
  63. package/templates/minimal-app/apps/expo/src/components/StatusText.tsx.tpl +22 -22
  64. package/templates/minimal-app/apps/expo/src/config.ts.tpl +9 -9
  65. package/templates/minimal-app/apps/expo/src/global.css.tpl +11 -11
  66. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +20 -20
  67. package/templates/minimal-app/apps/expo/tsconfig.json.tpl +11 -11
  68. package/templates/minimal-app/apps/expo/uniwind-env.d.ts.tpl +1 -1
  69. package/templates/minimal-app/apps/landing/.env.production.example.tpl +7 -0
  70. package/templates/minimal-app/apps/landing/Dockerfile.tpl +39 -0
  71. package/templates/minimal-app/apps/landing/fly.toml.tpl +20 -0
  72. package/templates/minimal-app/apps/landing/index.html.tpl +35 -0
  73. package/templates/minimal-app/apps/landing/package.json.tpl +34 -0
  74. package/templates/minimal-app/apps/landing/src/App.tsx.tpl +12 -0
  75. package/templates/minimal-app/apps/landing/src/LandingPage.tsx.tpl +37 -0
  76. package/templates/minimal-app/apps/landing/src/index.css.tpl +24 -0
  77. package/templates/minimal-app/apps/landing/src/main.tsx.tpl +16 -0
  78. package/templates/minimal-app/apps/landing/src/vite-env.d.ts.tpl +11 -0
  79. package/templates/minimal-app/apps/landing/tsconfig.json.tpl +12 -0
  80. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +25 -0
  81. package/templates/minimal-app/apps/server/AGENTS.md.tpl +55 -46
  82. package/templates/minimal-app/apps/server/{drizzle/seed.e2e.ts.tpl → db.e2e.ts.tpl} +57 -40
  83. package/templates/minimal-app/apps/server/{drizzle/seed.ts.tpl → db.ts.tpl} +27 -20
  84. package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +27 -0
  85. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +56 -166
  86. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +1796 -1910
  87. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +1818 -1932
  88. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +2137 -0
  89. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +8 -1
  90. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +3 -33
  91. package/templates/minimal-app/apps/server/package.json.tpl +5 -3
  92. package/templates/minimal-app/apps/server/src/app.ts.tpl +36 -0
  93. package/templates/minimal-app/apps/server/src/modules/conversation/conversation.mastra.ts.tpl +26 -0
  94. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.module.ts.tpl +51 -0
  95. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.service.ts.tpl +51 -0
  96. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.trpc.ts.tpl +16 -0
  97. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +28 -28
  98. package/templates/minimal-app/apps/server/src/modules/posts/posts.grants.ts.tpl +28 -28
  99. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +57 -54
  100. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +67 -67
  101. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +116 -116
  102. package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +37 -37
  103. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +279 -279
  104. package/templates/minimal-app/apps/server/src/schema.ts.tpl +31 -20
  105. package/templates/minimal-app/apps/server/src/types.ts.tpl +12 -12
  106. package/templates/minimal-app/apps/server/tsconfig.json.tpl +1 -1
  107. package/templates/minimal-app/apps/server/tsup.config.ts.tpl +23 -9
  108. package/templates/minimal-app/apps/shared/.env.example.tpl +5 -0
  109. package/templates/minimal-app/apps/shared/.env.production.example.tpl +23 -0
  110. package/templates/minimal-app/apps/shared/.env.tpl +5 -0
  111. package/templates/minimal-app/apps/shared/Dockerfile.tpl +48 -0
  112. package/templates/minimal-app/apps/shared/fly.toml.tpl +24 -0
  113. package/templates/minimal-app/apps/shared/package.json.tpl +26 -24
  114. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +28 -28
  115. package/templates/minimal-app/apps/shared/src/modules/conversation/conversation.constants.ts.tpl +1 -0
  116. package/templates/minimal-app/apps/shared/src/modules/demo-workflow/demo-workflow.constants.ts.tpl +1 -0
  117. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -3
  118. package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -5
  119. package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +25 -25
  120. package/templates/minimal-app/apps/webapp/package.json.tpl +53 -53
  121. package/templates/minimal-app/apps/webapp/public/push-sw.js.tpl +34 -34
  122. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +49 -44
  123. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +96 -65
  124. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +114 -93
  125. package/templates/minimal-app/apps/webapp/src/StarterServerEventProvider.tsx.tpl +38 -0
  126. package/templates/minimal-app/apps/webapp/src/index.css.tpl +15 -15
  127. package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -26
  128. package/templates/minimal-app/apps/webapp/src/modules/conversation/ConversationRoute.tsx.tpl +36 -0
  129. package/templates/minimal-app/apps/webapp/src/modules/files/FilesRoute.tsx.tpl +88 -0
  130. package/templates/minimal-app/apps/webapp/src/modules/files/hooks/useFilesRoute.ts.tpl +50 -0
  131. package/templates/minimal-app/apps/webapp/src/modules/workflows/WorkflowsRoute.tsx.tpl +97 -0
  132. package/templates/minimal-app/apps/webapp/src/modules/workflows/hooks/useWorkflowsRoute.ts.tpl +64 -0
  133. package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -13
  134. package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +10 -10
  135. package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -1
  136. package/templates/minimal-app/apps/webapp/translations/en/app.json.tpl +7 -7
  137. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +193 -135
  138. package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +17 -17
  139. package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -31
  140. package/templates/minimal-app/biome.json.tpl +17 -12
  141. package/templates/minimal-app/package.json.tpl +7 -2
  142. package/templates/minimal-app/pnpm-workspace.yaml.tpl +14 -7
  143. package/templates/minimal-app/template.manifest.json +93 -2
  144. package/templates/minimal-app/apps/server/drizzle/db.ts.tpl +0 -26
  145. package/templates/minimal-app/apps/server/drizzle/guard.ts.tpl +0 -36
  146. package/templates/minimal-app/apps/server/drizzle/reset.ts.tpl +0 -40
  147. package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +0 -16
@@ -1,289 +1,289 @@
1
- import type { APIRequestContext, Page, TestInfo } from "@playwright/test";
2
- import { expect } from "@playwright/test";
3
-
4
- export type Profile = "standard" | "waitlist" | "expoStandard" | "expoWaitlist";
5
-
6
- export type StoredEmail = {
7
- id: string;
8
- to: string | string[];
9
- templateId: string;
10
- subject: string;
11
- previewText: string;
12
- props: Record<string, unknown>;
13
- html?: string;
14
- };
15
-
16
- export type UserHarnessState = {
17
- user: {
18
- id: string;
19
- name: string;
20
- email: string;
21
- emailVerified: boolean;
22
- role: string | null;
23
- banned: boolean | null;
24
- };
25
- organizations: Array<{
26
- id: string;
27
- name: string;
28
- slug: string | null;
29
- type: string | null;
30
- parentId: string | null;
31
- role: string;
32
- }>;
33
- credentialAccount: boolean;
34
- accountClaims: Array<{
35
- id: string;
36
- status: string;
37
- claimUserId: string | null;
38
- claimedEmail: string | null;
39
- claimedAt: string | null;
40
- expiresAt: string | null;
41
- }>;
42
- pendingAccountClaims: number;
43
- acceptedAccountClaims: number;
44
- latestSession: {
45
- id: string;
46
- userId: string;
47
- activeOrganizationId: string | null;
48
- activeOrganizationMemberId: string | null;
49
- activeOrganizationRole: string | null;
50
- activeOrganizationType: string | null;
51
- activeTeamId: string | null;
52
- impersonatedBy: string | null;
53
- createdAt: string;
54
- } | null;
55
- };
56
-
57
- type AuthFetchOptions = {
58
- method?: string;
59
- body?: unknown;
60
- headers?: Record<string, string>;
61
- };
62
-
63
- export type AuthFetchResult = {
64
- ok: boolean;
65
- status: number;
66
- statusText: string;
67
- text: string;
68
- json: unknown;
69
- };
70
-
71
- export const profiles = {
72
- standard: {
73
- webUrl: "http://127.0.0.1:15173",
74
- serverUrl: "http://127.0.0.1:18180",
75
- adminEmail: "admin.standard@auth-e2e.local",
76
- adminPassword: "password1234",
77
- },
78
- waitlist: {
79
- webUrl: "http://127.0.0.1:15174",
80
- serverUrl: "http://127.0.0.1:18181",
81
- adminEmail: "admin.waitlist@auth-e2e.local",
82
- adminPassword: "password1234",
83
- },
84
- expoStandard: {
85
- webUrl: "http://127.0.0.1:15183",
86
- serverUrl: "http://127.0.0.1:18182",
87
- adminEmail: "admin.standard@auth-e2e.local",
88
- adminPassword: "password1234",
89
- },
90
- expoWaitlist: {
91
- webUrl: "http://127.0.0.1:15184",
92
- serverUrl: "http://127.0.0.1:18183",
93
- adminEmail: "admin.waitlist@auth-e2e.local",
94
- adminPassword: "password1234",
95
- },
96
- } as const satisfies Record<
97
- Profile,
98
- { webUrl: string; serverUrl: string; adminEmail: string; adminPassword: string }
99
- >;
100
-
101
- export function profileFrom(testInfo: TestInfo): Profile {
102
- if (testInfo.project.name === "expo-waitlist") return "expoWaitlist";
103
- if (testInfo.project.name === "expo-standard") return "expoStandard";
104
- return testInfo.project.name === "waitlist" ? "waitlist" : "standard";
105
- }
106
-
107
- export async function clearEmails(request: APIRequestContext, profile: Profile) {
108
- const response = await request.delete(`${profiles[profile].serverUrl}/__emails`);
109
- expect(response.ok()).toBe(true);
110
- }
111
-
112
- export async function latestEmail(
113
- request: APIRequestContext,
114
- profile: Profile,
115
- filter: { to?: string; templateId?: string }
116
- ) {
117
- const url = new URL(`${profiles[profile].serverUrl}/__emails/latest.json`);
118
- if (filter.to) url.searchParams.set("to", filter.to);
119
- if (filter.templateId) url.searchParams.set("templateId", filter.templateId);
120
- await expect
121
- .poll(async () => {
122
- const response = await request.get(url.toString());
123
- return response.ok();
124
- })
125
- .toBe(true);
126
-
127
- const response = await request.get(url.toString());
128
- expect(response.ok()).toBe(true);
129
- return (await response.json()) as StoredEmail;
130
- }
131
-
132
- export async function getUserState(request: APIRequestContext, profile: Profile, email: string) {
133
- const url = new URL(`${profiles[profile].serverUrl}/__auth-e2e/user.json`);
134
- url.searchParams.set("email", email);
135
- const response = await request.get(url.toString());
136
- if (!response.ok()) {
137
- throw new Error(`${response.status()} ${response.statusText()}: ${await response.text()}`);
138
- }
139
- return (await response.json()) as UserHarnessState;
140
- }
141
-
142
- export async function createProvisionedClaimUser(
143
- request: APIRequestContext,
144
- profile: Profile,
145
- email: string,
146
- options: { expiresInHours?: number } = {}
147
- ) {
148
- const response = await request.post(
149
- `${profiles[profile].serverUrl}/__auth-e2e/provisioned-claim-user`,
150
- {
151
- data: {
152
- email,
153
- expiresInHours: options.expiresInHours,
154
- },
155
- }
156
- );
157
- if (!response.ok()) {
158
- throw new Error(`${response.status()} ${response.statusText()}: ${await response.text()}`);
159
- }
160
- return (await response.json()) as {
161
- user: { id: string; email: string };
162
- organization: { id: string };
163
- claim: { id: string } | null;
164
- };
165
- }
166
-
167
- export async function seedOrganizations(
168
- request: APIRequestContext,
169
- profile: Profile,
170
- input: { prefix: string; count: number }
171
- ) {
172
- const response = await request.post(`${profiles[profile].serverUrl}/__auth-e2e/organizations`, {
173
- data: input,
174
- });
175
- if (!response.ok()) {
176
- throw new Error(`${response.status()} ${response.statusText()}: ${await response.text()}`);
177
- }
178
- return (await response.json()) as {
179
- organizations: Array<{ id: string; name: string; slug: string }>;
180
- };
181
- }
182
-
183
- export async function verifyLatestEmail(
184
- request: APIRequestContext,
185
- profile: Profile,
186
- email: string
187
- ) {
188
- const verification = await latestEmail(request, profile, {
189
- to: email,
190
- templateId: "verification",
191
- });
192
- const response = await request.get(emailUrl(verification), { maxRedirects: 0 });
193
- expect(
194
- response.ok() || (response.status() >= 300 && response.status() < 400),
195
- `${response.status()} ${response.statusText()}: ${await response.text()}`
196
- ).toBe(true);
197
- }
198
-
199
- export function emailUrl(email: StoredEmail) {
200
- const url = email.props.url;
201
- if (typeof url !== "string") {
202
- throw new Error(`Stored email ${email.id} does not include props.url`);
203
- }
204
- return url;
205
- }
206
-
207
- export async function signUp(page: Page, email: string, password: string) {
208
- await page.goto("/signup");
209
- await page.locator('[name="signup-email"]').fill(email);
210
- await page.locator('[name="signup-password"]').fill(password);
211
- await page.getByRole("button", { name: /sign up/i }).click();
212
- }
213
-
214
- export async function login(page: Page, email: string, password: string) {
215
- await page.goto("/login");
216
- await page.locator('[name="login-email"]').fill(email);
217
- await page.locator('[name="login-password"]').fill(password);
218
- await page.getByRole("button", { name: /^login$/i }).click();
219
- await expect(page.getByTestId("session-email")).toContainText(email);
220
- }
221
-
222
- export async function expectLoginRejected(page: Page, email: string, password: string) {
223
- await page.goto("/login");
224
- await page.locator('[name="login-email"]').fill(email);
225
- await page.locator('[name="login-password"]').fill(password);
226
- const responsePromise = page.waitForResponse(
227
- (response) =>
228
- response.url().includes("/api/auth/sign-in/email") && response.request().method() === "POST"
229
- );
230
- await page.getByRole("button", { name: /^login$/i }).click();
231
- const response = await responsePromise;
232
- expect(
233
- response.ok(),
234
- `${response.status()} ${response.statusText()}: ${await response.text()}`
235
- ).toBe(false);
236
- }
237
-
238
- export async function authFetch(
239
- page: Page,
240
- profile: Profile,
241
- path: string,
242
- options: AuthFetchOptions = {}
243
- ): Promise<AuthFetchResult> {
244
- return page.evaluate(
245
- async ({ options, path, serverUrl }) => {
246
- const headers = { ...(options.headers ?? {}) };
247
- const init: RequestInit = {
248
- method: options.method ?? "GET",
249
- credentials: "include",
250
- headers,
251
- };
252
- if (options.body !== undefined) {
253
- headers["content-type"] = "application/json";
254
- init.body = JSON.stringify(options.body);
255
- }
256
-
257
- const response = await fetch(`${serverUrl}${path}`, init);
258
- const text = await response.text();
259
- let json: unknown = null;
260
- try {
261
- json = JSON.parse(text);
262
- } catch {
263
- json = null;
264
- }
265
-
266
- return {
267
- ok: response.ok,
268
- status: response.status,
269
- statusText: response.statusText,
270
- text,
271
- json,
272
- };
273
- },
274
- { options, path, serverUrl: profiles[profile].serverUrl }
275
- );
276
- }
277
-
278
- export async function logout(page: Page) {
279
- await page.goto("/logout");
280
- await expect(page).toHaveURL(/\/login/);
281
- }
282
-
283
- export function resetTokenFrom(url: string) {
284
- const parsed = new URL(url);
285
- const parts = parsed.pathname.split("/");
286
- const token = parts.at(-1);
287
- if (!token) throw new Error(`Could not read reset token from ${url}`);
288
- return token;
289
- }
1
+ import type { APIRequestContext, Page, TestInfo } from "@playwright/test";
2
+ import { expect } from "@playwright/test";
3
+
4
+ export type Profile = "standard" | "waitlist" | "expoStandard" | "expoWaitlist";
5
+
6
+ export type StoredEmail = {
7
+ id: string;
8
+ to: string | string[];
9
+ templateId: string;
10
+ subject: string;
11
+ previewText: string;
12
+ props: Record<string, unknown>;
13
+ html?: string;
14
+ };
15
+
16
+ export type UserHarnessState = {
17
+ user: {
18
+ id: string;
19
+ name: string;
20
+ email: string;
21
+ emailVerified: boolean;
22
+ role: string | null;
23
+ banned: boolean | null;
24
+ };
25
+ organizations: Array<{
26
+ id: string;
27
+ name: string;
28
+ slug: string | null;
29
+ type: string | null;
30
+ parentId: string | null;
31
+ role: string;
32
+ }>;
33
+ credentialAccount: boolean;
34
+ accountClaims: Array<{
35
+ id: string;
36
+ status: string;
37
+ claimUserId: string | null;
38
+ claimedEmail: string | null;
39
+ claimedAt: string | null;
40
+ expiresAt: string | null;
41
+ }>;
42
+ pendingAccountClaims: number;
43
+ acceptedAccountClaims: number;
44
+ latestSession: {
45
+ id: string;
46
+ userId: string;
47
+ activeOrganizationId: string | null;
48
+ activeOrganizationMemberId: string | null;
49
+ activeOrganizationRole: string | null;
50
+ activeOrganizationType: string | null;
51
+ activeTeamId: string | null;
52
+ impersonatedBy: string | null;
53
+ createdAt: string;
54
+ } | null;
55
+ };
56
+
57
+ type AuthFetchOptions = {
58
+ method?: string;
59
+ body?: unknown;
60
+ headers?: Record<string, string>;
61
+ };
62
+
63
+ export type AuthFetchResult = {
64
+ ok: boolean;
65
+ status: number;
66
+ statusText: string;
67
+ text: string;
68
+ json: unknown;
69
+ };
70
+
71
+ export const profiles = {
72
+ standard: {
73
+ webUrl: "http://127.0.0.1:15173",
74
+ serverUrl: "http://127.0.0.1:18180",
75
+ adminEmail: "admin.standard@auth-e2e.local",
76
+ adminPassword: "password1234",
77
+ },
78
+ waitlist: {
79
+ webUrl: "http://127.0.0.1:15174",
80
+ serverUrl: "http://127.0.0.1:18181",
81
+ adminEmail: "admin.waitlist@auth-e2e.local",
82
+ adminPassword: "password1234",
83
+ },
84
+ expoStandard: {
85
+ webUrl: "http://127.0.0.1:15183",
86
+ serverUrl: "http://127.0.0.1:18182",
87
+ adminEmail: "admin.standard@auth-e2e.local",
88
+ adminPassword: "password1234",
89
+ },
90
+ expoWaitlist: {
91
+ webUrl: "http://127.0.0.1:15184",
92
+ serverUrl: "http://127.0.0.1:18183",
93
+ adminEmail: "admin.waitlist@auth-e2e.local",
94
+ adminPassword: "password1234",
95
+ },
96
+ } as const satisfies Record<
97
+ Profile,
98
+ { webUrl: string; serverUrl: string; adminEmail: string; adminPassword: string }
99
+ >;
100
+
101
+ export function profileFrom(testInfo: TestInfo): Profile {
102
+ if (testInfo.project.name === "expo-waitlist") return "expoWaitlist";
103
+ if (testInfo.project.name === "expo-standard") return "expoStandard";
104
+ return testInfo.project.name === "waitlist" ? "waitlist" : "standard";
105
+ }
106
+
107
+ export async function clearEmails(request: APIRequestContext, profile: Profile) {
108
+ const response = await request.delete(`${profiles[profile].serverUrl}/__emails`);
109
+ expect(response.ok()).toBe(true);
110
+ }
111
+
112
+ export async function latestEmail(
113
+ request: APIRequestContext,
114
+ profile: Profile,
115
+ filter: { to?: string; templateId?: string }
116
+ ) {
117
+ const url = new URL(`${profiles[profile].serverUrl}/__emails/latest.json`);
118
+ if (filter.to) url.searchParams.set("to", filter.to);
119
+ if (filter.templateId) url.searchParams.set("templateId", filter.templateId);
120
+ await expect
121
+ .poll(async () => {
122
+ const response = await request.get(url.toString());
123
+ return response.ok();
124
+ })
125
+ .toBe(true);
126
+
127
+ const response = await request.get(url.toString());
128
+ expect(response.ok()).toBe(true);
129
+ return (await response.json()) as StoredEmail;
130
+ }
131
+
132
+ export async function getUserState(request: APIRequestContext, profile: Profile, email: string) {
133
+ const url = new URL(`${profiles[profile].serverUrl}/__auth-e2e/user.json`);
134
+ url.searchParams.set("email", email);
135
+ const response = await request.get(url.toString());
136
+ if (!response.ok()) {
137
+ throw new Error(`${response.status()} ${response.statusText()}: ${await response.text()}`);
138
+ }
139
+ return (await response.json()) as UserHarnessState;
140
+ }
141
+
142
+ export async function createProvisionedClaimUser(
143
+ request: APIRequestContext,
144
+ profile: Profile,
145
+ email: string,
146
+ options: { expiresInHours?: number } = {}
147
+ ) {
148
+ const response = await request.post(
149
+ `${profiles[profile].serverUrl}/__auth-e2e/provisioned-claim-user`,
150
+ {
151
+ data: {
152
+ email,
153
+ expiresInHours: options.expiresInHours,
154
+ },
155
+ }
156
+ );
157
+ if (!response.ok()) {
158
+ throw new Error(`${response.status()} ${response.statusText()}: ${await response.text()}`);
159
+ }
160
+ return (await response.json()) as {
161
+ user: { id: string; email: string };
162
+ organization: { id: string };
163
+ claim: { id: string } | null;
164
+ };
165
+ }
166
+
167
+ export async function seedOrganizations(
168
+ request: APIRequestContext,
169
+ profile: Profile,
170
+ input: { prefix: string; count: number }
171
+ ) {
172
+ const response = await request.post(`${profiles[profile].serverUrl}/__auth-e2e/organizations`, {
173
+ data: input,
174
+ });
175
+ if (!response.ok()) {
176
+ throw new Error(`${response.status()} ${response.statusText()}: ${await response.text()}`);
177
+ }
178
+ return (await response.json()) as {
179
+ organizations: Array<{ id: string; name: string; slug: string }>;
180
+ };
181
+ }
182
+
183
+ export async function verifyLatestEmail(
184
+ request: APIRequestContext,
185
+ profile: Profile,
186
+ email: string
187
+ ) {
188
+ const verification = await latestEmail(request, profile, {
189
+ to: email,
190
+ templateId: "verification",
191
+ });
192
+ const response = await request.get(emailUrl(verification), { maxRedirects: 0 });
193
+ expect(
194
+ response.ok() || (response.status() >= 300 && response.status() < 400),
195
+ `${response.status()} ${response.statusText()}: ${await response.text()}`
196
+ ).toBe(true);
197
+ }
198
+
199
+ export function emailUrl(email: StoredEmail) {
200
+ const url = email.props.url;
201
+ if (typeof url !== "string") {
202
+ throw new Error(`Stored email ${email.id} does not include props.url`);
203
+ }
204
+ return url;
205
+ }
206
+
207
+ export async function signUp(page: Page, email: string, password: string) {
208
+ await page.goto("/signup");
209
+ await page.locator('[name="signup-email"]').fill(email);
210
+ await page.locator('[name="signup-password"]').fill(password);
211
+ await page.getByRole("button", { name: /sign up/i }).click();
212
+ }
213
+
214
+ export async function login(page: Page, email: string, password: string) {
215
+ await page.goto("/login");
216
+ await page.locator('[name="login-email"]').fill(email);
217
+ await page.locator('[name="login-password"]').fill(password);
218
+ await page.getByRole("button", { name: /^login$/i }).click();
219
+ await expect(page.getByTestId("session-email")).toContainText(email);
220
+ }
221
+
222
+ export async function expectLoginRejected(page: Page, email: string, password: string) {
223
+ await page.goto("/login");
224
+ await page.locator('[name="login-email"]').fill(email);
225
+ await page.locator('[name="login-password"]').fill(password);
226
+ const responsePromise = page.waitForResponse(
227
+ (response) =>
228
+ response.url().includes("/api/auth/sign-in/email") && response.request().method() === "POST"
229
+ );
230
+ await page.getByRole("button", { name: /^login$/i }).click();
231
+ const response = await responsePromise;
232
+ expect(
233
+ response.ok(),
234
+ `${response.status()} ${response.statusText()}: ${await response.text()}`
235
+ ).toBe(false);
236
+ }
237
+
238
+ export async function authFetch(
239
+ page: Page,
240
+ profile: Profile,
241
+ path: string,
242
+ options: AuthFetchOptions = {}
243
+ ): Promise<AuthFetchResult> {
244
+ return page.evaluate(
245
+ async ({ options, path, serverUrl }) => {
246
+ const headers = { ...(options.headers ?? {}) };
247
+ const init: RequestInit = {
248
+ method: options.method ?? "GET",
249
+ credentials: "include",
250
+ headers,
251
+ };
252
+ if (options.body !== undefined) {
253
+ headers["content-type"] = "application/json";
254
+ init.body = JSON.stringify(options.body);
255
+ }
256
+
257
+ const response = await fetch(`${serverUrl}${path}`, init);
258
+ const text = await response.text();
259
+ let json: unknown = null;
260
+ try {
261
+ json = JSON.parse(text);
262
+ } catch {
263
+ json = null;
264
+ }
265
+
266
+ return {
267
+ ok: response.ok,
268
+ status: response.status,
269
+ statusText: response.statusText,
270
+ text,
271
+ json,
272
+ };
273
+ },
274
+ { options, path, serverUrl: profiles[profile].serverUrl }
275
+ );
276
+ }
277
+
278
+ export async function logout(page: Page) {
279
+ await page.goto("/logout");
280
+ await expect(page).toHaveURL(/\/login/);
281
+ }
282
+
283
+ export function resetTokenFrom(url: string) {
284
+ const parsed = new URL(url);
285
+ const parts = parsed.pathname.split("/");
286
+ const token = parts.at(-1);
287
+ if (!token) throw new Error(`Could not read reset token from ${url}`);
288
+ return token;
289
+ }