@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,129 @@
1
+ import type { z } from "zod";
2
+
3
+ export type APIErrorProp = {
4
+ status: number;
5
+ error: string;
6
+ message: string;
7
+ };
8
+
9
+ export function APIError({ status, error, message }: APIErrorProp) {
10
+ return Response.json(
11
+ {
12
+ status,
13
+ error,
14
+ message,
15
+ },
16
+ { status },
17
+ );
18
+ }
19
+
20
+ /**
21
+ * An API route's answer to a method it does not serve.
22
+ *
23
+ * Without one, a route that declares only POST leaves a browser's GET to fall
24
+ * through to the app router, which answers 200 with the whole SPA shell and no
25
+ * dehydrated router state — so the page loads and then fails to hydrate in the
26
+ * reader's browser. An API path has to answer as an API (ADR 0010).
27
+ *
28
+ * Routes wire this to GET and nothing else, deliberately. GET is the method a
29
+ * browser sends and the only one that produces the rendered body the failure
30
+ * needs; HEAD carries none, and answering OPTIONS here would put a 405 in front
31
+ * of a CORS preflight, which is a different question from this one.
32
+ */
33
+ export function methodNotAllowed(allow: string) {
34
+ const response = APIError({
35
+ status: 405,
36
+ error: "method_not_allowed",
37
+ message: `This endpoint accepts ${allow} only.`,
38
+ });
39
+ response.headers.set("Allow", allow);
40
+ return response;
41
+ }
42
+
43
+ /**
44
+ * A server function's refusal, as an Error rather than a Response.
45
+ *
46
+ * A `Response` returned from server-fn middleware is stamped as a deliberate
47
+ * raw success and handed to the client as the resolved query value — so a 401
48
+ * arrives as *data*, TanStack Query caches it, and a component expecting an
49
+ * array crashes while one expecting an object renders a confident zero.
50
+ * Throwing this instead makes a refusal a rejected query, which nothing can
51
+ * mistake for a result.
52
+ *
53
+ * It survives the wire only because `src/start.ts` registers a serialization
54
+ * adapter for it. Without that, the router's shallow error serializer keeps
55
+ * `message` and drops every custom property — including `.status`.
56
+ *
57
+ * `APIError` above stays for route handlers and the OAuth surface, which
58
+ * legitimately answer with a `Response`.
59
+ */
60
+ export class HttpError extends Error {
61
+ readonly status: number;
62
+ readonly error: string;
63
+
64
+ constructor({ status, error, message }: APIErrorProp) {
65
+ super(message);
66
+ this.name = "HttpError";
67
+ this.status = status;
68
+ this.error = error;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * What a failed call may tell the user.
74
+ *
75
+ * Only a 4xx `HttpError` message is written for them — a refused name states
76
+ * the rule, so showing it is the difference between correcting the name and
77
+ * retrying it forever. Everything else (a 5xx, a stray `TypeError`) carries
78
+ * internal detail written for us, and gets the caller's own wording.
79
+ */
80
+ export function userFacingMessage(error: unknown, fallback: string): string {
81
+ if (!(error instanceof HttpError)) return fallback;
82
+ return error.status >= 400 && error.status < 500 ? error.message : fallback;
83
+ }
84
+
85
+ /**
86
+ * What a caller is told when a field breaks a rule nobody wrote out.
87
+ *
88
+ * zod's own wording is written for whoever wrote the schema, not for the person
89
+ * who typed the value: "Invalid string: must match pattern /^\d{4}-\d{2}-\d{2}$/"
90
+ * hands the reader a regular expression, and "Too small: expected string to have
91
+ * >=1 characters" reads like a stack frame. Neither may be shown (ADR 0010).
92
+ */
93
+ const INVALID_INPUT = "That input isn't valid.";
94
+
95
+ /**
96
+ * The one way a server function declares its input (ADR 0010).
97
+ *
98
+ * `.validator(schema)` and `.validator((d) => schema.parse(d))` both leave the
99
+ * framework's `execValidator` to throw: the first as an `Error` whose message
100
+ * is a JSON dump of the schema's issues, the second as a bare `ZodError`.
101
+ * Neither carries `.status`, so a caller who mistypes a field is shown a dump
102
+ * written for nobody, and an expected 4xx reaches error reporting as a crash.
103
+ *
104
+ * Wrapping the schema makes that refusal a 400 like every other, which is all
105
+ * it takes — the status is what every downstream filter reads.
106
+ */
107
+ export function validated<TSchema extends z.ZodType>(
108
+ schema: TSchema,
109
+ ): (input: z.input<TSchema>) => z.output<TSchema> {
110
+ // Declared as the schema's own input, not `unknown`: the framework reads this
111
+ // signature to decide whether `data` is required at the call site, and
112
+ // `unknown` accepts `undefined` — so typing it honestly would quietly make
113
+ // every wrapped server function's argument optional. Nothing is lost, since
114
+ // what actually arrives is parsed rather than trusted.
115
+ return (input: unknown) => {
116
+ // A message the schema's author wrote outranks this map, and a message zod
117
+ // would have generated does not — so an authored rule still reaches the
118
+ // reader while zod's own wording can never be what they see.
119
+ const parsed = schema.safeParse(input, { error: () => INVALID_INPUT });
120
+ if (parsed.success) return parsed.data;
121
+ throw new HttpError({
122
+ status: 400,
123
+ error: "invalid_param",
124
+ // The first issue's message only: its `path`, `code` and the shape of
125
+ // the rest of the array are internal.
126
+ message: parsed.error.issues[0]?.message ?? INVALID_INPUT,
127
+ });
128
+ };
129
+ }
@@ -0,0 +1,157 @@
1
+ import * as m from "@/paraglide/messages";
2
+
3
+ /**
4
+ * Messages for the `?error=` codes the OAuth callback can bounce back with.
5
+ * `email_not_found` is Better Auth's built-in no-email rejection;
6
+ * `github_email_unverified` comes from the signup gate in
7
+ * `services/account-security`; `account_not_linked` is the built-in refusal to
8
+ * auto-link an unverified provider email onto an existing account.
9
+ */
10
+ export function oauthErrorMessage(code: string | undefined): string | null {
11
+ switch (code) {
12
+ case undefined:
13
+ return null;
14
+ case "email_not_found":
15
+ return m.auth_oauth_no_email();
16
+ case "github_email_unverified":
17
+ return m.auth_oauth_email_unverified();
18
+ case "account_not_linked":
19
+ return m.auth_oauth_account_not_linked();
20
+ default:
21
+ return m.auth_oauth_generic();
22
+ }
23
+ }
24
+
25
+ /**
26
+ * The refusal codes `src/services/account-security.ts` returns, as copy.
27
+ *
28
+ * One map rather than a message per call site: every security action can
29
+ * return any of these, and a page that formats the code itself ends up showing
30
+ * `not_fresh` to someone who needs to be told to sign in again.
31
+ */
32
+ export function accountSecurityErrorMessage(code: string): string {
33
+ switch (code) {
34
+ case "already_enabled":
35
+ return m.account_err_already_enabled();
36
+ case "cooldown":
37
+ return m.account_err_cooldown();
38
+ case "email_not_found":
39
+ return m.account_err_email_not_found();
40
+ case "github_email_unverified":
41
+ return m.account_err_github_email_unverified();
42
+ case "invalid_code":
43
+ return m.account_err_invalid_code();
44
+ case "not_enabled":
45
+ return m.account_err_not_enabled();
46
+ case "not_fresh":
47
+ return m.account_err_not_fresh();
48
+ case "too_many_attempts":
49
+ return m.account_err_too_many_attempts();
50
+ case "totp_required":
51
+ return m.account_err_totp_required();
52
+ case "unauthorized":
53
+ return m.account_err_unauthorized();
54
+ default:
55
+ return m.auth_error_generic();
56
+ }
57
+ }
58
+
59
+ /** Better Auth verification redirects use SCREAMING_SNAKE; OAuth uses snake_case. */
60
+ const VERIFICATION_STYLE_CODE = /^[A-Z][A-Z0-9_]*$/;
61
+
62
+ /**
63
+ * Messages for `?error=` codes Better Auth appends when email verification
64
+ * fails and redirects to callbackURL (default `/` or `/sign-in`).
65
+ * Codes come from BASE_ERROR_CODES (TOKEN_EXPIRED, INVALID_TOKEN, …).
66
+ */
67
+ export function verificationErrorMessage(
68
+ code: string | undefined,
69
+ ): string | null {
70
+ switch (code) {
71
+ case undefined:
72
+ return null;
73
+ case "TOKEN_EXPIRED":
74
+ return m.auth_verify_token_expired();
75
+ case "INVALID_TOKEN":
76
+ return m.auth_verify_token_invalid();
77
+ case "USER_NOT_FOUND":
78
+ return m.auth_verify_user_not_found();
79
+ default:
80
+ // Other verification-style codes (e.g. INVALID_USER). Leave OAuth
81
+ // snake_case codes (email_not_found, …) for oauthErrorMessage.
82
+ if (VERIFICATION_STYLE_CODE.test(code)) {
83
+ return m.auth_verify_generic();
84
+ }
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Prefer verification copy, then OAuth copy, for a shared `?error=` query.
91
+ * Sign-in can receive either class of redirect.
92
+ */
93
+ export function authQueryErrorMessage(code: string | undefined): string | null {
94
+ return verificationErrorMessage(code) ?? oauthErrorMessage(code);
95
+ }
96
+
97
+ /**
98
+ * Transport failures, which arrive as a thrown `TypeError` rather than an
99
+ * `{ error }` result — Safari says "Load failed", Chrome "Failed to fetch".
100
+ * Matched by message because neither carries a status or a code.
101
+ */
102
+ const NETWORK_MESSAGE =
103
+ /^(Load failed|Failed to fetch|NetworkError when attempting to fetch resource\.?|Network request failed)$/i;
104
+
105
+ type AuthCallError = {
106
+ message?: string | null;
107
+ status?: number;
108
+ statusCode?: number;
109
+ };
110
+
111
+ export type SettledAuthError = {
112
+ message: string;
113
+ status?: number;
114
+ };
115
+
116
+ /**
117
+ * Run a Better Auth client call that either returns `{ data, error }` or throws
118
+ * on transport failure. Never throws — always returns a settled `{ data, error }`
119
+ * so a page renders a banner instead of hitting the error boundary.
120
+ */
121
+ export async function settleAuthCall<T = unknown>(
122
+ call: () => Promise<{ data?: T | null; error?: AuthCallError | null }>,
123
+ ): Promise<{ data: T; error: null } | { data: null; error: SettledAuthError }> {
124
+ try {
125
+ const result = await call();
126
+ if (result.error) {
127
+ const status =
128
+ typeof result.error.status === "number"
129
+ ? result.error.status
130
+ : typeof result.error.statusCode === "number"
131
+ ? result.error.statusCode
132
+ : undefined;
133
+ return {
134
+ data: null,
135
+ error: {
136
+ message: result.error.message?.trim() || m.auth_error_generic(),
137
+ ...(status !== undefined ? { status } : {}),
138
+ },
139
+ };
140
+ }
141
+ return { data: result.data as T, error: null };
142
+ } catch (error) {
143
+ const message =
144
+ error instanceof Error ? error.message : String(error ?? "");
145
+ if (NETWORK_MESSAGE.test(message)) {
146
+ return { data: null, error: { message: m.auth_error_network() } };
147
+ }
148
+ return {
149
+ data: null,
150
+ error: {
151
+ message:
152
+ (error instanceof Error && error.message.trim()) ||
153
+ m.auth_error_generic(),
154
+ },
155
+ };
156
+ }
157
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Post-authentication navigation, shared by the sign-in page, the two-factor
3
+ * page, and the social/passkey buttons. Client-only (reads window).
4
+ */
5
+ import { localizeHref } from "@/paraglide/runtime";
6
+
7
+ /** Default landing after a successful sign-in when no ?redirect= is present. */
8
+ export const DEFAULT_AFTER_AUTH_PATH = "/dashboard";
9
+
10
+ /**
11
+ * Locale-prefix a Starter auth HTML path for the active locale.
12
+ * Machine paths (`/api/*`) must not go through this helper.
13
+ */
14
+ export function localizedAuthPath(
15
+ path: "/sign-in" | "/sign-up" | "/two-factor" | "/forgot-password",
16
+ ): string {
17
+ return localizeHref(path);
18
+ }
19
+
20
+ export function safeRedirectPath(value: string | null): string {
21
+ if (!value) return DEFAULT_AFTER_AUTH_PATH;
22
+ if (!value.startsWith("/") || value.startsWith("//")) {
23
+ return DEFAULT_AFTER_AUTH_PATH;
24
+ }
25
+ if (value.includes("\\")) return DEFAULT_AFTER_AUTH_PATH;
26
+ if ([...value].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) {
27
+ return DEFAULT_AFTER_AUTH_PATH;
28
+ }
29
+ try {
30
+ const url = new URL(value, window.location.origin);
31
+ return url.origin === window.location.origin
32
+ ? `${url.pathname}${url.search}${url.hash}`
33
+ : DEFAULT_AFTER_AUTH_PATH;
34
+ } catch {
35
+ return DEFAULT_AFTER_AUTH_PATH;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Send a user whose session died back to sign-in, carrying where they were so
41
+ * they land back on it. A full navigation rather than a router one: the session
42
+ * is gone, so every cached query behind it is stale too.
43
+ *
44
+ * Path is locale-prefixed so full-app URL i18n is preserved (e.g. `/de/sign-in`).
45
+ */
46
+ export function goToSignIn(from: string): void {
47
+ const signIn = localizedAuthPath("/sign-in");
48
+ window.location.href = `${signIn}?redirect=${encodeURIComponent(from)}`;
49
+ }
50
+
51
+ /**
52
+ * Where to land after a completed authentication: resume an OAuth authorize
53
+ * flow if we arrived mid-authorize (a client_id in the signed query the login
54
+ * page carries), otherwise honor ?redirect or the user dashboard.
55
+ */
56
+ export function afterAuthTarget(): string {
57
+ const query = window.location.search.replace(/^\?/, "");
58
+ const params = new URLSearchParams(query);
59
+ if (params.has("client_id")) return `/api/auth/oauth2/authorize?${query}`;
60
+ return safeRedirectPath(params.get("redirect"));
61
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Reading a request body behind a hard cap.
3
+ *
4
+ * A `Content-Length` check alone is bypassed by a chunked request — the header
5
+ * is absent and the guard never fires — so the cap has to count bytes as they
6
+ * arrive. Public webhooks must not buffer an unbounded body before the
7
+ * signature is verified.
8
+ *
9
+ * `readTextCapped` hands back the raw string, not parsed JSON: webhooks verify
10
+ * signatures over the exact characters received.
11
+ *
12
+ * Framework-agnostic and binding-free — no `cloudflare:workers` import.
13
+ */
14
+
15
+ export class BodyTooLargeError extends Error {
16
+ constructor() {
17
+ super("Request body is too large.");
18
+ this.name = "BodyTooLargeError";
19
+ }
20
+ }
21
+
22
+ export class UnsupportedMediaTypeError extends Error {
23
+ constructor() {
24
+ super("Request body must be application/json.");
25
+ this.name = "UnsupportedMediaTypeError";
26
+ }
27
+ }
28
+
29
+ export type CappedReadOptions = {
30
+ maxBytes: number;
31
+ /** Require an `application/json` content type. Default true. */
32
+ requireJson?: boolean;
33
+ };
34
+
35
+ function assertJsonContentType(request: Request): void {
36
+ const header = request.headers.get("content-type") ?? "";
37
+ const mediaType = header.split(";")[0].trim().toLowerCase();
38
+ if (mediaType !== "application/json") throw new UnsupportedMediaTypeError();
39
+ }
40
+
41
+ /**
42
+ * Read the body as text, refusing anything over `maxBytes` whether or not a
43
+ * `Content-Length` was declared. Returns `""` when there is no body.
44
+ */
45
+ export async function readTextCapped(
46
+ request: Request,
47
+ { maxBytes, requireJson = true }: CappedReadOptions,
48
+ ): Promise<string> {
49
+ if (requireJson) assertJsonContentType(request);
50
+
51
+ const declared = request.headers.get("content-length");
52
+ if (declared && Number(declared) > maxBytes) throw new BodyTooLargeError();
53
+
54
+ const reader = request.body?.getReader();
55
+ if (!reader) return "";
56
+
57
+ const decoder = new TextDecoder();
58
+ let text = "";
59
+ let total = 0;
60
+ for (;;) {
61
+ const { done, value } = await reader.read();
62
+ if (done) break;
63
+ total += value.byteLength;
64
+ if (total > maxBytes) {
65
+ await reader.cancel();
66
+ throw new BodyTooLargeError();
67
+ }
68
+ text += decoder.decode(value, { stream: true });
69
+ }
70
+
71
+ return text + decoder.decode();
72
+ }
73
+
74
+ /**
75
+ * Same cap, parsed. Returns `{}` for a body-less request.
76
+ */
77
+ export async function readJsonCapped(
78
+ request: Request,
79
+ options: CappedReadOptions,
80
+ ): Promise<unknown> {
81
+ const text = await readTextCapped(request, options);
82
+ if (text === "") return {};
83
+ return JSON.parse(text);
84
+ }
@@ -0,0 +1,6 @@
1
+ type ClassPart = string | false | null | undefined;
2
+
3
+ /** Join class names, skipping falsy values. */
4
+ export function cn(...parts: ClassPart[]): string {
5
+ return parts.filter(Boolean).join(" ");
6
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Server-side field rules. A route's zod schema runs in the browser only —
3
+ * Better Auth endpoints accept whatever is posted — so rules that matter live
4
+ * here. Free-form text is hardened, not sanitized.
5
+ */
6
+ import { HttpError } from "@/utils/api-error";
7
+
8
+ export const MAX_DISPLAY_NAME_LENGTH = 32;
9
+ export const MAX_PASSKEY_NAME_LENGTH = 64;
10
+ export const MAX_EMAIL_LENGTH = 254;
11
+ export const MAX_EMAIL_LOCAL_LENGTH = 32;
12
+ export const MIN_PASSWORD_LENGTH = 8;
13
+ export const MAX_PASSWORD_LENGTH = 32;
14
+
15
+ function isControl(cp: number, allowNewlines: boolean): boolean {
16
+ if (allowNewlines && (cp === 0x0a || cp === 0x0d || cp === 0x09))
17
+ return false;
18
+ return (cp >= 0x00 && cp <= 0x1f) || (cp >= 0x7f && cp <= 0x9f);
19
+ }
20
+
21
+ function isBidiOverride(cp: number): boolean {
22
+ return (
23
+ (cp >= 0x202a && cp <= 0x202e) ||
24
+ (cp >= 0x2066 && cp <= 0x2069) ||
25
+ cp === 0x200e ||
26
+ cp === 0x200f ||
27
+ cp === 0x061c
28
+ );
29
+ }
30
+
31
+ function isZeroWidth(cp: number): boolean {
32
+ return (cp >= 0x200b && cp <= 0x200d) || cp === 0xfeff;
33
+ }
34
+
35
+ function hasControl(value: string, allowNewlines: boolean): boolean {
36
+ for (const ch of value) {
37
+ if (isControl(ch.codePointAt(0) ?? 0, allowNewlines)) return true;
38
+ }
39
+ return false;
40
+ }
41
+
42
+ function has(value: string, predicate: (cp: number) => boolean): boolean {
43
+ for (const ch of value) {
44
+ if (predicate(ch.codePointAt(0) ?? 0)) return true;
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /**
50
+ * A broken field rule, as a 400 rather than a bare `Error`.
51
+ *
52
+ * Extending `HttpError` is what carries `.status` across the wire (via the
53
+ * adapter in `src/start.ts`) and what lets `userFacingMessage` show the rule to
54
+ * the person who broke it. A plain `Error` here would reach the client with its
55
+ * message stripped, so a refused display name would read as a server fault.
56
+ */
57
+ export class InvalidFieldError extends HttpError {
58
+ constructor(message: string) {
59
+ super({ status: 400, error: "invalid_param", message });
60
+ this.name = "InvalidFieldError";
61
+ }
62
+ }
63
+
64
+ function assertNoInvisibles(value: string, field: string): void {
65
+ if (has(value, isBidiOverride)) {
66
+ throw new InvalidFieldError(
67
+ `${field} cannot contain text-direction overrides.`,
68
+ );
69
+ }
70
+ if (has(value, isZeroWidth)) {
71
+ throw new InvalidFieldError(
72
+ `${field} cannot contain invisible characters.`,
73
+ );
74
+ }
75
+ }
76
+
77
+ export function normalizeDisplayName(raw: string): string {
78
+ const value = raw.normalize("NFKC").trim();
79
+ if (!value) throw new InvalidFieldError("Name is required.");
80
+ if (value.length > MAX_DISPLAY_NAME_LENGTH) {
81
+ throw new InvalidFieldError(
82
+ `Name is too long (maximum ${MAX_DISPLAY_NAME_LENGTH} characters).`,
83
+ );
84
+ }
85
+ if (hasControl(value, false)) {
86
+ throw new InvalidFieldError("Name cannot contain control characters.");
87
+ }
88
+ assertNoInvisibles(value, "Name");
89
+ return value;
90
+ }
91
+
92
+ export function sanitizeDerivedDisplayName(raw: string): string {
93
+ const value = [...raw.normalize("NFKC")]
94
+ .filter((ch) => {
95
+ const cp = ch.codePointAt(0) ?? 0;
96
+ return !isControl(cp, false) && !isBidiOverride(cp) && !isZeroWidth(cp);
97
+ })
98
+ .join("")
99
+ .trim()
100
+ .slice(0, MAX_DISPLAY_NAME_LENGTH);
101
+ return value || "User";
102
+ }
103
+
104
+ export function normalizeFreeformText(
105
+ raw: string,
106
+ field: string,
107
+ maxLength: number,
108
+ ): string {
109
+ const value = raw.normalize("NFKC").trim();
110
+ if (!value) throw new InvalidFieldError(`${field} is required.`);
111
+ if (value.length > maxLength) {
112
+ throw new InvalidFieldError(
113
+ `${field} is too long (maximum ${maxLength} characters).`,
114
+ );
115
+ }
116
+ if (hasControl(value, true)) {
117
+ throw new InvalidFieldError(`${field} cannot contain control characters.`);
118
+ }
119
+ assertNoInvisibles(value, field);
120
+ return value;
121
+ }
122
+
123
+ export function assertPasswordRules(password: string): void {
124
+ if (password.length < MIN_PASSWORD_LENGTH) {
125
+ throw new InvalidFieldError(
126
+ `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`,
127
+ );
128
+ }
129
+ if (password.length > MAX_PASSWORD_LENGTH) {
130
+ throw new InvalidFieldError(
131
+ `Password must be at most ${MAX_PASSWORD_LENGTH} characters.`,
132
+ );
133
+ }
134
+ if (!/\p{L}/u.test(password)) {
135
+ throw new InvalidFieldError("Password must contain a letter.");
136
+ }
137
+ if (!/\p{Nd}/u.test(password)) {
138
+ throw new InvalidFieldError("Password must contain a digit.");
139
+ }
140
+ if (!/[^\p{L}\p{Nd}]/u.test(password)) {
141
+ throw new InvalidFieldError("Password must contain a symbol.");
142
+ }
143
+ }
144
+
145
+ export function assertEmailLimits(email: string): void {
146
+ if (email.length > MAX_EMAIL_LENGTH) {
147
+ throw new InvalidFieldError(
148
+ `Email must be at most ${MAX_EMAIL_LENGTH} characters.`,
149
+ );
150
+ }
151
+ const at = email.lastIndexOf("@");
152
+ const local = at === -1 ? email : email.slice(0, at);
153
+ if (local.length > MAX_EMAIL_LOCAL_LENGTH) {
154
+ throw new InvalidFieldError(
155
+ `The part of the email before @ must be at most ${MAX_EMAIL_LOCAL_LENGTH} characters.`,
156
+ );
157
+ }
158
+ }
159
+
160
+ export function assertPasskeyName(name: string): void {
161
+ normalizeFreeformText(name, "Passkey name", MAX_PASSKEY_NAME_LENGTH);
162
+ }
163
+
164
+ export const SUPPORTED_LOCALES = ["en", "de"] as const;
165
+ export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
166
+
167
+ export function assertLocale(locale: string): SupportedLocale {
168
+ if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
169
+ return locale as SupportedLocale;
170
+ }
171
+ throw new InvalidFieldError("That locale isn't supported.");
172
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Locale → path pairs for one user-facing page (English bare, German prefixed).
3
+ * Shared by Paraglide urlPatterns so English stays bare and German is prefixed.
4
+ */
5
+ export function localizedLocalePaths(
6
+ page: string,
7
+ ): Array<readonly [string, string]> {
8
+ if (page === "/") {
9
+ return [
10
+ ["en", "/"],
11
+ ["de", "/de/"],
12
+ ];
13
+ }
14
+ return [
15
+ ["en", page],
16
+ ["de", `/de${page}`],
17
+ ];
18
+ }
@@ -0,0 +1,20 @@
1
+ import { deLocalizeHref, type Locale, localizeHref } from "@/paraglide/runtime";
2
+
3
+ /**
4
+ * Build a same-page language-switch href: localize the path, keep search and
5
+ * hash byte-for-byte. Pure over the compiled Paraglide runtime.
6
+ */
7
+ export function samePageLocaleHref(input: {
8
+ pathname: string;
9
+ /** Including leading "?", or "". */
10
+ search?: string;
11
+ /** Including leading "#", or "". */
12
+ hash?: string;
13
+ locale: Locale;
14
+ }): string {
15
+ const search = input.search ?? "";
16
+ const hash = input.hash ?? "";
17
+ const canonical = deLocalizeHref(input.pathname);
18
+ const path = localizeHref(canonical, { locale: input.locale });
19
+ return `${path}${search}${hash}`;
20
+ }