@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.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 (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/assets/auth.sqlite.schema.ts +479 -0
  4. package/assets/bun-sqlite.test.ts +46 -0
  5. package/assets/d1.test.ts +41 -0
  6. package/assets/drizzle.config.ts +2 -0
  7. package/assets/generate-auth-schema.mjs +25 -0
  8. package/assets/platform.sqlite.schema.ts +46 -0
  9. package/assets/polar-checkout.ts +10 -0
  10. package/assets/polar-route.ts +19 -0
  11. package/assets/polar.test.ts +30 -0
  12. package/assets/polar.ts +12 -0
  13. package/assets/purchase.bun-sqlite.ts +38 -0
  14. package/assets/purchase.sqlite.ts +39 -0
  15. package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
  16. package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
  17. package/assets/sqlite-migrations/meta/_journal.json +13 -0
  18. package/assets/turso.test.ts +40 -0
  19. package/package.json +44 -5
  20. package/src/adapters/database.mjs +448 -0
  21. package/src/adapters/deployment.mjs +101 -0
  22. package/src/adapters/email.mjs +75 -0
  23. package/src/adapters/payments.mjs +270 -0
  24. package/src/catalog.mjs +128 -0
  25. package/src/cli.mjs +294 -0
  26. package/src/generate.mjs +183 -0
  27. package/src/template.mjs +93 -0
  28. package/template/.env.docker +16 -0
  29. package/template/.env.example +67 -0
  30. package/template/DESIGN.md +58 -0
  31. package/template/LICENSE +21 -0
  32. package/template/biome.json +46 -0
  33. package/template/docker-compose.yml +47 -0
  34. package/template/docs/project-guide.md +61 -0
  35. package/template/drizzle/0000_create-app-role.sql +54 -0
  36. package/template/drizzle/0001_add-rls-context.sql +21 -0
  37. package/template/drizzle/0002_platform_tables.sql +320 -0
  38. package/template/drizzle/meta/0002_snapshot.json +2125 -0
  39. package/template/drizzle/meta/_journal.json +27 -0
  40. package/template/drizzle.config.ts +25 -0
  41. package/template/e2e/authenticated-ui.spec.ts +40 -0
  42. package/template/e2e/headless-design.spec.ts +66 -0
  43. package/template/e2e/navigation-loading.spec.ts +70 -0
  44. package/template/e2e/theme-responsive.spec.ts +260 -0
  45. package/template/gitignore +24 -0
  46. package/template/messages/de.json +212 -0
  47. package/template/messages/en.json +212 -0
  48. package/template/package.json +101 -0
  49. package/template/paraglide.config.ts +49 -0
  50. package/template/playwright.config.ts +58 -0
  51. package/template/project.inlang/settings.json +12 -0
  52. package/template/public/drizzle.svg +1 -0
  53. package/template/public/favicon.ico +0 -0
  54. package/template/public/logo192.png +0 -0
  55. package/template/public/logo512.png +0 -0
  56. package/template/public/manifest.json +25 -0
  57. package/template/public/robots.txt +3 -0
  58. package/template/scripts/check-client-bundle.ts +150 -0
  59. package/template/scripts/check-server-fns.ts +290 -0
  60. package/template/scripts/check-sql-raw.ts +50 -0
  61. package/template/scripts/compile-paraglide.ts +12 -0
  62. package/template/scripts/postgres/README.md +76 -0
  63. package/template/scripts/postgres/create-app-role.sh +60 -0
  64. package/template/scripts/seed-local.ts +331 -0
  65. package/template/scripts/validate-prod-env.ts +53 -0
  66. package/template/src/__tests__/README.md +9 -0
  67. package/template/src/__tests__/account-security.test.ts +664 -0
  68. package/template/src/__tests__/admin-http-surface.test.ts +311 -0
  69. package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
  70. package/template/src/__tests__/auth-harness.ts +104 -0
  71. package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
  72. package/template/src/__tests__/auth-redirect.test.ts +120 -0
  73. package/template/src/__tests__/body-limit.test.ts +47 -0
  74. package/template/src/__tests__/creem.test.ts +62 -0
  75. package/template/src/__tests__/demo-checkout.test.ts +141 -0
  76. package/template/src/__tests__/email-locale.test.ts +110 -0
  77. package/template/src/__tests__/global-setup.ts +80 -0
  78. package/template/src/__tests__/input-rules.test.ts +0 -0
  79. package/template/src/__tests__/message-parity.test.ts +38 -0
  80. package/template/src/__tests__/navigation-loading.test.tsx +252 -0
  81. package/template/src/__tests__/nowpayments.test.ts +106 -0
  82. package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
  83. package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
  84. package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
  85. package/template/src/__tests__/rls-policy.test.ts +120 -0
  86. package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
  87. package/template/src/__tests__/search-params-oauth.test.ts +57 -0
  88. package/template/src/__tests__/security-headers.test.ts +12 -0
  89. package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
  90. package/template/src/__tests__/stripe.test.ts +84 -0
  91. package/template/src/__tests__/test-db.ts +32 -0
  92. package/template/src/__tests__/test-rls-db.ts +23 -0
  93. package/template/src/__tests__/user-update-guard.test.ts +109 -0
  94. package/template/src/components/LocaleSwitcher.tsx +79 -0
  95. package/template/src/components/ThemeToggle.tsx +47 -0
  96. package/template/src/components/auth/social.tsx +90 -0
  97. package/template/src/components/auth/turnstile.tsx +67 -0
  98. package/template/src/components/root/navigation-progress.tsx +32 -0
  99. package/template/src/components/root/not-found.tsx +20 -0
  100. package/template/src/components/root/pending.tsx +9 -0
  101. package/template/src/components/root/root-document.tsx +37 -0
  102. package/template/src/components/root/root-error.tsx +34 -0
  103. package/template/src/components/shells.tsx +201 -0
  104. package/template/src/components/ui/styles.ts +9 -0
  105. package/template/src/db/auth.schema.ts +455 -0
  106. package/template/src/db/helper.ts +38 -0
  107. package/template/src/db/index.ts +15 -0
  108. package/template/src/db/platform.schema.ts +122 -0
  109. package/template/src/db/schema.ts +2 -0
  110. package/template/src/env.ts +65 -0
  111. package/template/src/hooks/use-theme.ts +32 -0
  112. package/template/src/integrations/better-auth/header-user.tsx +36 -0
  113. package/template/src/integrations/better-auth/session-query.ts +11 -0
  114. package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
  115. package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
  116. package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
  117. package/template/src/integrations/react-email/theme.ts +75 -0
  118. package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
  119. package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
  120. package/template/src/lib/auth-client.ts +27 -0
  121. package/template/src/lib/auth-paths.ts +94 -0
  122. package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
  123. package/template/src/lib/auth-plugins/field-rules.ts +84 -0
  124. package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
  125. package/template/src/lib/auth-timing.ts +9 -0
  126. package/template/src/lib/auth.ts +279 -0
  127. package/template/src/lib/captcha.ts +11 -0
  128. package/template/src/lib/email.tsx +162 -0
  129. package/template/src/lib/github.ts +18 -0
  130. package/template/src/lib/locale-cookie.ts +12 -0
  131. package/template/src/lib/localized-pages.ts +25 -0
  132. package/template/src/lib/oauth-metadata.ts +19 -0
  133. package/template/src/lib/permissions.ts +29 -0
  134. package/template/src/lib/security-headers.ts +20 -0
  135. package/template/src/lib/site.ts +59 -0
  136. package/template/src/lib/theme.ts +42 -0
  137. package/template/src/lib/user-update-guard.ts +35 -0
  138. package/template/src/middlewares/admin.ts +46 -0
  139. package/template/src/middlewares/auth-rate-limit.ts +37 -0
  140. package/template/src/middlewares/better-auth.ts +15 -0
  141. package/template/src/middlewares/body-limit.ts +62 -0
  142. package/template/src/middlewares/database.ts +13 -0
  143. package/template/src/middlewares/protected.ts +45 -0
  144. package/template/src/routeTree.gen.ts +536 -0
  145. package/template/src/router.tsx +53 -0
  146. package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
  147. package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
  148. package/template/src/routes/__root.tsx +175 -0
  149. package/template/src/routes/admin/audit.tsx +74 -0
  150. package/template/src/routes/admin/index.tsx +7 -0
  151. package/template/src/routes/admin/oauth-apps.tsx +164 -0
  152. package/template/src/routes/admin/route.tsx +27 -0
  153. package/template/src/routes/admin/users.tsx +215 -0
  154. package/template/src/routes/api/auth/$.ts +14 -0
  155. package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
  156. package/template/src/routes/api/webhooks/creem.ts +69 -0
  157. package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
  158. package/template/src/routes/api/webhooks/stripe.ts +74 -0
  159. package/template/src/routes/dashboard/account.tsx +430 -0
  160. package/template/src/routes/dashboard/index.tsx +128 -0
  161. package/template/src/routes/dashboard/route.tsx +17 -0
  162. package/template/src/routes/forgot-password.tsx +65 -0
  163. package/template/src/routes/index.tsx +85 -0
  164. package/template/src/routes/oauth/consent.tsx +77 -0
  165. package/template/src/routes/reset-password.tsx +78 -0
  166. package/template/src/routes/sign-in.tsx +174 -0
  167. package/template/src/routes/sign-up.tsx +135 -0
  168. package/template/src/routes/two-factor.tsx +74 -0
  169. package/template/src/server/README.md +3 -0
  170. package/template/src/server/account-security.ts +163 -0
  171. package/template/src/server/admin-audit.ts +37 -0
  172. package/template/src/server/admin-users.ts +112 -0
  173. package/template/src/server/auth.ts +44 -0
  174. package/template/src/server/demo-checkout.ts +67 -0
  175. package/template/src/server/oauth-clients.ts +83 -0
  176. package/template/src/server/payment-toggles.ts +39 -0
  177. package/template/src/server.ts +20 -0
  178. package/template/src/services/README.md +3 -0
  179. package/template/src/services/account-security.ts +893 -0
  180. package/template/src/services/admin-security.ts +164 -0
  181. package/template/src/services/admin-users.ts +181 -0
  182. package/template/src/services/auth-rate-limit.ts +32 -0
  183. package/template/src/services/creem.ts +83 -0
  184. package/template/src/services/demo-checkout.ts +100 -0
  185. package/template/src/services/nowpayments.ts +154 -0
  186. package/template/src/services/oauth-client.ts +85 -0
  187. package/template/src/services/oauth-clients.ts +15 -0
  188. package/template/src/services/payment-providers.ts +157 -0
  189. package/template/src/services/payment-toggles.ts +48 -0
  190. package/template/src/services/purchase.ts +233 -0
  191. package/template/src/services/stripe.ts +106 -0
  192. package/template/src/start.ts +35 -0
  193. package/template/src/styles/layout.css +93 -0
  194. package/template/src/styles/theme.css +84 -0
  195. package/template/src/styles/ui.css +233 -0
  196. package/template/src/styles.css +90 -0
  197. package/template/src/utils/api-error.ts +129 -0
  198. package/template/src/utils/auth-errors.ts +157 -0
  199. package/template/src/utils/auth-redirect.ts +61 -0
  200. package/template/src/utils/body-limit.ts +84 -0
  201. package/template/src/utils/cn.ts +6 -0
  202. package/template/src/utils/input-rules.ts +172 -0
  203. package/template/src/utils/locale-paths.ts +18 -0
  204. package/template/src/utils/same-page-locale-href.ts +20 -0
  205. package/template/src/utils/search-params.ts +71 -0
  206. package/template/tsconfig.json +30 -0
  207. package/template/tsr.config.json +6 -0
  208. package/template/vite.config.ts +24 -0
  209. package/template/vitest.config.ts +26 -0
  210. package/template/worker-configuration.d.ts +14660 -0
  211. package/template/wrangler.jsonc +39 -0
@@ -0,0 +1,120 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { beforeAll, beforeEach, describe, expect, it } from "vitest";
4
+ import {
5
+ afterAuthTarget,
6
+ goToSignIn,
7
+ localizedAuthPath,
8
+ safeRedirectPath,
9
+ } from "@/utils/auth-redirect";
10
+
11
+ beforeAll(async () => {
12
+ if (
13
+ typeof (globalThis as { URLPattern?: unknown }).URLPattern === "undefined"
14
+ ) {
15
+ const mod = (await import("@inlang/paraglide-js/urlpattern-polyfill")) as {
16
+ URLPattern?: unknown;
17
+ default?: unknown;
18
+ };
19
+ (globalThis as { URLPattern?: unknown }).URLPattern =
20
+ mod.URLPattern ?? mod.default;
21
+ }
22
+ });
23
+
24
+ beforeEach(() => {
25
+ window.history.replaceState({}, "", "/sign-in");
26
+ });
27
+
28
+ describe("safeRedirectPath", () => {
29
+ it("defaults to the user dashboard when redirect is missing", () => {
30
+ expect(safeRedirectPath(null)).toBe("/dashboard");
31
+ });
32
+
33
+ it("accepts same-origin relative paths with query and hash", () => {
34
+ expect(safeRedirectPath("/dashboard/account?tab=security#x")).toBe(
35
+ "/dashboard/account?tab=security#x",
36
+ );
37
+ });
38
+
39
+ it("rejects open redirects and protocol-relative URLs", () => {
40
+ expect(safeRedirectPath("//evil.example")).toBe("/dashboard");
41
+ expect(safeRedirectPath("https://evil.example/phish")).toBe("/dashboard");
42
+ expect(safeRedirectPath("\\evil")).toBe("/dashboard");
43
+ });
44
+ });
45
+
46
+ describe("afterAuthTarget", () => {
47
+ it("resumes OAuth authorize when client_id is present", () => {
48
+ window.history.replaceState(
49
+ {},
50
+ "",
51
+ "/sign-in?client_id=abc&state=1&ba_param=client_id",
52
+ );
53
+ expect(afterAuthTarget()).toBe(
54
+ "/api/auth/oauth2/authorize?client_id=abc&state=1&ba_param=client_id",
55
+ );
56
+ });
57
+
58
+ it("honors a safe redirect query", () => {
59
+ window.history.replaceState(
60
+ {},
61
+ "",
62
+ "/sign-in?redirect=%2Fdashboard%2Faccount",
63
+ );
64
+ expect(afterAuthTarget()).toBe("/dashboard/account");
65
+ });
66
+
67
+ it("defaults to the dashboard with no redirect", () => {
68
+ window.history.replaceState({}, "", "/sign-in");
69
+ expect(afterAuthTarget()).toBe("/dashboard");
70
+ });
71
+ });
72
+
73
+ describe("localizedAuthPath + goToSignIn", () => {
74
+ it("keeps English auth paths bare", () => {
75
+ expect(localizedAuthPath("/sign-in")).toBe("/sign-in");
76
+ expect(localizedAuthPath("/two-factor")).toBe("/two-factor");
77
+ });
78
+
79
+ it("prefixes German auth paths", async () => {
80
+ const { overwriteGetLocale } = await import("@/paraglide/runtime");
81
+ overwriteGetLocale(() => "de");
82
+ try {
83
+ expect(localizedAuthPath("/sign-in")).toBe("/de/sign-in");
84
+ expect(localizedAuthPath("/two-factor")).toBe("/de/two-factor");
85
+ } finally {
86
+ overwriteGetLocale(() => "en");
87
+ }
88
+ });
89
+
90
+ it("sends expired sessions to the locale-aware sign-in URL", async () => {
91
+ const { overwriteGetLocale } = await import("@/paraglide/runtime");
92
+ overwriteGetLocale(() => "de");
93
+ const hrefs: string[] = [];
94
+ const original = window.location;
95
+ Object.defineProperty(window, "location", {
96
+ configurable: true,
97
+ value: {
98
+ ...original,
99
+ get href() {
100
+ return hrefs[hrefs.length - 1] ?? original.href;
101
+ },
102
+ set href(v: string) {
103
+ hrefs.push(v);
104
+ },
105
+ },
106
+ });
107
+ try {
108
+ goToSignIn("/de/dashboard/account?tab=security");
109
+ expect(hrefs.at(-1)).toBe(
110
+ `/de/sign-in?redirect=${encodeURIComponent("/de/dashboard/account?tab=security")}`,
111
+ );
112
+ } finally {
113
+ Object.defineProperty(window, "location", {
114
+ configurable: true,
115
+ value: original,
116
+ });
117
+ overwriteGetLocale(() => "en");
118
+ }
119
+ });
120
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { BodyTooLargeError, readJsonCapped } from "@/utils/body-limit";
3
+
4
+ function requestFromChunks(chunks: Uint8Array[]): Request {
5
+ let next = 0;
6
+ return new Request("http://local/body", {
7
+ method: "POST",
8
+ body: new ReadableStream<Uint8Array>({
9
+ pull(controller) {
10
+ const chunk = chunks[next++];
11
+ if (chunk) controller.enqueue(chunk);
12
+ else controller.close();
13
+ },
14
+ }),
15
+ duplex: "half",
16
+ } as RequestInit);
17
+ }
18
+
19
+ describe("readJsonCapped", () => {
20
+ it("decodes JSON split across UTF-8 byte boundaries", async () => {
21
+ const bytes = new TextEncoder().encode(
22
+ JSON.stringify({ message: "你好,Starter" }),
23
+ );
24
+ const value = await readJsonCapped(
25
+ requestFromChunks([
26
+ bytes.slice(0, 14),
27
+ bytes.slice(14, 15),
28
+ bytes.slice(15),
29
+ ]),
30
+ { maxBytes: bytes.byteLength, requireJson: false },
31
+ );
32
+
33
+ expect(value).toEqual({ message: "你好,Starter" });
34
+ });
35
+
36
+ it("rejects a chunked body as soon as its cumulative bytes exceed the cap", async () => {
37
+ await expect(
38
+ readJsonCapped(
39
+ requestFromChunks([
40
+ new TextEncoder().encode('{"a":'),
41
+ new TextEncoder().encode('"too-large"}'),
42
+ ]),
43
+ { maxBytes: 8, requireJson: false },
44
+ ),
45
+ ).rejects.toBeInstanceOf(BodyTooLargeError);
46
+ });
47
+ });
@@ -0,0 +1,62 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { Database } from "@/db";
4
+ import { handleCreemWebhook, verifyCreemSignature } from "@/services/creem";
5
+
6
+ /** A db that fails the test if the handler touches it. */
7
+ const unusableDb = new Proxy({} as Database, {
8
+ get() {
9
+ throw new Error("handler reached the database on an unverified event");
10
+ },
11
+ });
12
+
13
+ function sign(secret: string, body: string): string {
14
+ return createHmac("sha256", secret).update(body).digest("hex");
15
+ }
16
+
17
+ describe("verifyCreemSignature", () => {
18
+ it("accepts HMAC-SHA256 of the raw body", () => {
19
+ const body = '{"id":"ch_1"}';
20
+ expect(
21
+ verifyCreemSignature("creem_sec", body, sign("creem_sec", body)),
22
+ ).toBe(true);
23
+ });
24
+
25
+ it("rejects a bad or missing signature", () => {
26
+ const body = '{"id":"ch_1"}';
27
+ expect(verifyCreemSignature("creem_sec", body, "deadbeef")).toBe(false);
28
+ expect(verifyCreemSignature("creem_sec", body, null)).toBe(false);
29
+ });
30
+ });
31
+
32
+ describe("handleCreemWebhook", () => {
33
+ it("refuses an unverified event without touching the database", async () => {
34
+ const body = '{"id":"ch_1"}';
35
+ const result = await handleCreemWebhook(unusableDb, {
36
+ rawBody: body,
37
+ signature: "deadbeef",
38
+ secret: "creem_sec",
39
+ });
40
+ expect(result).toBe("invalid_signature");
41
+ });
42
+
43
+ it("refuses a signed body that is not JSON", async () => {
44
+ const body = "{not json";
45
+ const result = await handleCreemWebhook(unusableDb, {
46
+ rawBody: body,
47
+ signature: sign("creem_sec", body),
48
+ secret: "creem_sec",
49
+ });
50
+ expect(result).toBe("invalid_payload");
51
+ });
52
+
53
+ it("ignores an event that carries no checkout id", async () => {
54
+ const body = '{"eventType":"checkout.updated"}';
55
+ const result = await handleCreemWebhook(unusableDb, {
56
+ rawBody: body,
57
+ signature: sign("creem_sec", body),
58
+ secret: "creem_sec",
59
+ });
60
+ expect(result).toBe("noop");
61
+ });
62
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Purchase seam extension: demo checkout start is gated by payment toggles
3
+ * and provider env readiness. Creates a pending Purchase under service scope
4
+ * only (user RLS cannot insert purchases).
5
+ */
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import {
8
+ DEMO_CHECKOUT_AMOUNT_MINOR,
9
+ type DemoCheckoutPort,
10
+ resolveCheckoutGate,
11
+ startDemoCheckout,
12
+ } from "@/services/demo-checkout";
13
+ import type { PurchaseProvider } from "@/services/purchase";
14
+
15
+ describe("resolveCheckoutGate", () => {
16
+ const configured = {
17
+ stripe: true,
18
+ creem: true,
19
+ nowpayments: true,
20
+ };
21
+
22
+ it("allows a rail that is toggled on and configured", () => {
23
+ expect(
24
+ resolveCheckoutGate({
25
+ provider: "stripe",
26
+ toggles: { stripe: true, creem: true, nowpayments: true },
27
+ configured,
28
+ }),
29
+ ).toEqual({ ok: true });
30
+ });
31
+
32
+ it("refuses a rail that is toggled off even when configured", () => {
33
+ expect(
34
+ resolveCheckoutGate({
35
+ provider: "stripe",
36
+ toggles: { stripe: false, creem: true, nowpayments: true },
37
+ configured,
38
+ }),
39
+ ).toEqual({ ok: false, code: "disabled" });
40
+ });
41
+
42
+ it("refuses a rail that lacks credentials even when toggled on", () => {
43
+ expect(
44
+ resolveCheckoutGate({
45
+ provider: "creem",
46
+ toggles: { stripe: true, creem: true, nowpayments: true },
47
+ configured: { ...configured, creem: false },
48
+ }),
49
+ ).toEqual({ ok: false, code: "not_configured" });
50
+ });
51
+ });
52
+
53
+ describe("startDemoCheckout", () => {
54
+ function makePort(
55
+ overrides: Partial<DemoCheckoutPort> = {},
56
+ ): DemoCheckoutPort {
57
+ return {
58
+ getToggles: async () => ({
59
+ stripe: true,
60
+ creem: true,
61
+ nowpayments: true,
62
+ }),
63
+ isConfigured: (p: PurchaseProvider) => p === "stripe",
64
+ createHostedSession: async () => ({
65
+ externalId: "cs_test_1",
66
+ url: "https://checkout.example/session",
67
+ }),
68
+ createPendingPurchase: async () => ({ purchaseId: "pur_1" }),
69
+ ...overrides,
70
+ };
71
+ }
72
+
73
+ it("creates a pending purchase and returns the hosted URL", async () => {
74
+ const createPending = vi.fn(async () => ({ purchaseId: "pur_1" }));
75
+ const createHosted = vi.fn(async () => ({
76
+ externalId: "cs_test_1",
77
+ url: "https://checkout.example/session",
78
+ }));
79
+ const result = await startDemoCheckout(
80
+ makePort({
81
+ createPendingPurchase: createPending,
82
+ createHostedSession: createHosted,
83
+ }),
84
+ { userId: "u1", userEmail: "u1@example.test", provider: "stripe" },
85
+ );
86
+
87
+ expect(result).toEqual({
88
+ ok: true,
89
+ url: "https://checkout.example/session",
90
+ purchaseId: "pur_1",
91
+ externalId: "cs_test_1",
92
+ amount: DEMO_CHECKOUT_AMOUNT_MINOR,
93
+ });
94
+ expect(createHosted).toHaveBeenCalledWith({
95
+ provider: "stripe",
96
+ userId: "u1",
97
+ // Providers bill an address, not an id — a session created without it
98
+ // is the difference between a receipt and a silent orphan charge.
99
+ userEmail: "u1@example.test",
100
+ amount: DEMO_CHECKOUT_AMOUNT_MINOR,
101
+ currency: "usd",
102
+ });
103
+ expect(createPending).toHaveBeenCalledWith({
104
+ userId: "u1",
105
+ provider: "stripe",
106
+ externalId: "cs_test_1",
107
+ amount: DEMO_CHECKOUT_AMOUNT_MINOR,
108
+ currency: "usd",
109
+ });
110
+ });
111
+
112
+ it("does not create a purchase when the rail is disabled", async () => {
113
+ const createPending = vi.fn();
114
+ const result = await startDemoCheckout(
115
+ makePort({
116
+ getToggles: async () => ({
117
+ stripe: false,
118
+ creem: true,
119
+ nowpayments: true,
120
+ }),
121
+ createPendingPurchase: createPending,
122
+ }),
123
+ { userId: "u1", userEmail: "u1@example.test", provider: "stripe" },
124
+ );
125
+ expect(result).toEqual({ ok: false, code: "disabled" });
126
+ expect(createPending).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it("does not create a purchase when credentials are missing", async () => {
130
+ const createPending = vi.fn();
131
+ const result = await startDemoCheckout(
132
+ makePort({
133
+ isConfigured: () => false,
134
+ createPendingPurchase: createPending,
135
+ }),
136
+ { userId: "u1", userEmail: "u1@example.test", provider: "stripe" },
137
+ );
138
+ expect(result).toEqual({ ok: false, code: "not_configured" });
139
+ expect(createPending).not.toHaveBeenCalled();
140
+ });
141
+ });
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Transactional email is rendered in the recipient's language, not the
3
+ * sender's (CONTEXT.md: emails honour `user.locale`).
4
+ *
5
+ * The trap this pins: Paraglide messages read an *ambient* locale by default,
6
+ * and an email is rendered inside whatever request triggered it — a webhook,
7
+ * a cron, or another person's session. So the locale has to arrive as an
8
+ * argument, and a test that only calls the message function would pass while
9
+ * every real send went out in the sender's language.
10
+ */
11
+ import { render } from "@react-email/render";
12
+ import { describe, expect, it } from "vitest";
13
+ import { PasswordOtpEmail } from "@/integrations/react-email/PasswordOtpEmail";
14
+ import { ResetPasswordEmail } from "@/integrations/react-email/ResetPasswordEmail";
15
+ import { VerifyEmail } from "@/integrations/react-email/VerifyEmail";
16
+ import { mailLocale } from "@/lib/email";
17
+
18
+ describe("mailLocale", () => {
19
+ it("keeps a locale the Starter has a catalog for", () => {
20
+ expect(mailLocale("de")).toBe("de");
21
+ expect(mailLocale("en")).toBe("en");
22
+ });
23
+
24
+ // An account that never chose a language must fall back to the base locale
25
+ // rather than to whatever the triggering request happened to be in.
26
+ it("falls back to the base locale, never to ambient state", () => {
27
+ expect(mailLocale(null)).toBe("en");
28
+ expect(mailLocale(undefined)).toBe("en");
29
+ expect(mailLocale("fr")).toBe("en");
30
+ });
31
+ });
32
+
33
+ describe("verification email", () => {
34
+ it("renders German for a German account", async () => {
35
+ const html = await render(
36
+ VerifyEmail({
37
+ url: "https://example.test/verify",
38
+ name: "Ada",
39
+ brandName: "Starter",
40
+ locale: "de",
41
+ }),
42
+ );
43
+ expect(html).toContain("Bestätige deine E-Mail-Adresse");
44
+ expect(html).toContain('lang="de"');
45
+ expect(html).not.toContain("Confirm your email");
46
+ });
47
+
48
+ it("renders English for an account with no stored locale", async () => {
49
+ const html = await render(
50
+ VerifyEmail({
51
+ url: "https://example.test/verify",
52
+ brandName: "Starter",
53
+ locale: mailLocale(null),
54
+ }),
55
+ );
56
+ expect(html).toContain("Confirm your email");
57
+ });
58
+ });
59
+
60
+ describe("password reset email", () => {
61
+ it("renders German for a German account", async () => {
62
+ const html = await render(
63
+ ResetPasswordEmail({
64
+ url: "https://example.test/reset",
65
+ name: "Ada",
66
+ brandName: "Starter",
67
+ locale: "de",
68
+ }),
69
+ );
70
+ expect(html).toContain("Passwort zurücksetzen");
71
+ expect(html).not.toContain("Reset your password");
72
+ });
73
+ });
74
+
75
+ describe("one-time code email", () => {
76
+ it("renders German, and distinguishes reset from sign-in", async () => {
77
+ const reset = await render(
78
+ PasswordOtpEmail({
79
+ otp: "123456",
80
+ brandName: "Starter",
81
+ purpose: "forget-password",
82
+ locale: "de",
83
+ }),
84
+ );
85
+ const signIn = await render(
86
+ PasswordOtpEmail({
87
+ otp: "123456",
88
+ brandName: "Starter",
89
+ purpose: "sign-in",
90
+ locale: "de",
91
+ }),
92
+ );
93
+
94
+ expect(reset).toContain("Zurücksetzen");
95
+ expect(signIn).toContain("Anmeldecode");
96
+ expect(reset).not.toBe(signIn);
97
+ });
98
+
99
+ it("still carries the code itself", async () => {
100
+ const html = await render(
101
+ PasswordOtpEmail({
102
+ otp: "123456",
103
+ brandName: "Starter",
104
+ purpose: "sign-in",
105
+ locale: "de",
106
+ }),
107
+ );
108
+ expect(html).toContain("123456");
109
+ });
110
+ });
@@ -0,0 +1,80 @@
1
+ import { existsSync } from "node:fs";
2
+ import { PostgreSqlContainer } from "@testcontainers/postgresql";
3
+ import { drizzle } from "drizzle-orm/node-postgres";
4
+ import { migrate } from "drizzle-orm/node-postgres/migrator";
5
+ import { Pool } from "pg";
6
+ import type { TestProject } from "vitest/node";
7
+
8
+ declare module "vitest" {
9
+ interface ProvidedContext {
10
+ pgUri: string;
11
+ pgRlsAppUri: string;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * One Postgres container for the whole run.
17
+ *
18
+ * - `postgres` superuser (`pgUri`): temp-table suites (CREATE TEMP TABLE isolation).
19
+ * - `rls_test` as `starter_app` (`pgRlsAppUri`): real migrations, NOBYPASSRLS —
20
+ * policies and GUC contexts match Hyperdrive production.
21
+ *
22
+ * Migration 0000 references role/database names used by local Hyperdrive
23
+ * (`starter_app`, CONNECT on database `postgres`); the container uses the same
24
+ * username/database so those grants apply.
25
+ */
26
+ export default async function setup(project: TestProject) {
27
+ // Pure unit suites (pipeline, search params, HttpError wire) can run without
28
+ // Docker. Integration suites that call inject("pgUri") still require a real
29
+ // container — they will fail closed if this path is used.
30
+ if (process.env.SKIP_TESTCONTAINERS === "1") {
31
+ project.provide("pgUri", "");
32
+ project.provide("pgRlsAppUri", "");
33
+ return;
34
+ }
35
+
36
+ configureDockerRuntime();
37
+ const container = await new PostgreSqlContainer("postgres:17")
38
+ .withUsername("postgres")
39
+ .withPassword("postgres")
40
+ .withDatabase("postgres")
41
+ .start();
42
+ project.provide("pgUri", container.getConnectionUri());
43
+
44
+ const admin = new Pool({
45
+ connectionString: container.getConnectionUri(),
46
+ max: 1,
47
+ });
48
+ await admin.query("CREATE DATABASE rls_test");
49
+ await admin.end();
50
+
51
+ const rlsAdmin = new Pool({
52
+ connectionString: container
53
+ .getConnectionUri()
54
+ .replace(/\/postgres(\?|$)/, "/rls_test$1"),
55
+ max: 1,
56
+ });
57
+ await migrate(drizzle(rlsAdmin), { migrationsFolder: "./drizzle" });
58
+ // Known test password; migration 0000 bakes the local-dev default.
59
+ await rlsAdmin.query("ALTER ROLE starter_app PASSWORD 'rls_test'");
60
+ // Migration 0000 grants CONNECT on database `postgres` only.
61
+ await rlsAdmin.query("GRANT CONNECT ON DATABASE rls_test TO starter_app");
62
+ await rlsAdmin.end();
63
+
64
+ project.provide(
65
+ "pgRlsAppUri",
66
+ `postgresql://starter_app:rls_test@${container.getHost()}:${container.getMappedPort(5432)}/rls_test`,
67
+ );
68
+
69
+ return async () => {
70
+ await container.stop();
71
+ };
72
+ }
73
+
74
+ function configureDockerRuntime() {
75
+ const orbstackSocket = `${process.env.HOME}/.orbstack/run/docker.sock`;
76
+ if (!process.env.DOCKER_HOST && existsSync(orbstackSocket)) {
77
+ process.env.DOCKER_HOST = `unix://${orbstackSocket}`;
78
+ }
79
+ process.env.TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE ??= "/var/run/docker.sock";
80
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import de from "../../messages/de.json";
3
+ import en from "../../messages/en.json";
4
+
5
+ /**
6
+ * Every locale catalog must carry exactly the same message keys (ADR 0005).
7
+ *
8
+ * A missing key ships as a silent English fallback, which reads as a finished
9
+ * translation to everyone except the reader who speaks the other language.
10
+ * The German *values* are reviewed by hand; this only guards the key set.
11
+ */
12
+ const keysOf = (catalog: Record<string, unknown>) =>
13
+ Object.keys(catalog)
14
+ .filter((key) => key !== "$schema")
15
+ .sort();
16
+
17
+ describe("message catalog parity", () => {
18
+ const enKeys = keysOf(en);
19
+
20
+ it("German covers exactly the same keys as English", () => {
21
+ expect(keysOf(de)).toEqual(enKeys);
22
+ });
23
+
24
+ it("no catalog ships an empty string for a defined key", () => {
25
+ for (const [locale, catalog] of [
26
+ ["en", en],
27
+ ["de", de],
28
+ ] as const) {
29
+ for (const key of keysOf(catalog)) {
30
+ const value = (catalog as Record<string, unknown>)[key];
31
+ expect(
32
+ typeof value === "string" && value.trim().length > 0,
33
+ `${locale}.${key} is empty`,
34
+ ).toBe(true);
35
+ }
36
+ }
37
+ });
38
+ });