create-m5kdev 0.32.8 → 0.34.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 (152) hide show
  1. package/dist/src/catalog.cjs +76 -11
  2. package/dist/src/catalog.cjs.map +1 -1
  3. package/dist/src/catalog.d.cts +13 -1
  4. package/dist/src/create.cjs +6 -2
  5. package/dist/src/create.cjs.map +1 -1
  6. package/dist/src/doctor.cjs +13 -7
  7. package/dist/src/doctor.cjs.map +1 -1
  8. package/dist/src/fs.cjs +10 -5
  9. package/dist/src/fs.cjs.map +1 -1
  10. package/dist/src/prompts.cjs +32 -0
  11. package/dist/src/prompts.cjs.map +1 -1
  12. package/dist/src/prompts.d.cts +7 -1
  13. package/dist/src/template.cjs +23 -4
  14. package/dist/src/template.cjs.map +1 -1
  15. package/dist/src/template.d.cts +17 -3
  16. package/dist/src/types.d.cts +11 -4
  17. package/package.json +4 -1
  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/.dockerignore.tpl +31 -0
  23. package/templates/minimal-app/.gitignore.tpl +1 -0
  24. package/templates/minimal-app/AGENTS.md.tpl +13 -10
  25. package/templates/minimal-app/CLAUDE.md.tpl +132 -131
  26. package/templates/minimal-app/README.md.tpl +50 -19
  27. package/templates/minimal-app/apps/e2e/package.json.tpl +23 -23
  28. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +51 -39
  29. package/templates/minimal-app/apps/e2e/playwright.expo.config.ts.tpl +38 -38
  30. package/templates/minimal-app/apps/e2e/servers.ts.tpl +136 -112
  31. package/templates/minimal-app/apps/e2e/tests/conversation.spec.ts.tpl +22 -0
  32. package/templates/minimal-app/apps/e2e/tests/email.spec.ts.tpl +47 -47
  33. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +73 -73
  34. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +517 -517
  35. package/templates/minimal-app/apps/e2e/tests/expo-waitlist.spec.ts.tpl +94 -94
  36. package/templates/minimal-app/apps/e2e/tests/files.spec.ts.tpl +35 -0
  37. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +289 -289
  38. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +516 -516
  39. package/templates/minimal-app/apps/e2e/tests/waitlist.spec.ts.tpl +107 -107
  40. package/templates/minimal-app/apps/e2e/tests/workflow.spec.ts.tpl +26 -0
  41. package/templates/minimal-app/apps/e2e/tsconfig.json.tpl +10 -10
  42. package/templates/minimal-app/apps/email/package.json.tpl +34 -34
  43. package/templates/minimal-app/apps/email/src/emails/trialEndingEmail.tsx.tpl +40 -0
  44. package/templates/minimal-app/apps/email/src/index.ts.tpl +7 -0
  45. package/templates/minimal-app/apps/email/src/resources.ts.tpl +72 -65
  46. package/templates/minimal-app/apps/expo/app.json.tpl +36 -36
  47. package/templates/minimal-app/apps/expo/eslint.config.js.tpl +8 -8
  48. package/templates/minimal-app/apps/expo/metro.config.js.tpl +16 -16
  49. package/templates/minimal-app/apps/expo/package.json.tpl +59 -59
  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/scripts/fly-deploy.mjs.tpl +75 -0
  75. package/templates/minimal-app/apps/landing/scripts/fly-secrets.mjs.tpl +31 -0
  76. package/templates/minimal-app/apps/landing/src/App.tsx.tpl +12 -0
  77. package/templates/minimal-app/apps/landing/src/LandingPage.tsx.tpl +37 -0
  78. package/templates/minimal-app/apps/landing/src/index.css.tpl +24 -0
  79. package/templates/minimal-app/apps/landing/src/main.tsx.tpl +16 -0
  80. package/templates/minimal-app/apps/landing/src/vite-env.d.ts.tpl +11 -0
  81. package/templates/minimal-app/apps/landing/tsconfig.json.tpl +12 -0
  82. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +25 -0
  83. package/templates/minimal-app/apps/server/AGENTS.md.tpl +54 -46
  84. package/templates/minimal-app/apps/server/{drizzle/seed.e2e.ts.tpl → db.e2e.ts.tpl} +57 -40
  85. package/templates/minimal-app/apps/server/{drizzle/seed.ts.tpl → db.ts.tpl} +27 -20
  86. package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +3 -0
  87. package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +27 -0
  88. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +56 -166
  89. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +1796 -1910
  90. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +1818 -0
  91. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +2137 -0
  92. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +15 -1
  93. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +3 -33
  94. package/templates/minimal-app/apps/server/package.json.tpl +57 -56
  95. package/templates/minimal-app/apps/server/src/app.ts.tpl +149 -141
  96. package/templates/minimal-app/apps/server/src/index.ts.tpl +12 -65
  97. package/templates/minimal-app/apps/server/src/modules/conversation/conversation.mastra.ts.tpl +26 -0
  98. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.module.ts.tpl +47 -0
  99. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.service.ts.tpl +43 -0
  100. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.trpc.ts.tpl +16 -0
  101. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +28 -28
  102. package/templates/minimal-app/apps/server/src/modules/posts/posts.grants.ts.tpl +28 -30
  103. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +54 -54
  104. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +67 -67
  105. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +116 -115
  106. package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +37 -37
  107. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +279 -279
  108. package/templates/minimal-app/apps/server/src/schema.ts.tpl +31 -20
  109. package/templates/minimal-app/apps/server/src/types.ts.tpl +12 -1
  110. package/templates/minimal-app/apps/server/tsconfig.json.tpl +1 -1
  111. package/templates/minimal-app/apps/server/tsup.config.ts.tpl +23 -9
  112. package/templates/minimal-app/apps/shared/.env.example.tpl +5 -0
  113. package/templates/minimal-app/apps/shared/.env.production.example.tpl +23 -0
  114. package/templates/minimal-app/apps/shared/.env.tpl +5 -0
  115. package/templates/minimal-app/apps/shared/Dockerfile.tpl +48 -0
  116. package/templates/minimal-app/apps/shared/fly.toml.tpl +24 -0
  117. package/templates/minimal-app/apps/shared/package.json.tpl +25 -24
  118. package/templates/minimal-app/apps/shared/scripts/fly-deploy.mjs.tpl +75 -0
  119. package/templates/minimal-app/apps/shared/scripts/fly-secrets.mjs.tpl +31 -0
  120. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +28 -28
  121. package/templates/minimal-app/apps/shared/src/modules/conversation/conversation.constants.ts.tpl +1 -0
  122. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -3
  123. package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -5
  124. package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +25 -25
  125. package/templates/minimal-app/apps/webapp/package.json.tpl +53 -53
  126. package/templates/minimal-app/apps/webapp/public/push-sw.js.tpl +34 -34
  127. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +46 -44
  128. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +96 -65
  129. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +114 -93
  130. package/templates/minimal-app/apps/webapp/src/index.css.tpl +15 -15
  131. package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -26
  132. package/templates/minimal-app/apps/webapp/src/modules/conversation/ConversationRoute.tsx.tpl +36 -0
  133. package/templates/minimal-app/apps/webapp/src/modules/files/FilesRoute.tsx.tpl +88 -0
  134. package/templates/minimal-app/apps/webapp/src/modules/files/hooks/useFilesRoute.ts.tpl +50 -0
  135. package/templates/minimal-app/apps/webapp/src/modules/workflows/WorkflowsRoute.tsx.tpl +97 -0
  136. package/templates/minimal-app/apps/webapp/src/modules/workflows/hooks/useWorkflowsRoute.ts.tpl +64 -0
  137. package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -13
  138. package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +10 -10
  139. package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -1
  140. package/templates/minimal-app/apps/webapp/translations/en/app.json.tpl +7 -7
  141. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +193 -135
  142. package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +17 -17
  143. package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -31
  144. package/templates/minimal-app/biome.json.tpl +17 -12
  145. package/templates/minimal-app/package.json.tpl +9 -5
  146. package/templates/minimal-app/pnpm-workspace.yaml.tpl +67 -58
  147. package/templates/minimal-app/template.manifest.json +92 -2
  148. package/templates/minimal-app/apps/server/drizzle/db.ts.tpl +0 -26
  149. package/templates/minimal-app/apps/server/drizzle/guard.ts.tpl +0 -36
  150. package/templates/minimal-app/apps/server/drizzle/reset.ts.tpl +0 -40
  151. package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +0 -16
  152. package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +0 -12
