@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.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/assets/auth.sqlite.schema.ts +479 -0
- package/assets/bun-sqlite.test.ts +46 -0
- package/assets/d1.test.ts +41 -0
- package/assets/drizzle.config.ts +2 -0
- package/assets/generate-auth-schema.mjs +25 -0
- package/assets/platform.sqlite.schema.ts +46 -0
- package/assets/polar-checkout.ts +10 -0
- package/assets/polar-route.ts +19 -0
- package/assets/polar.test.ts +30 -0
- package/assets/polar.ts +12 -0
- package/assets/purchase.bun-sqlite.ts +38 -0
- package/assets/purchase.sqlite.ts +39 -0
- package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
- package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
- package/assets/sqlite-migrations/meta/_journal.json +13 -0
- package/assets/turso.test.ts +40 -0
- package/package.json +44 -5
- package/src/adapters/database.mjs +448 -0
- package/src/adapters/deployment.mjs +101 -0
- package/src/adapters/email.mjs +75 -0
- package/src/adapters/payments.mjs +270 -0
- package/src/catalog.mjs +128 -0
- package/src/cli.mjs +294 -0
- package/src/generate.mjs +183 -0
- package/src/template.mjs +93 -0
- package/template/.env.docker +16 -0
- package/template/.env.example +67 -0
- package/template/DESIGN.md +58 -0
- package/template/LICENSE +21 -0
- package/template/biome.json +46 -0
- package/template/docker-compose.yml +47 -0
- package/template/docs/project-guide.md +61 -0
- package/template/drizzle/0000_create-app-role.sql +54 -0
- package/template/drizzle/0001_add-rls-context.sql +21 -0
- package/template/drizzle/0002_platform_tables.sql +320 -0
- package/template/drizzle/meta/0002_snapshot.json +2125 -0
- package/template/drizzle/meta/_journal.json +27 -0
- package/template/drizzle.config.ts +25 -0
- package/template/e2e/authenticated-ui.spec.ts +40 -0
- package/template/e2e/headless-design.spec.ts +66 -0
- package/template/e2e/navigation-loading.spec.ts +70 -0
- package/template/e2e/theme-responsive.spec.ts +260 -0
- package/template/gitignore +24 -0
- package/template/messages/de.json +212 -0
- package/template/messages/en.json +212 -0
- package/template/package.json +101 -0
- package/template/paraglide.config.ts +49 -0
- package/template/playwright.config.ts +58 -0
- package/template/project.inlang/settings.json +12 -0
- package/template/public/drizzle.svg +1 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logo192.png +0 -0
- package/template/public/logo512.png +0 -0
- package/template/public/manifest.json +25 -0
- package/template/public/robots.txt +3 -0
- package/template/scripts/check-client-bundle.ts +150 -0
- package/template/scripts/check-server-fns.ts +290 -0
- package/template/scripts/check-sql-raw.ts +50 -0
- package/template/scripts/compile-paraglide.ts +12 -0
- package/template/scripts/postgres/README.md +76 -0
- package/template/scripts/postgres/create-app-role.sh +60 -0
- package/template/scripts/seed-local.ts +331 -0
- package/template/scripts/validate-prod-env.ts +53 -0
- package/template/src/__tests__/README.md +9 -0
- package/template/src/__tests__/account-security.test.ts +664 -0
- package/template/src/__tests__/admin-http-surface.test.ts +311 -0
- package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
- package/template/src/__tests__/auth-harness.ts +104 -0
- package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
- package/template/src/__tests__/auth-redirect.test.ts +120 -0
- package/template/src/__tests__/body-limit.test.ts +47 -0
- package/template/src/__tests__/creem.test.ts +62 -0
- package/template/src/__tests__/demo-checkout.test.ts +141 -0
- package/template/src/__tests__/email-locale.test.ts +110 -0
- package/template/src/__tests__/global-setup.ts +80 -0
- package/template/src/__tests__/input-rules.test.ts +0 -0
- package/template/src/__tests__/message-parity.test.ts +38 -0
- package/template/src/__tests__/navigation-loading.test.tsx +252 -0
- package/template/src/__tests__/nowpayments.test.ts +106 -0
- package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
- package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
- package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
- package/template/src/__tests__/rls-policy.test.ts +120 -0
- package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
- package/template/src/__tests__/search-params-oauth.test.ts +57 -0
- package/template/src/__tests__/security-headers.test.ts +12 -0
- package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
- package/template/src/__tests__/stripe.test.ts +84 -0
- package/template/src/__tests__/test-db.ts +32 -0
- package/template/src/__tests__/test-rls-db.ts +23 -0
- package/template/src/__tests__/user-update-guard.test.ts +109 -0
- package/template/src/components/LocaleSwitcher.tsx +79 -0
- package/template/src/components/ThemeToggle.tsx +47 -0
- package/template/src/components/auth/social.tsx +90 -0
- package/template/src/components/auth/turnstile.tsx +67 -0
- package/template/src/components/root/navigation-progress.tsx +32 -0
- package/template/src/components/root/not-found.tsx +20 -0
- package/template/src/components/root/pending.tsx +9 -0
- package/template/src/components/root/root-document.tsx +37 -0
- package/template/src/components/root/root-error.tsx +34 -0
- package/template/src/components/shells.tsx +201 -0
- package/template/src/components/ui/styles.ts +9 -0
- package/template/src/db/auth.schema.ts +455 -0
- package/template/src/db/helper.ts +38 -0
- package/template/src/db/index.ts +15 -0
- package/template/src/db/platform.schema.ts +122 -0
- package/template/src/db/schema.ts +2 -0
- package/template/src/env.ts +65 -0
- package/template/src/hooks/use-theme.ts +32 -0
- package/template/src/integrations/better-auth/header-user.tsx +36 -0
- package/template/src/integrations/better-auth/session-query.ts +11 -0
- package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
- package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
- package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
- package/template/src/integrations/react-email/theme.ts +75 -0
- package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
- package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
- package/template/src/lib/auth-client.ts +27 -0
- package/template/src/lib/auth-paths.ts +94 -0
- package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
- package/template/src/lib/auth-plugins/field-rules.ts +84 -0
- package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
- package/template/src/lib/auth-timing.ts +9 -0
- package/template/src/lib/auth.ts +279 -0
- package/template/src/lib/captcha.ts +11 -0
- package/template/src/lib/email.tsx +162 -0
- package/template/src/lib/github.ts +18 -0
- package/template/src/lib/locale-cookie.ts +12 -0
- package/template/src/lib/localized-pages.ts +25 -0
- package/template/src/lib/oauth-metadata.ts +19 -0
- package/template/src/lib/permissions.ts +29 -0
- package/template/src/lib/security-headers.ts +20 -0
- package/template/src/lib/site.ts +59 -0
- package/template/src/lib/theme.ts +42 -0
- package/template/src/lib/user-update-guard.ts +35 -0
- package/template/src/middlewares/admin.ts +46 -0
- package/template/src/middlewares/auth-rate-limit.ts +37 -0
- package/template/src/middlewares/better-auth.ts +15 -0
- package/template/src/middlewares/body-limit.ts +62 -0
- package/template/src/middlewares/database.ts +13 -0
- package/template/src/middlewares/protected.ts +45 -0
- package/template/src/routeTree.gen.ts +536 -0
- package/template/src/router.tsx +53 -0
- package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
- package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
- package/template/src/routes/__root.tsx +175 -0
- package/template/src/routes/admin/audit.tsx +74 -0
- package/template/src/routes/admin/index.tsx +7 -0
- package/template/src/routes/admin/oauth-apps.tsx +164 -0
- package/template/src/routes/admin/route.tsx +27 -0
- package/template/src/routes/admin/users.tsx +215 -0
- package/template/src/routes/api/auth/$.ts +14 -0
- package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
- package/template/src/routes/api/webhooks/creem.ts +69 -0
- package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
- package/template/src/routes/api/webhooks/stripe.ts +74 -0
- package/template/src/routes/dashboard/account.tsx +430 -0
- package/template/src/routes/dashboard/index.tsx +128 -0
- package/template/src/routes/dashboard/route.tsx +17 -0
- package/template/src/routes/forgot-password.tsx +65 -0
- package/template/src/routes/index.tsx +85 -0
- package/template/src/routes/oauth/consent.tsx +77 -0
- package/template/src/routes/reset-password.tsx +78 -0
- package/template/src/routes/sign-in.tsx +174 -0
- package/template/src/routes/sign-up.tsx +135 -0
- package/template/src/routes/two-factor.tsx +74 -0
- package/template/src/server/README.md +3 -0
- package/template/src/server/account-security.ts +163 -0
- package/template/src/server/admin-audit.ts +37 -0
- package/template/src/server/admin-users.ts +112 -0
- package/template/src/server/auth.ts +44 -0
- package/template/src/server/demo-checkout.ts +67 -0
- package/template/src/server/oauth-clients.ts +83 -0
- package/template/src/server/payment-toggles.ts +39 -0
- package/template/src/server.ts +20 -0
- package/template/src/services/README.md +3 -0
- package/template/src/services/account-security.ts +893 -0
- package/template/src/services/admin-security.ts +164 -0
- package/template/src/services/admin-users.ts +181 -0
- package/template/src/services/auth-rate-limit.ts +32 -0
- package/template/src/services/creem.ts +83 -0
- package/template/src/services/demo-checkout.ts +100 -0
- package/template/src/services/nowpayments.ts +154 -0
- package/template/src/services/oauth-client.ts +85 -0
- package/template/src/services/oauth-clients.ts +15 -0
- package/template/src/services/payment-providers.ts +157 -0
- package/template/src/services/payment-toggles.ts +48 -0
- package/template/src/services/purchase.ts +233 -0
- package/template/src/services/stripe.ts +106 -0
- package/template/src/start.ts +35 -0
- package/template/src/styles/layout.css +93 -0
- package/template/src/styles/theme.css +84 -0
- package/template/src/styles/ui.css +233 -0
- package/template/src/styles.css +90 -0
- package/template/src/utils/api-error.ts +129 -0
- package/template/src/utils/auth-errors.ts +157 -0
- package/template/src/utils/auth-redirect.ts +61 -0
- package/template/src/utils/body-limit.ts +84 -0
- package/template/src/utils/cn.ts +6 -0
- package/template/src/utils/input-rules.ts +172 -0
- package/template/src/utils/locale-paths.ts +18 -0
- package/template/src/utils/same-page-locale-href.ts +20 -0
- package/template/src/utils/search-params.ts +71 -0
- package/template/tsconfig.json +30 -0
- package/template/tsr.config.json +6 -0
- package/template/vite.config.ts +24 -0
- package/template/vitest.config.ts +26 -0
- package/template/worker-configuration.d.ts +14660 -0
- 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,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
|
+
}
|