@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,279 @@
|
|
|
1
|
+
import { waitUntil } from "cloudflare:workers";
|
|
2
|
+
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
|
3
|
+
import { oauthProvider } from "@better-auth/oauth-provider";
|
|
4
|
+
import { passkey } from "@better-auth/passkey";
|
|
5
|
+
import { APIError, betterAuth } from "better-auth";
|
|
6
|
+
import { admin, captcha, emailOTP, jwt, twoFactor } from "better-auth/plugins";
|
|
7
|
+
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
9
|
+
import type { Database } from "@/db";
|
|
10
|
+
// Aliased: `user` is already the member role imported from ./permissions.
|
|
11
|
+
import { user as userTable } from "@/db/schema";
|
|
12
|
+
import { env } from "@/env";
|
|
13
|
+
import { CAPTCHA_ENDPOINTS, DISABLED_AUTH_PATHS } from "@/lib/auth-paths";
|
|
14
|
+
import { CAPTCHA_ENABLED } from "@/lib/captcha";
|
|
15
|
+
import {
|
|
16
|
+
evaluateGithubSignup,
|
|
17
|
+
TWO_FACTOR_ISSUER,
|
|
18
|
+
} from "@/services/account-security";
|
|
19
|
+
import { canManageOAuthClients } from "@/services/admin-security";
|
|
20
|
+
import {
|
|
21
|
+
MAX_PASSWORD_LENGTH,
|
|
22
|
+
MIN_PASSWORD_LENGTH,
|
|
23
|
+
normalizeDisplayName,
|
|
24
|
+
sanitizeDerivedDisplayName,
|
|
25
|
+
} from "@/utils/input-rules";
|
|
26
|
+
import { existingEmailSignup } from "./auth-plugins/existing-email-signup";
|
|
27
|
+
import { fieldRules } from "./auth-plugins/field-rules";
|
|
28
|
+
import { socialTwoFactor } from "./auth-plugins/social-two-factor";
|
|
29
|
+
import {
|
|
30
|
+
ACCESS_TOKEN_TTL_SEC,
|
|
31
|
+
DAY,
|
|
32
|
+
EMAIL_VERIFICATION_EXPIRES_IN_SEC,
|
|
33
|
+
} from "./auth-timing";
|
|
34
|
+
import {
|
|
35
|
+
sendPasswordOtpEmail,
|
|
36
|
+
sendResetPasswordEmail,
|
|
37
|
+
sendVerificationEmail,
|
|
38
|
+
} from "./email";
|
|
39
|
+
import { issuerFromAuthUrl } from "./oauth-metadata";
|
|
40
|
+
import { ac, admin as adminRole, superadmin, user } from "./permissions";
|
|
41
|
+
import { userUpdateGuard } from "./user-update-guard";
|
|
42
|
+
|
|
43
|
+
export { ACCESS_TOKEN_TTL_SEC } from "./auth-timing";
|
|
44
|
+
|
|
45
|
+
/** Authorization-server identifier advertised in discovery / PRM. */
|
|
46
|
+
export function getIssuer() {
|
|
47
|
+
return issuerFromAuthUrl(env.BETTER_AUTH_URL);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function audiences(): string[] {
|
|
51
|
+
if (env.BETTER_AUTH_AUDIENCES) {
|
|
52
|
+
return env.BETTER_AUTH_AUDIENCES.split(",")
|
|
53
|
+
.map((s) => s.trim())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
return [env.APP_ORIGIN];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createAuth(db: Database) {
|
|
60
|
+
// Public client id: prefer server GITHUB_CLIENT_ID, fall back to the same
|
|
61
|
+
// Vite-public value the UI uses so button visibility and provider config
|
|
62
|
+
// cannot drift.
|
|
63
|
+
const githubClientId =
|
|
64
|
+
env.GITHUB_CLIENT_ID?.trim() ||
|
|
65
|
+
(typeof import.meta.env.VITE_GITHUB_CLIENT_ID === "string"
|
|
66
|
+
? import.meta.env.VITE_GITHUB_CLIENT_ID.trim()
|
|
67
|
+
: "") ||
|
|
68
|
+
undefined;
|
|
69
|
+
const githubConfigured =
|
|
70
|
+
Boolean(githubClientId) && Boolean(env.GITHUB_CLIENT_SECRET);
|
|
71
|
+
|
|
72
|
+
return betterAuth({
|
|
73
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
74
|
+
disabledPaths: [...DISABLED_AUTH_PATHS],
|
|
75
|
+
database: drizzleAdapter(db, {
|
|
76
|
+
provider: "pg",
|
|
77
|
+
}),
|
|
78
|
+
trustedOrigins: [
|
|
79
|
+
"http://localhost:3000",
|
|
80
|
+
env.APP_ORIGIN,
|
|
81
|
+
env.BETTER_AUTH_URL,
|
|
82
|
+
].filter((v, i, a) => a.indexOf(v) === i),
|
|
83
|
+
telemetry: { enabled: false },
|
|
84
|
+
advanced: {
|
|
85
|
+
backgroundTasks: {
|
|
86
|
+
handler: (promise) => {
|
|
87
|
+
waitUntil(promise);
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
session: {
|
|
92
|
+
cookieCache: {
|
|
93
|
+
enabled: !import.meta.env.DEV,
|
|
94
|
+
maxAge: 360,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
emailAndPassword: {
|
|
98
|
+
enabled: true,
|
|
99
|
+
requireEmailVerification: true,
|
|
100
|
+
minPasswordLength: MIN_PASSWORD_LENGTH,
|
|
101
|
+
maxPasswordLength: MAX_PASSWORD_LENGTH,
|
|
102
|
+
revokeSessionsOnPasswordReset: true,
|
|
103
|
+
sendResetPassword: async ({ user: u, url }) => {
|
|
104
|
+
await sendResetPasswordEmail({
|
|
105
|
+
to: u.email,
|
|
106
|
+
url,
|
|
107
|
+
name: u.name,
|
|
108
|
+
locale: (u as { locale?: string | null }).locale,
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
emailVerification: {
|
|
113
|
+
sendOnSignUp: true,
|
|
114
|
+
sendOnSignIn: true,
|
|
115
|
+
autoSignInAfterVerification: true,
|
|
116
|
+
expiresIn: EMAIL_VERIFICATION_EXPIRES_IN_SEC,
|
|
117
|
+
sendVerificationEmail: async ({ user: u, url }) => {
|
|
118
|
+
await sendVerificationEmail({
|
|
119
|
+
to: u.email,
|
|
120
|
+
url,
|
|
121
|
+
name: u.name,
|
|
122
|
+
locale: (u as { locale?: string | null }).locale,
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
...(githubConfigured
|
|
127
|
+
? {
|
|
128
|
+
socialProviders: {
|
|
129
|
+
github: {
|
|
130
|
+
clientId: githubClientId as string,
|
|
131
|
+
clientSecret: env.GITHUB_CLIENT_SECRET as string,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
user: {
|
|
137
|
+
additionalFields: {
|
|
138
|
+
tokensRevokedAt: { type: "date", required: false, input: false },
|
|
139
|
+
locale: { type: "string", required: false, input: true },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
databaseHooks: {
|
|
143
|
+
user: {
|
|
144
|
+
create: {
|
|
145
|
+
before: async (newUser, context) => {
|
|
146
|
+
if (context?.path?.startsWith("/callback")) {
|
|
147
|
+
const u = newUser as {
|
|
148
|
+
email?: string;
|
|
149
|
+
emailVerified?: boolean;
|
|
150
|
+
name?: string;
|
|
151
|
+
};
|
|
152
|
+
const verdict = evaluateGithubSignup({
|
|
153
|
+
email: u.email,
|
|
154
|
+
emailVerified: Boolean(u.emailVerified),
|
|
155
|
+
});
|
|
156
|
+
if (!verdict.ok) {
|
|
157
|
+
throw new APIError("BAD_REQUEST", {
|
|
158
|
+
message: verdict.code,
|
|
159
|
+
code: verdict.code,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (typeof u.name === "string") {
|
|
163
|
+
return {
|
|
164
|
+
data: {
|
|
165
|
+
...newUser,
|
|
166
|
+
name: sanitizeDerivedDisplayName(u.name),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (typeof newUser.name === "string") {
|
|
172
|
+
try {
|
|
173
|
+
return {
|
|
174
|
+
data: {
|
|
175
|
+
...newUser,
|
|
176
|
+
name: normalizeDisplayName(newUser.name),
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
} catch {
|
|
180
|
+
throw new APIError("BAD_REQUEST", {
|
|
181
|
+
message: "Invalid display name.",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { data: newUser };
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
update: {
|
|
189
|
+
before: async (data, context) => {
|
|
190
|
+
const guarded = userUpdateGuard(data);
|
|
191
|
+
if (!guarded.data || typeof guarded.data !== "object") {
|
|
192
|
+
return guarded;
|
|
193
|
+
}
|
|
194
|
+
const rest = { ...(guarded.data as Record<string, unknown>) };
|
|
195
|
+
// Same display-name rule on edit. Human-typed updates refuse
|
|
196
|
+
// invalid names; system-supplied names (e.g. provider sync) strip.
|
|
197
|
+
if (typeof rest.name === "string") {
|
|
198
|
+
if (context?.path === "/update-user") {
|
|
199
|
+
try {
|
|
200
|
+
rest.name = normalizeDisplayName(rest.name);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
throw new APIError("BAD_REQUEST", {
|
|
203
|
+
message:
|
|
204
|
+
err instanceof Error ? err.message : "Invalid name.",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
rest.name = sanitizeDerivedDisplayName(rest.name);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return { data: rest as typeof data };
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
plugins: [
|
|
217
|
+
jwt(),
|
|
218
|
+
oauthProvider({
|
|
219
|
+
loginPage: "/sign-in",
|
|
220
|
+
consentPage: "/oauth/consent",
|
|
221
|
+
validAudiences: audiences(),
|
|
222
|
+
clientPrivileges: ({ user: u }) => canManageOAuthClients(u),
|
|
223
|
+
allowDynamicClientRegistration: false,
|
|
224
|
+
allowUnauthenticatedClientRegistration: false,
|
|
225
|
+
accessTokenExpiresIn: ACCESS_TOKEN_TTL_SEC,
|
|
226
|
+
refreshTokenExpiresIn: 30 * DAY,
|
|
227
|
+
}),
|
|
228
|
+
...(CAPTCHA_ENABLED && env.TURNSTILE_SECRET_KEY
|
|
229
|
+
? [
|
|
230
|
+
captcha({
|
|
231
|
+
provider: "cloudflare-turnstile",
|
|
232
|
+
secretKey: env.TURNSTILE_SECRET_KEY,
|
|
233
|
+
endpoints: [...CAPTCHA_ENDPOINTS],
|
|
234
|
+
}),
|
|
235
|
+
]
|
|
236
|
+
: []),
|
|
237
|
+
admin({
|
|
238
|
+
ac,
|
|
239
|
+
roles: { admin: adminRole, superadmin, user },
|
|
240
|
+
}),
|
|
241
|
+
twoFactor({
|
|
242
|
+
issuer: TWO_FACTOR_ISSUER,
|
|
243
|
+
}),
|
|
244
|
+
emailOTP({
|
|
245
|
+
async sendVerificationOTP({ email, otp, type }) {
|
|
246
|
+
if (type !== "forget-password" && type !== "sign-in") return;
|
|
247
|
+
// This callback is handed an address, not a user, so the
|
|
248
|
+
// recipient's language costs one indexed lookup. An address with
|
|
249
|
+
// no account still gets a code (that is the plugin's own
|
|
250
|
+
// enumeration defence) — it just gets it in the base locale.
|
|
251
|
+
const [row] = await db
|
|
252
|
+
.select({ locale: userTable.locale })
|
|
253
|
+
.from(userTable)
|
|
254
|
+
.where(eq(userTable.email, email))
|
|
255
|
+
.limit(1);
|
|
256
|
+
await sendPasswordOtpEmail({
|
|
257
|
+
to: email,
|
|
258
|
+
otp,
|
|
259
|
+
type,
|
|
260
|
+
locale: row?.locale,
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
}),
|
|
264
|
+
passkey({
|
|
265
|
+
rpID: new URL(env.APP_ORIGIN).hostname,
|
|
266
|
+
rpName: env.EMAIL_FROM_NAME,
|
|
267
|
+
}),
|
|
268
|
+
existingEmailSignup(),
|
|
269
|
+
fieldRules(),
|
|
270
|
+
// After twoFactor so the social callback can tear down a full session.
|
|
271
|
+
socialTwoFactor(),
|
|
272
|
+
// Must be last.
|
|
273
|
+
tanstackStartCookies(),
|
|
274
|
+
],
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// For better-auth schema generation only.
|
|
279
|
+
export const auth = createAuth({} as unknown as Database);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Cloudflare Turnstile is enabled only in production builds that have a public
|
|
2
|
+
// site key configured. The Better Auth captcha plugin (server) and the widget
|
|
3
|
+
// (client) both read this SAME build-time constant, so they can never disagree:
|
|
4
|
+
// no site key ⇒ plugin stays off, so the server never demands a token the
|
|
5
|
+
// client can't mint. Vite inlines `import.meta.env.*` into both the worker and
|
|
6
|
+
// browser bundles, which is what lets one module gate both sides.
|
|
7
|
+
export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as
|
|
8
|
+
| string
|
|
9
|
+
| undefined;
|
|
10
|
+
|
|
11
|
+
export const CAPTCHA_ENABLED = import.meta.env.PROD && !!TURNSTILE_SITE_KEY;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { render } from "@react-email/render";
|
|
2
|
+
import type { ReactElement } from "react";
|
|
3
|
+
import { env } from "@/env";
|
|
4
|
+
import { PasswordOtpEmail } from "@/integrations/react-email/PasswordOtpEmail";
|
|
5
|
+
import { ResetPasswordEmail } from "@/integrations/react-email/ResetPasswordEmail";
|
|
6
|
+
import { VerifyEmail } from "@/integrations/react-email/VerifyEmail";
|
|
7
|
+
import * as m from "@/paraglide/messages";
|
|
8
|
+
|
|
9
|
+
type EmailBinding = {
|
|
10
|
+
send: (msg: {
|
|
11
|
+
from: { email: string; name: string };
|
|
12
|
+
to: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
html: string;
|
|
15
|
+
text: string;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
async function emailBinding(): Promise<EmailBinding | null> {
|
|
20
|
+
try {
|
|
21
|
+
const { env: CFEnv } = await import("cloudflare:workers");
|
|
22
|
+
// Through `unknown`: the generated `Env` declares `EMAIL` with wrangler's
|
|
23
|
+
// own message type, which is structurally unrelated to the narrow shape
|
|
24
|
+
// this module actually sends.
|
|
25
|
+
return (CFEnv as unknown as { EMAIL?: EmailBinding }).EMAIL ?? null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function send(opts: {
|
|
32
|
+
to: string;
|
|
33
|
+
subject: string;
|
|
34
|
+
email: ReactElement;
|
|
35
|
+
devLabel: string;
|
|
36
|
+
/** Dev-only detail (URLs / OTPs). Never logged in production. */
|
|
37
|
+
devDetail: string;
|
|
38
|
+
}): Promise<void> {
|
|
39
|
+
const [html, text] = await Promise.all([
|
|
40
|
+
render(opts.email),
|
|
41
|
+
render(opts.email, { plainText: true }),
|
|
42
|
+
]);
|
|
43
|
+
const binding = await emailBinding();
|
|
44
|
+
if (!binding) {
|
|
45
|
+
if (import.meta.env.DEV) {
|
|
46
|
+
console.warn(
|
|
47
|
+
`[email] ${opts.devLabel} for ${opts.to}: ${opts.devDetail}`,
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
console.warn(
|
|
51
|
+
`[email] ${opts.devLabel} skipped (no binding) for ${opts.to}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await binding.send({
|
|
58
|
+
from: { email: env.EMAIL_FROM, name: env.EMAIL_FROM_NAME },
|
|
59
|
+
to: opts.to,
|
|
60
|
+
subject: opts.subject,
|
|
61
|
+
html,
|
|
62
|
+
text,
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (import.meta.env.DEV) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[email] ${opts.devLabel} for ${opts.to}: ${opts.devDetail}`,
|
|
68
|
+
error,
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
console.warn(`[email] ${opts.devLabel} failed for ${opts.to}`, error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The recipient's own language, not the sender's.
|
|
78
|
+
*
|
|
79
|
+
* An email is rendered inside whatever request happened to trigger it — a
|
|
80
|
+
* webhook, a cron, another person's session — so ambient locale is the wrong
|
|
81
|
+
* source. Callers pass the account's stored `user.locale`; an account that has
|
|
82
|
+
* never chosen one falls back to the base locale rather than to the locale of
|
|
83
|
+
* whoever caused the send.
|
|
84
|
+
*/
|
|
85
|
+
type MailLocale = "en" | "de";
|
|
86
|
+
|
|
87
|
+
export function mailLocale(value: string | null | undefined): MailLocale {
|
|
88
|
+
return value === "de" ? "de" : "en";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function sendVerificationEmail(opts: {
|
|
92
|
+
to: string;
|
|
93
|
+
url: string;
|
|
94
|
+
name?: string;
|
|
95
|
+
locale?: string | null;
|
|
96
|
+
}): Promise<void> {
|
|
97
|
+
const locale = mailLocale(opts.locale);
|
|
98
|
+
await send({
|
|
99
|
+
to: opts.to,
|
|
100
|
+
subject: m.email_verify_subject({}, { locale }),
|
|
101
|
+
email: (
|
|
102
|
+
<VerifyEmail
|
|
103
|
+
url={opts.url}
|
|
104
|
+
name={opts.name}
|
|
105
|
+
brandName={env.EMAIL_FROM_NAME}
|
|
106
|
+
locale={locale}
|
|
107
|
+
/>
|
|
108
|
+
),
|
|
109
|
+
devLabel: "verification link",
|
|
110
|
+
devDetail: opts.url,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function sendResetPasswordEmail(opts: {
|
|
115
|
+
to: string;
|
|
116
|
+
url: string;
|
|
117
|
+
name?: string;
|
|
118
|
+
locale?: string | null;
|
|
119
|
+
}): Promise<void> {
|
|
120
|
+
const locale = mailLocale(opts.locale);
|
|
121
|
+
await send({
|
|
122
|
+
to: opts.to,
|
|
123
|
+
subject: m.email_reset_subject({}, { locale }),
|
|
124
|
+
email: (
|
|
125
|
+
<ResetPasswordEmail
|
|
126
|
+
url={opts.url}
|
|
127
|
+
name={opts.name}
|
|
128
|
+
brandName={env.EMAIL_FROM_NAME}
|
|
129
|
+
locale={locale}
|
|
130
|
+
/>
|
|
131
|
+
),
|
|
132
|
+
devLabel: "password reset link",
|
|
133
|
+
devDetail: opts.url,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function sendPasswordOtpEmail(opts: {
|
|
138
|
+
to: string;
|
|
139
|
+
otp: string;
|
|
140
|
+
type: "forget-password" | "sign-in";
|
|
141
|
+
locale?: string | null;
|
|
142
|
+
}): Promise<void> {
|
|
143
|
+
const locale = mailLocale(opts.locale);
|
|
144
|
+
const subject =
|
|
145
|
+
opts.type === "forget-password"
|
|
146
|
+
? m.email_otp_subject_reset({}, { locale })
|
|
147
|
+
: m.email_otp_subject_signin({}, { locale });
|
|
148
|
+
await send({
|
|
149
|
+
to: opts.to,
|
|
150
|
+
subject,
|
|
151
|
+
email: (
|
|
152
|
+
<PasswordOtpEmail
|
|
153
|
+
otp={opts.otp}
|
|
154
|
+
purpose={opts.type}
|
|
155
|
+
brandName={env.EMAIL_FROM_NAME}
|
|
156
|
+
locale={locale}
|
|
157
|
+
/>
|
|
158
|
+
),
|
|
159
|
+
devLabel: "OTP",
|
|
160
|
+
devDetail: opts.otp,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub OAuth public client id — safe to expose in the browser.
|
|
3
|
+
*
|
|
4
|
+
* Server and UI share this value so the button cannot appear when the issuer
|
|
5
|
+
* has no provider (or vice versa). Prefer `VITE_GITHUB_CLIENT_ID` (inlined for
|
|
6
|
+
* both bundles). `GITHUB_CLIENT_ID` is an optional server-only alias that must
|
|
7
|
+
* match when both are set.
|
|
8
|
+
*/
|
|
9
|
+
function githubPublicClientId(): string | undefined {
|
|
10
|
+
const fromVite =
|
|
11
|
+
typeof import.meta.env.VITE_GITHUB_CLIENT_ID === "string"
|
|
12
|
+
? import.meta.env.VITE_GITHUB_CLIENT_ID.trim()
|
|
13
|
+
: "";
|
|
14
|
+
return fromVite || undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True when the sign-in/up UI should offer GitHub. */
|
|
18
|
+
export const GITHUB_OAUTH_ENABLED = Boolean(githubPublicClientId());
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Cookie written by the locale switcher so edge detect (if added) honors choice. */
|
|
2
|
+
export const LOCALE_COOKIE = "PARAGLIDE_LOCALE";
|
|
3
|
+
|
|
4
|
+
export function writeLocaleCookie(locale: string): void {
|
|
5
|
+
if (typeof document === "undefined") return;
|
|
6
|
+
const secure =
|
|
7
|
+
typeof window !== "undefined" && window.location.protocol === "https:"
|
|
8
|
+
? "; Secure"
|
|
9
|
+
: "";
|
|
10
|
+
// biome-ignore lint/suspicious/noDocumentCookie: plain sync cookie write; Cookie Store API not universal.
|
|
11
|
+
document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; samesite=lax${secure}`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every Starter user-facing HTML path that receives a locale URL form.
|
|
3
|
+
*
|
|
4
|
+
* Machine paths (`/api/*`, `/.well-known/*`, webhooks) are intentionally
|
|
5
|
+
* omitted — the Paraglide identity catch-all leaves them unprefixed.
|
|
6
|
+
*
|
|
7
|
+
* Keep this list in sync with real routes under `src/routes/`. Nested
|
|
8
|
+
* dashboard/admin pages must appear here (or as a tree pattern in
|
|
9
|
+
* `paraglide.config.ts`) for full-app URL i18n.
|
|
10
|
+
*/
|
|
11
|
+
export const LOCALIZED_PAGES = [
|
|
12
|
+
"/",
|
|
13
|
+
"/sign-in",
|
|
14
|
+
"/sign-up",
|
|
15
|
+
"/forgot-password",
|
|
16
|
+
"/reset-password",
|
|
17
|
+
"/two-factor",
|
|
18
|
+
"/oauth/consent",
|
|
19
|
+
"/dashboard",
|
|
20
|
+
"/dashboard/account",
|
|
21
|
+
"/admin",
|
|
22
|
+
"/admin/users",
|
|
23
|
+
"/admin/audit",
|
|
24
|
+
"/admin/oauth-apps",
|
|
25
|
+
] as const;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth discovery helpers for the Starter acting as issuer + resource.
|
|
3
|
+
* Pure builders so unit tests do not need Worker env.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function issuerFromAuthUrl(betterAuthUrl: string): string {
|
|
7
|
+
return `${betterAuthUrl.replace(/\/$/, "")}/api/auth`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function buildProtectedResourceMetadata(opts: {
|
|
11
|
+
resource: string;
|
|
12
|
+
issuer: string;
|
|
13
|
+
}) {
|
|
14
|
+
return {
|
|
15
|
+
resource: opts.resource,
|
|
16
|
+
authorization_servers: [opts.issuer],
|
|
17
|
+
scopes_supported: ["openid", "profile", "email", "offline_access"] as const,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createAccessControl } from "better-auth/plugins/access";
|
|
2
|
+
import { adminAc, defaultStatements } from "better-auth/plugins/admin/access";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Access-control statements + roles for the Better Auth admin plugin.
|
|
6
|
+
* Starter roles: user / admin / superadmin only (no product-domain roles).
|
|
7
|
+
*
|
|
8
|
+
* Resources:
|
|
9
|
+
* - `config` — system settings including payment method toggles.
|
|
10
|
+
*/
|
|
11
|
+
export const statement = {
|
|
12
|
+
...defaultStatements, // user: [...], session: [...] from the admin plugin
|
|
13
|
+
config: ["read", "manage"],
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export const ac = createAccessControl(statement);
|
|
17
|
+
|
|
18
|
+
export const user = ac.newRole({
|
|
19
|
+
config: [],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Full admin user/session powers + config. */
|
|
23
|
+
export const admin = ac.newRole({
|
|
24
|
+
...adminAc.statements,
|
|
25
|
+
config: ["read", "manage"],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Alias: superadmin is the same capability set, distinguished only by name. */
|
|
29
|
+
export const superadmin = admin;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Baseline security headers applied to every Worker response. */
|
|
2
|
+
export const BASELINE_SECURITY_HEADERS: Record<string, string> = {
|
|
3
|
+
"X-Content-Type-Options": "nosniff",
|
|
4
|
+
"X-Frame-Options": "DENY",
|
|
5
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
6
|
+
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function withSecurityHeaders(res: Response): Response {
|
|
10
|
+
const headers = new Headers(res.headers);
|
|
11
|
+
for (const [k, v] of Object.entries(BASELINE_SECURITY_HEADERS)) {
|
|
12
|
+
headers.set(k, v);
|
|
13
|
+
}
|
|
14
|
+
// Pass the body through untouched so streaming responses keep streaming.
|
|
15
|
+
return new Response(res.body, {
|
|
16
|
+
status: res.status,
|
|
17
|
+
statusText: res.statusText,
|
|
18
|
+
headers,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site identity helpers for document head (title, OG, JSON-LD).
|
|
3
|
+
* Clone-time: set VITE_APP_TITLE / VITE_APP_ORIGIN (and server APP_ORIGIN).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const trimSlash = (value: string) => value.replace(/\/+$/, "");
|
|
7
|
+
|
|
8
|
+
function readOrigin(): string {
|
|
9
|
+
// Prefer public Vite env (available on client + build).
|
|
10
|
+
const viteOrigin = import.meta.env.VITE_APP_ORIGIN as string | undefined;
|
|
11
|
+
if (viteOrigin?.trim()) return trimSlash(viteOrigin.trim());
|
|
12
|
+
|
|
13
|
+
// Server-only: APP_ORIGIN from process (Workers / Node).
|
|
14
|
+
if (typeof process !== "undefined") {
|
|
15
|
+
const appOrigin = process.env.APP_ORIGIN?.trim();
|
|
16
|
+
if (appOrigin) return trimSlash(appOrigin);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Browser fallback when env is not injected (local preview).
|
|
20
|
+
if (typeof window !== "undefined" && window.location?.origin) {
|
|
21
|
+
return trimSlash(window.location.origin);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Display name for <title>, PWA, and schema.org. */
|
|
28
|
+
export const SITE_NAME =
|
|
29
|
+
(import.meta.env.VITE_APP_TITLE as string | undefined)?.trim() ||
|
|
30
|
+
"TanStack Starter";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Absolute origin used for OG/JSON-LD URLs.
|
|
34
|
+
* Empty when unknown — structured data is then omitted.
|
|
35
|
+
*/
|
|
36
|
+
export const SITE_ORIGIN: string = readOrigin();
|
|
37
|
+
|
|
38
|
+
/** Resolve a site-relative path to an absolute URL when origin is known. */
|
|
39
|
+
export function abs(path: string): string {
|
|
40
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
41
|
+
return SITE_ORIGIN ? `${SITE_ORIGIN}${p}` : p;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_TITLE = `${SITE_NAME} — Platform SaaS starter`;
|
|
45
|
+
export const DEFAULT_DESCRIPTION =
|
|
46
|
+
"Generic SaaS platform shell: auth, roles, Postgres Hyperdrive, email, Turnstile, optional payments, user dashboard, and admin console — no product domain. Clone and brand it.";
|
|
47
|
+
|
|
48
|
+
/** Browser chrome tint — keep in sync with styles.css tokens. */
|
|
49
|
+
export const THEME_COLOR_LIGHT = "#ffffff";
|
|
50
|
+
export const THEME_COLOR_DARK = "#0a0a0a";
|
|
51
|
+
|
|
52
|
+
/** Head meta for auth / dashboard / admin — never index transactional surfaces. */
|
|
53
|
+
export const NOINDEX_META = [
|
|
54
|
+
{ name: "robots", content: "noindex, nofollow" },
|
|
55
|
+
] as const;
|
|
56
|
+
|
|
57
|
+
export function pageTitle(title: string): string {
|
|
58
|
+
return `${title} · ${SITE_NAME}`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type ThemePreference = "light" | "dark" | "system";
|
|
2
|
+
|
|
3
|
+
const THEME_STORAGE_KEY = "theme";
|
|
4
|
+
|
|
5
|
+
export function readThemePreference(): ThemePreference {
|
|
6
|
+
if (typeof window === "undefined") return "system";
|
|
7
|
+
try {
|
|
8
|
+
const raw = localStorage.getItem(THEME_STORAGE_KEY);
|
|
9
|
+
if (raw === "light" || raw === "dark" || raw === "system") return raw;
|
|
10
|
+
} catch {
|
|
11
|
+
// ignore
|
|
12
|
+
}
|
|
13
|
+
return "system";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveDark(preference: ThemePreference): boolean {
|
|
17
|
+
if (preference === "dark") return true;
|
|
18
|
+
if (preference === "light") return false;
|
|
19
|
+
if (typeof window === "undefined") return false;
|
|
20
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Apply theme to <html> and persist preference. */
|
|
24
|
+
export function applyTheme(preference: ThemePreference): void {
|
|
25
|
+
if (typeof document === "undefined") return;
|
|
26
|
+
const dark = resolveDark(preference);
|
|
27
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
28
|
+
document.documentElement.dataset.theme = preference;
|
|
29
|
+
try {
|
|
30
|
+
if (preference === "system") {
|
|
31
|
+
localStorage.removeItem(THEME_STORAGE_KEY);
|
|
32
|
+
} else {
|
|
33
|
+
localStorage.setItem(THEME_STORAGE_KEY, preference);
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore quota / private mode
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Inline FOUC-prevention script for document head. */
|
|
41
|
+
export const THEME_INIT_SCRIPT =
|
|
42
|
+
"try{var k='theme';var t=localStorage.getItem(k);var d=t==='dark'||((t!=='light')&&matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',d);document.documentElement.dataset.theme=t==='light'||t==='dark'?t:'system'}catch(e){}";
|