@@ -1,37 +1,37 @@
1
- import { handleTRPCResult, type TRPCMethods } from "@m5kdev/backend/utils/trpc";
2
- import { postSchemas } from "./posts.dto";
3
- import type { PostsService } from "./posts.service";
4
-
5
- export function createPostsTRPC(
6
- { router, privateProcedure: procedure }: TRPCMethods,
7
- postsService: PostsService
8
- ) {
9
- return router({
10
- list: procedure
11
- .input(postSchemas.input.list)
12
- .output(postSchemas.output.list)
13
- .query(async ({ ctx, input }) => handleTRPCResult(await postsService.list(input ?? {}, ctx))),
14
-
15
- create: procedure
16
- .input(postSchemas.input.create)
17
- .output(postSchemas.output.single)
18
- .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.create(input, ctx))),
19
-
20
- update: procedure
21
- .input(postSchemas.input.update)
22
- .output(postSchemas.output.single)
23
- .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.update(input, ctx))),
24
-
25
- publish: procedure
26
- .input(postSchemas.input.publish)
27
- .output(postSchemas.output.single)
28
- .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.publish(input, ctx))),
29
-
30
- softDelete: procedure
31
- .input(postSchemas.input.delete)
32
- .output(postSchemas.output.uuid)
33
- .mutation(async ({ ctx, input }) =>
34
- handleTRPCResult(await postsService.softDelete(input, ctx))
35
- ),
36
- });
37
- }
1
+ import { handleTRPCResult, type TRPCMethods } from "@m5kdev/backend/utils/trpc";
2
+ import { postSchemas } from "./posts.dto";
3
+ import type { PostsService } from "./posts.service";
4
+
5
+ export function createPostsTRPC(
6
+ { router, organizationProcedure: procedure }: TRPCMethods,
7
+ postsService: PostsService
8
+ ) {
9
+ return router({
10
+ list: procedure
11
+ .input(postSchemas.input.list)
12
+ .output(postSchemas.output.list)
13
+ .query(async ({ ctx, input }) => handleTRPCResult(await postsService.list(input ?? {}, ctx))),
14
+
15
+ create: procedure
16
+ .input(postSchemas.input.create)
17
+ .output(postSchemas.output.single)
18
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.create(input, ctx))),
19
+
20
+ update: procedure
21
+ .input(postSchemas.input.update)
22
+ .output(postSchemas.output.single)
23
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.update(input, ctx))),
24
+
25
+ publish: procedure
26
+ .input(postSchemas.input.publish)
27
+ .output(postSchemas.output.single)
28
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.publish(input, ctx))),
29
+
30
+ softDelete: procedure
31
+ .input(postSchemas.input.delete)
32
+ .output(postSchemas.output.uuid)
33
+ .mutation(async ({ ctx, input }) =>
34
+ handleTRPCResult(await postsService.softDelete(input, ctx))
35
+ ),
36
+ });
37
+ }
@@ -1,279 +1,279 @@
1
- import {
2
- BaseModule,
3
- type ModuleExpressContext,
4
- type TableMap,
5
- } from "@m5kdev/backend/modules/base/base.module";
6
- import { and, desc, eq, isNotNull } from "drizzle-orm";
7
- import type { Request, Response } from "express";
8
- import { v4 as uuidv4 } from "uuid";
9
-
10
- type TestHarnessTables = TableMap;
11
-
12
- function normalizedEmail(value: unknown) {
13
- return typeof value === "string" ? value.trim().toLowerCase() : "";
14
- }
15
-
16
- function normalizedText(value: unknown, fallback: string) {
17
- return typeof value === "string" && value.trim() ? value.trim() : fallback;
18
- }
19
-
20
- function slugPart(value: string) {
21
- return value
22
- .trim()
23
- .toLowerCase()
24
- .replace(/[^a-z0-9]+/g, "-")
25
- .replace(/^-+|-+$/g, "")
26
- .slice(0, 48);
27
- }
28
-
29
- function sendJson(res: Response, status: number, body: unknown) {
30
- res
31
- .status(status)
32
- .type("application/json")
33
- .send(JSON.stringify(body, null, 2));
34
- }
35
-
36
- export class TestHarnessModule extends BaseModule<
37
- Record<string, never>,
38
- TestHarnessTables,
39
- Record<string, never>,
40
- Record<string, never>,
41
- never
42
- > {
43
- readonly id = "auth-e2e-test-harness";
44
- override readonly dbDependsOn = ["auth"] as const;
45
-
46
- override express({ db, infra }: ModuleExpressContext<Record<string, never>>) {
47
- infra.express.get("/__auth-e2e/user.json", async (req: Request, res: Response) => {
48
- const email = normalizedEmail(req.query.email);
49
- if (!email) {
50
- sendJson(res, 400, { error: "email is required" });
51
- return;
52
- }
53
-
54
- const [user] = await db.orm
55
- .select({
56
- id: db.schema.users.id,
57
- name: db.schema.users.name,
58
- email: db.schema.users.email,
59
- emailVerified: db.schema.users.emailVerified,
60
- role: db.schema.users.role,
61
- banned: db.schema.users.banned,
62
- })
63
- .from(db.schema.users)
64
- .where(eq(db.schema.users.email, email))
65
- .limit(1);
66
-
67
- if (!user) {
68
- sendJson(res, 404, { error: "user not found" });
69
- return;
70
- }
71
-
72
- const organizations = await db.orm
73
- .select({
74
- id: db.schema.organizations.id,
75
- name: db.schema.organizations.name,
76
- slug: db.schema.organizations.slug,
77
- type: db.schema.organizations.type,
78
- parentId: db.schema.organizations.parentId,
79
- role: db.schema.members.role,
80
- })
81
- .from(db.schema.members)
82
- .innerJoin(
83
- db.schema.organizations,
84
- eq(db.schema.members.organizationId, db.schema.organizations.id)
85
- )
86
- .where(eq(db.schema.members.userId, user.id));
87
-
88
- const [credentialAccount] = await db.orm
89
- .select({ id: db.schema.accounts.id })
90
- .from(db.schema.accounts)
91
- .where(
92
- and(
93
- eq(db.schema.accounts.userId, user.id),
94
- eq(db.schema.accounts.providerId, "credential"),
95
- isNotNull(db.schema.accounts.password)
96
- )
97
- )
98
- .limit(1);
99
-
100
- const accountClaims = await db.orm
101
- .select({
102
- id: db.schema.waitlist.id,
103
- status: db.schema.waitlist.status,
104
- claimUserId: db.schema.waitlist.claimUserId,
105
- claimedEmail: db.schema.waitlist.claimedEmail,
106
- claimedAt: db.schema.waitlist.claimedAt,
107
- expiresAt: db.schema.waitlist.expiresAt,
108
- })
109
- .from(db.schema.waitlist)
110
- .where(
111
- and(
112
- eq(db.schema.waitlist.type, "ACCOUNT_CLAIM"),
113
- eq(db.schema.waitlist.claimUserId, user.id)
114
- )
115
- )
116
- .orderBy(desc(db.schema.waitlist.createdAt));
117
-
118
- const [latestSession] = await db.orm
119
- .select({
120
- id: db.schema.sessions.id,
121
- userId: db.schema.sessions.userId,
122
- activeOrganizationId: db.schema.sessions.activeOrganizationId,
123
- activeOrganizationRole: db.schema.sessions.activeOrganizationRole,
124
- activeOrganizationMemberId: db.schema.sessions.activeOrganizationMemberId,
125
- activeOrganizationType: db.schema.sessions.activeOrganizationType,
126
- activeTeamId: db.schema.sessions.activeTeamId,
127
- impersonatedBy: db.schema.sessions.impersonatedBy,
128
- createdAt: db.schema.sessions.createdAt,
129
- })
130
- .from(db.schema.sessions)
131
- .where(eq(db.schema.sessions.userId, user.id))
132
- .orderBy(desc(db.schema.sessions.createdAt))
133
- .limit(1);
134
-
135
- sendJson(res, 200, {
136
- user,
137
- organizations,
138
- credentialAccount: Boolean(credentialAccount),
139
- accountClaims,
140
- pendingAccountClaims: accountClaims.filter((claim) => claim.status === "INVITED").length,
141
- acceptedAccountClaims: accountClaims.filter((claim) => claim.status === "ACCEPTED").length,
142
- latestSession: latestSession ?? null,
143
- });
144
- });
145
-
146
- infra.express.post(
147
- "/__auth-e2e/provisioned-claim-user",
148
- async (req: Request, res: Response) => {
149
- const email = normalizedEmail((req.body as { email?: unknown }).email);
150
- const expiresInHours =
151
- typeof (req.body as { expiresInHours?: unknown }).expiresInHours === "number"
152
- ? (req.body as { expiresInHours: number }).expiresInHours
153
- : 24 * 14;
154
- const createClaim = (req.body as { createClaim?: unknown }).createClaim === true;
155
-
156
- if (!email.endsWith("@provisioned.auth-e2e.local")) {
157
- sendJson(res, 400, { error: "email must use the provisioned auth-e2e domain" });
158
- return;
159
- }
160
-
161
- const [existing] = await db.orm
162
- .select({ id: db.schema.users.id })
163
- .from(db.schema.users)
164
- .where(eq(db.schema.users.email, email))
165
- .limit(1);
166
-
167
- if (existing) {
168
- sendJson(res, 409, { error: "user already exists" });
169
- return;
170
- }
171
-
172
- const now = new Date();
173
- const userId = uuidv4();
174
- const organizationId = uuidv4();
175
- const teamId = uuidv4();
176
- const claimId = createClaim ? uuidv4() : null;
177
-
178
- await db.orm.transaction(async (tx) => {
179
- await tx.insert(db.schema.users).values({
180
- id: userId,
181
- name: `Provisioned ${email}`,
182
- email,
183
- emailVerified: false,
184
- role: "user",
185
- createdAt: now,
186
- updatedAt: now,
187
- });
188
-
189
- await tx.insert(db.schema.organizations).values({
190
- id: organizationId,
191
- name: `Provisioned ${userId.slice(0, 8)}`,
192
- slug: `provisioned-${userId.slice(0, 8)}`,
193
- type: "organization",
194
- createdAt: now,
195
- });
196
-
197
- await tx.insert(db.schema.members).values({
198
- id: uuidv4(),
199
- organizationId,
200
- userId,
201
- role: "owner",
202
- createdAt: now,
203
- });
204
-
205
- await tx.insert(db.schema.teams).values({
206
- id: teamId,
207
- name: "Editorial",
208
- organizationId,
209
- createdAt: now,
210
- updatedAt: now,
211
- });
212
-
213
- await tx.insert(db.schema.teamMembers).values({
214
- id: uuidv4(),
215
- teamId,
216
- userId,
217
- role: "owner",
218
- createdAt: now,
219
- });
220
-
221
- if (claimId) {
222
- await tx.insert(db.schema.waitlist).values({
223
- id: claimId,
224
- type: "ACCOUNT_CLAIM",
225
- status: "INVITED",
226
- claimUserId: userId,
227
- code: uuidv4(),
228
- expiresAt: new Date(Date.now() + expiresInHours * 60 * 60 * 1000),
229
- createdAt: now,
230
- updatedAt: now,
231
- });
232
- }
233
- });
234
-
235
- sendJson(res, 201, {
236
- user: { id: userId, email },
237
- organization: { id: organizationId },
238
- claim: claimId ? { id: claimId } : null,
239
- });
240
- }
241
- );
242
-
243
- infra.express.post("/__auth-e2e/organizations", async (req: Request, res: Response) => {
244
- const body = req.body as { count?: unknown; prefix?: unknown };
245
- const count = typeof body.count === "number" ? Math.trunc(body.count) : 12;
246
- const prefix = normalizedText(body.prefix, "Harness Organization");
247
-
248
- if (count < 1 || count > 50) {
249
- sendJson(res, 400, { error: "count must be between 1 and 50" });
250
- return;
251
- }
252
-
253
- const now = Date.now();
254
- const slugPrefix = slugPart(prefix) || "organization";
255
- const organizations = Array.from({ length: count }, (_, index) => {
256
- const id = uuidv4();
257
- const displayIndex = String(index + 1).padStart(2, "0");
258
-
259
- return {
260
- id,
261
- name: `${prefix} ${displayIndex}`,
262
- slug: `${slugPrefix}-${displayIndex}-${id.slice(0, 8)}`,
263
- type: "organization",
264
- createdAt: new Date(now + index),
265
- };
266
- });
267
-
268
- await db.orm.insert(db.schema.organizations).values(organizations);
269
-
270
- sendJson(res, 201, {
271
- organizations: organizations.map((organization) => ({
272
- id: organization.id,
273
- name: organization.name,
274
- slug: organization.slug,
275
- })),
276
- });
277
- });
278
- }
279
- }
1
+ import {
2
+ BaseModule,
3
+ type ModuleExpressContext,
4
+ type TableMap,
5
+ } from "@m5kdev/backend/modules/base/base.module";
6
+ import { and, desc, eq, isNotNull } from "drizzle-orm";
7
+ import type { Request, Response } from "express";
8
+ import { v4 as uuidv4 } from "uuid";
9
+
10
+ type TestHarnessTables = TableMap;
11
+
12
+ function normalizedEmail(value: unknown) {
13
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
14
+ }
15
+
16
+ function normalizedText(value: unknown, fallback: string) {
17
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
18
+ }
19
+
20
+ function slugPart(value: string) {
21
+ return value
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-+|-+$/g, "")
26
+ .slice(0, 48);
27
+ }
28
+
29
+ function sendJson(res: Response, status: number, body: unknown) {
30
+ res
31
+ .status(status)
32
+ .type("application/json")
33
+ .send(JSON.stringify(body, null, 2));
34
+ }
35
+
36
+ export class TestHarnessModule extends BaseModule<
37
+ Record<string, never>,
38
+ TestHarnessTables,
39
+ Record<string, never>,
40
+ Record<string, never>,
41
+ never
42
+ > {
43
+ readonly id = "auth-e2e-test-harness";
44
+ override readonly dbDependsOn = ["auth"] as const;
45
+
46
+ override express({ db, infra }: ModuleExpressContext<Record<string, never>>) {
47
+ infra.express.get("/__auth-e2e/user.json", async (req: Request, res: Response) => {
48
+ const email = normalizedEmail(req.query.email);
49
+ if (!email) {
50
+ sendJson(res, 400, { error: "email is required" });
51
+ return;
52
+ }
53
+
54
+ const [user] = await db.orm
55
+ .select({
56
+ id: db.schema.users.id,
57
+ name: db.schema.users.name,
58
+ email: db.schema.users.email,
59
+ emailVerified: db.schema.users.emailVerified,
60
+ role: db.schema.users.role,
61
+ banned: db.schema.users.banned,
62
+ })
63
+ .from(db.schema.users)
64
+ .where(eq(db.schema.users.email, email))
65
+ .limit(1);
66
+
67
+ if (!user) {
68
+ sendJson(res, 404, { error: "user not found" });
69
+ return;
70
+ }
71
+
72
+ const organizations = await db.orm
73
+ .select({
74
+ id: db.schema.organizations.id,
75
+ name: db.schema.organizations.name,
76
+ slug: db.schema.organizations.slug,
77
+ type: db.schema.organizations.type,
78
+ parentId: db.schema.organizations.parentId,
79
+ role: db.schema.members.role,
80
+ })
81
+ .from(db.schema.members)
82
+ .innerJoin(
83
+ db.schema.organizations,
84
+ eq(db.schema.members.organizationId, db.schema.organizations.id)
85
+ )
86
+ .where(eq(db.schema.members.userId, user.id));
87
+
88
+ const [credentialAccount] = await db.orm
89
+ .select({ id: db.schema.accounts.id })
90
+ .from(db.schema.accounts)
91
+ .where(
92
+ and(
93
+ eq(db.schema.accounts.userId, user.id),
94
+ eq(db.schema.accounts.providerId, "credential"),
95
+ isNotNull(db.schema.accounts.password)
96
+ )
97
+ )
98
+ .limit(1);
99
+
100
+ const accountClaims = await db.orm
101
+ .select({
102
+ id: db.schema.waitlist.id,
103
+ status: db.schema.waitlist.status,
104
+ claimUserId: db.schema.waitlist.claimUserId,
105
+ claimedEmail: db.schema.waitlist.claimedEmail,
106
+ claimedAt: db.schema.waitlist.claimedAt,
107
+ expiresAt: db.schema.waitlist.expiresAt,
108
+ })
109
+ .from(db.schema.waitlist)
110
+ .where(
111
+ and(
112
+ eq(db.schema.waitlist.type, "ACCOUNT_CLAIM"),
113
+ eq(db.schema.waitlist.claimUserId, user.id)
114
+ )
115
+ )
116
+ .orderBy(desc(db.schema.waitlist.createdAt));
117
+
118
+ const [latestSession] = await db.orm
119
+ .select({
120
+ id: db.schema.sessions.id,
121
+ userId: db.schema.sessions.userId,
122
+ activeOrganizationId: db.schema.sessions.activeOrganizationId,
123
+ activeOrganizationRole: db.schema.sessions.activeOrganizationRole,
124
+ activeOrganizationMemberId: db.schema.sessions.activeOrganizationMemberId,
125
+ activeOrganizationType: db.schema.sessions.activeOrganizationType,
126
+ activeTeamId: db.schema.sessions.activeTeamId,
127
+ impersonatedBy: db.schema.sessions.impersonatedBy,
128
+ createdAt: db.schema.sessions.createdAt,
129
+ })
130
+ .from(db.schema.sessions)
131
+ .where(eq(db.schema.sessions.userId, user.id))
132
+ .orderBy(desc(db.schema.sessions.createdAt))
133
+ .limit(1);
134
+
135
+ sendJson(res, 200, {
136
+ user,
137
+ organizations,
138
+ credentialAccount: Boolean(credentialAccount),
139
+ accountClaims,
140
+ pendingAccountClaims: accountClaims.filter((claim) => claim.status === "INVITED").length,
141
+ acceptedAccountClaims: accountClaims.filter((claim) => claim.status === "ACCEPTED").length,
142
+ latestSession: latestSession ?? null,
143
+ });
144
+ });
145
+
146
+ infra.express.post(
147
+ "/__auth-e2e/provisioned-claim-user",
148
+ async (req: Request, res: Response) => {
149
+ const email = normalizedEmail((req.body as { email?: unknown }).email);
150
+ const expiresInHours =
151
+ typeof (req.body as { expiresInHours?: unknown }).expiresInHours === "number"
152
+ ? (req.body as { expiresInHours: number }).expiresInHours
153
+ : 24 * 14;
154
+ const createClaim = (req.body as { createClaim?: unknown }).createClaim === true;
155
+
156
+ if (!email.endsWith("@provisioned.auth-e2e.local")) {
157
+ sendJson(res, 400, { error: "email must use the provisioned auth-e2e domain" });
158
+ return;
159
+ }
160
+
161
+ const [existing] = await db.orm
162
+ .select({ id: db.schema.users.id })
163
+ .from(db.schema.users)
164
+ .where(eq(db.schema.users.email, email))
165
+ .limit(1);
166
+
167
+ if (existing) {
168
+ sendJson(res, 409, { error: "user already exists" });
169
+ return;
170
+ }
171
+
172
+ const now = new Date();
173
+ const userId = uuidv4();
174
+ const organizationId = uuidv4();
175
+ const teamId = uuidv4();
176
+ const claimId = createClaim ? uuidv4() : null;
177
+
178
+ await db.orm.transaction(async (tx) => {
179
+ await tx.insert(db.schema.users).values({
180
+ id: userId,
181
+ name: `Provisioned ${email}`,
182
+ email,
183
+ emailVerified: false,
184
+ role: "user",
185
+ createdAt: now,
186
+ updatedAt: now,
187
+ });
188
+
189
+ await tx.insert(db.schema.organizations).values({
190
+ id: organizationId,
191
+ name: `Provisioned ${userId.slice(0, 8)}`,
192
+ slug: `provisioned-${userId.slice(0, 8)}`,
193
+ type: "organization",
194
+ createdAt: now,
195
+ });
196
+
197
+ await tx.insert(db.schema.members).values({
198
+ id: uuidv4(),
199
+ organizationId,
200
+ userId,
201
+ role: "owner",
202
+ createdAt: now,
203
+ });
204
+
205
+ await tx.insert(db.schema.teams).values({
206
+ id: teamId,
207
+ name: "Editorial",
208
+ organizationId,
209
+ createdAt: now,
210
+ updatedAt: now,
211
+ });
212
+
213
+ await tx.insert(db.schema.teamMembers).values({
214
+ id: uuidv4(),
215
+ teamId,
216
+ userId,
217
+ role: "owner",
218
+ createdAt: now,
219
+ });
220
+
221
+ if (claimId) {
222
+ await tx.insert(db.schema.waitlist).values({
223
+ id: claimId,
224
+ type: "ACCOUNT_CLAIM",
225
+ status: "INVITED",
226
+ claimUserId: userId,
227
+ code: uuidv4(),
228
+ expiresAt: new Date(Date.now() + expiresInHours * 60 * 60 * 1000),
229
+ createdAt: now,
230
+ updatedAt: now,
231
+ });
232
+ }
233
+ });
234
+
235
+ sendJson(res, 201, {
236
+ user: { id: userId, email },
237
+ organization: { id: organizationId },
238
+ claim: claimId ? { id: claimId } : null,
239
+ });
240
+ }
241
+ );
242
+
243
+ infra.express.post("/__auth-e2e/organizations", async (req: Request, res: Response) => {
244
+ const body = req.body as { count?: unknown; prefix?: unknown };
245
+ const count = typeof body.count === "number" ? Math.trunc(body.count) : 12;
246
+ const prefix = normalizedText(body.prefix, "Harness Organization");
247
+
248
+ if (count < 1 || count > 50) {
249
+ sendJson(res, 400, { error: "count must be between 1 and 50" });
250
+ return;
251
+ }
252
+
253
+ const now = Date.now();
254
+ const slugPrefix = slugPart(prefix) || "organization";
255
+ const organizations = Array.from({ length: count }, (_, index) => {
256
+ const id = uuidv4();
257
+ const displayIndex = String(index + 1).padStart(2, "0");
258
+
259
+ return {
260
+ id,
261
+ name: `${prefix} ${displayIndex}`,
262
+ slug: `${slugPrefix}-${displayIndex}-${id.slice(0, 8)}`,
263
+ type: "organization",
264
+ createdAt: new Date(now + index),
265
+ };
266
+ });
267
+
268
+ await db.orm.insert(db.schema.organizations).values(organizations);
269
+
270
+ sendJson(res, 201, {
271
+ organizations: organizations.map((organization) => ({
272
+ id: organization.id,
273
+ name: organization.name,
274
+ slug: organization.slug,
275
+ })),
276
+ });
277
+ });
278
+ }
279
+ }