sveltekit-auth-example 5.6.1 → 5.8.3

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 (51) hide show
  1. package/.env.example +5 -3
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +24 -18
  4. package/db_create.sql +0 -1
  5. package/db_schema.sql +3 -3
  6. package/package.json +8 -5
  7. package/src/app.d.ts +125 -61
  8. package/src/app.html +6 -0
  9. package/src/hooks.server.unit.test.ts +163 -0
  10. package/src/lib/Turnstile.svelte +53 -0
  11. package/src/lib/app-state.svelte.unit.test.ts +73 -0
  12. package/src/lib/auth-redirect.unit.test.ts +92 -0
  13. package/src/lib/fetch-interceptor.unit.test.ts +99 -0
  14. package/src/lib/focus.unit.test.ts +91 -0
  15. package/src/lib/google.ts +41 -17
  16. package/src/lib/google.unit.test.ts +189 -0
  17. package/src/lib/server/brevo.ts +179 -0
  18. package/src/lib/server/brevo.unit.test.ts +186 -0
  19. package/src/lib/server/db.unit.test.ts +91 -0
  20. package/src/lib/server/email/mfa-code.ts +6 -6
  21. package/src/lib/server/email/mfa-code.unit.test.ts +81 -0
  22. package/src/lib/server/email/password-reset.ts +7 -7
  23. package/src/lib/server/email/password-reset.unit.test.ts +70 -0
  24. package/src/lib/server/email/verify-email.ts +7 -7
  25. package/src/lib/server/email/verify-email.unit.test.ts +79 -0
  26. package/src/lib/server/turnstile.ts +29 -0
  27. package/src/lib/server/turnstile.unit.test.ts +111 -0
  28. package/src/routes/api/v1/user/+server.unit.test.ts +114 -0
  29. package/src/routes/auth/[slug]/+server.unit.test.ts +15 -0
  30. package/src/routes/auth/forgot/+server.ts +9 -2
  31. package/src/routes/auth/forgot/+server.unit.test.ts +110 -0
  32. package/src/routes/auth/google/+server.unit.test.ts +132 -0
  33. package/src/routes/auth/login/+server.ts +8 -3
  34. package/src/routes/auth/login/+server.unit.test.ts +221 -0
  35. package/src/routes/auth/logout/+server.unit.test.ts +85 -0
  36. package/src/routes/auth/mfa/+server.ts +8 -3
  37. package/src/routes/auth/mfa/+server.unit.test.ts +153 -0
  38. package/src/routes/auth/register/+server.ts +14 -3
  39. package/src/routes/auth/register/+server.unit.test.ts +182 -0
  40. package/src/routes/auth/reset/+server.ts +8 -2
  41. package/src/routes/auth/reset/+server.unit.test.ts +139 -0
  42. package/src/routes/auth/reset/[token]/+page.svelte +11 -1
  43. package/src/routes/auth/verify/[token]/+server.ts +2 -2
  44. package/src/routes/auth/verify/[token]/+server.unit.test.ts +124 -0
  45. package/src/routes/forgot/+page.svelte +9 -1
  46. package/src/routes/login/+page.svelte +28 -5
  47. package/src/routes/register/+page.svelte +11 -1
  48. package/src/service-worker.unit.test.ts +228 -0
  49. package/svelte.config.js +4 -2
  50. package/vite.config.ts +5 -10
  51. package/src/lib/server/sendgrid.ts +0 -22
package/.env.example CHANGED
@@ -2,6 +2,8 @@ DATABASE_URL=postgres://REPLACE_WITH_USER:REPLACE_WITH_PASSWORD@localhost:5432/a
2
2
  DATABASE_SSL=false
3
3
  DOMAIN=http://localhost:3000
4
4
  JWT_SECRET=replace_with_your_own
5
- SENDGRID_KEY=replace_with_your_own
6
- SENDGRID_SENDER=replace_with_your_own
7
- PUBLIC_GOOGLE_CLIENT_ID=REPLACE_WITH_YOUR_OWN
5
+ BREVO_KEY=replace_with_your_own
6
+ EMAIL=replace_with_your_own
7
+ PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
8
+ PUBLIC_TURNSTILE_SITE_KEY=replace_with_your_own
9
+ TURNSTILE_SECRET_KEY=replace_with_your_own
package/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  - Add password complexity checking on /register and /profile pages (only checks for length currently despite what the pages say)
4
4
 
5
+ # 5.8.3
6
+
7
+ - Add unit tests for all auth route handlers: `/auth/[slug]`, `/auth/forgot`, `/auth/google`, `/auth/login`, `/auth/logout`, `/auth/mfa`, `/auth/register`, `/auth/reset`, `/auth/verify/[token]`
8
+ - Switch all server-side env imports from `$env/static/private` to `$env/dynamic/private` so secrets are read at runtime rather than baked into the build
9
+
10
+ # 5.8.2
11
+
12
+ - Add Chrome DevTools
13
+
14
+ # 5.8.1
15
+
16
+ - Fix MFA verification code always showing "6-digit verification code required" for valid codes: replaced `form.checkValidity()` and `codeInput.checkValidity()` (which picked up Cloudflare Turnstile's injected form elements) with a direct JS regex test against the bound `mfaCode` value
17
+ - Move `<Turnstile>` outside the MFA `<form>` so its injected inputs are never part of the form's element collection
18
+ - Fix `google is not defined` crash on hard reload of `/login`: Google GSI script is loaded `async defer` and may not be ready when `onMount` fires; added `whenGoogleReady()` helper in `src/lib/google.ts` that polls until `google` is available (up to 10 s) before calling `initializeGoogleAccounts()` and `renderGoogleButton()`
19
+
20
+ # 5.8.0
21
+
22
+ - Replace SendGrid with Brevo for all transactional email (password reset, email verification, MFA code)
23
+ - New `src/lib/server/brevo.ts` sends email via the Brevo API with exponential-backoff retry logic (up to 4 attempts), 30-second per-request timeout, and `Retry-After` header support
24
+ - Email templates (`password-reset.ts`, `mfa-code.ts`, `verify-email.ts`) updated to use Brevo message format (`sender`, `to[]`, `htmlContent`, `tags`)
25
+ - Env vars changed: `SENDGRID_KEY` → `BREVO_KEY`, `SENDGRID_SENDER` → `EMAIL`
26
+ - `app.d.ts` `PrivateEnv` updated to reflect new env vars
27
+ - README updated with Brevo setup instructions and new env var names
28
+
29
+ # 5.7.0
30
+
31
+ - Add Cloudflare Turnstile CAPTCHA to login, register, forgot password, MFA, and password reset forms
32
+ - New `src/lib/Turnstile.svelte` reusable Svelte 5 component wraps the Turnstile widget with `reset()` export
33
+ - New `src/lib/server/turnstile.ts` server-side token verification utility (`verifyTurnstileToken`)
34
+ - All auth endpoints verify the Turnstile challenge token before processing requests
35
+ - New env vars: `PUBLIC_TURNSTILE_SITE_KEY` (client) and `TURNSTILE_SECRET_KEY` (server)
36
+ - Extend `app.d.ts` with `PublicEnv`, `PrivateEnv` (add `TURNSTILE_SECRET_KEY`), and `Window.turnstile` types; wrap in `declare global`
37
+
5
38
  # 5.6.1
6
39
 
7
40
  - Add JSDoc comments throughout the codebase (`app.d.ts`, server hooks, route handlers, lib utilities, Svelte components)
package/README.md CHANGED
@@ -4,18 +4,19 @@
4
4
  [![Node](https://img.shields.io/node/v/sveltekit-auth-example)](https://nodejs.org)
5
5
  [![Svelte](https://img.shields.io/badge/Svelte-5-orange)](https://svelte.dev)
6
6
 
7
- A complete, production-ready authentication and authorization starter for **Svelte 5** and **SvelteKit 2**. Skip the boilerplate — get secure local accounts, Google OAuth, MFA, email verification, role-based access control, and OWASP-compliant password hashing out of the box.
7
+ A complete, production-ready authentication and authorization starter for **Svelte 5** and **SvelteKit 2**. Skip the boilerplate — get secure local accounts, Google OAuth, MFA, email verification, role-based access control, OWASP-compliant password hashing, and bot protection out of the box.
8
8
 
9
9
  ## Features
10
10
 
11
- | | |
12
- | ---------------------------------------------- | --------------------------------------- |
13
- | ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
14
- | ✅ Multi-factor authentication (MFA via email) | ✅ Email verification |
15
- | ✅ Forgot password / email reset (SendGrid) | ✅ User profile management |
16
- | ✅ Session management + timeout | ✅ Rate limiting |
17
- | ✅ Role-based access control | ✅ Password complexity enforcement |
18
- | ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
11
+ | | |
12
+ | ------------------------------------------------ | --------------------------------------- |
13
+ | ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
14
+ | ✅ Multi-factor authentication (MFA via email) | ✅ Email verification |
15
+ | ✅ Forgot password / email reset (Brevo) | ✅ User profile management |
16
+ | ✅ Session management + timeout | ✅ Rate limiting |
17
+ | ✅ Role-based access control | ✅ Password complexity enforcement |
18
+ | ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
19
+ | ✅ Cloudflare Turnstile CAPTCHA (bot protection) | |
19
20
 
20
21
  ## Stack
21
22
 
@@ -28,8 +29,9 @@ A complete, production-ready authentication and authorization starter for **Svel
28
29
 
29
30
  - Node.js 24.14.0 or later
30
31
  - PostgreSQL 16 or later
31
- - A [SendGrid](https://sendgrid.com) account (for password reset emails)
32
+ - A [Brevo](https://brevo.com) account (for transactional emails)
32
33
  - A [Google API client ID](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid) (for Sign in with Google)
34
+ - A [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/) site key and secret key (for bot protection on auth forms)
33
35
 
34
36
  ## Setting up the project
35
37
 
@@ -49,7 +51,7 @@ bash db_create.sh
49
51
 
50
52
  2. Create a **Google API client ID** per [these instructions](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid). Make sure you include `http://localhost:3000` and `http://localhost` in the Authorized JavaScript origins, and `http://localhost:3000/auth/google/callback` in the Authorized redirect URIs for your Client ID for Web application. **Do not access the site using http://127.0.0.1:3000** — use `http://localhost:3000` or it will not work.
51
53
 
52
- 3. [Create a free Twilio SendGrid account](https://signup.sendgrid.com) and generate an API Key following [this documentation](https://docs.sendgrid.com/ui/account-and-settings/api-keys) and add a sender as documented [here](https://docs.sendgrid.com/ui/sending-email/senders).
54
+ 3. [Create a free Brevo account](https://app.brevo.com/account/register) and generate an API Key under **SMTP & API** settings. Set `EMAIL` to the sender address verified in your Brevo account.
53
55
 
54
56
  4. Create a **.env** file at the top level of the project with the following values (substituting your own id and PostgreSQL username and password):
55
57
 
@@ -58,9 +60,11 @@ DATABASE_URL=postgres://user:password@localhost:5432/auth
58
60
  DATABASE_SSL=false
59
61
  DOMAIN=http://localhost:3000
60
62
  JWT_SECRET=replace_with_your_own
61
- SENDGRID_KEY=replace_with_your_own
62
- SENDGRID_SENDER=replace_with_your_own
63
+ BREVO_KEY=replace_with_your_own
64
+ EMAIL=replace_with_your_own
63
65
  PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
66
+ PUBLIC_TURNSTILE_SITE_KEY=replace_with_your_own
67
+ TURNSTILE_SECRET_KEY=replace_with_your_own
64
68
  ```
65
69
 
66
70
  ## Run locally
@@ -84,11 +88,13 @@ yarn preview
84
88
 
85
89
  The db_create.sql script adds three users to the database with obvious roles:
86
90
 
87
- | Email | Password | Role |
88
- | ------------------- | ---------- | ------- |
89
- | admin@example.com | admin123 | admin |
90
- | teacher@example.com | teacher123 | teacher |
91
- | student@example.com | student123 | student |
91
+ | Email | Password | Role |
92
+ | ------------------- | ------------ | ------- |
93
+ | admin@example.com | Admin1234! | admin |
94
+ | teacher@example.com | Teacher1234! | teacher |
95
+ | student@example.com | Student1234! | student |
96
+
97
+ > **MFA note:** Local account logins require a 6-digit code sent to the user's email address. To successfully log in with the seed accounts above, either update their email addresses in the database to your own (`UPDATE users SET email = 'you@yourdomain.com' WHERE email = 'admin@example.com';`), or retrieve the code directly from the `mfa_codes` table after submitting the login form (`SELECT code FROM mfa_codes;`).
92
98
 
93
99
  ## How it works
94
100
 
package/db_create.sql CHANGED
@@ -1,6 +1,5 @@
1
1
  -- Run via db_create.sh or:
2
2
  -- $ psql -d postgres -f db_create.sql && psql -d auth -f db_schema.sql
3
-
4
3
  -- Create role if not already there
5
4
  DO $do$
6
5
  BEGIN
package/db_schema.sql CHANGED
@@ -357,13 +357,13 @@ $BODY$;
357
357
  ALTER FUNCTION public.verify_mfa_code (text, text) OWNER TO auth;
358
358
 
359
359
  CALL public.upsert_user (
360
- '{"id":0, "role":"admin", "email":"admin@example.com", "password":"admin123", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json
360
+ '{"id":0, "role":"admin", "email":"admin@example.com", "password":"Admin1234!", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json
361
361
  );
362
362
 
363
363
  CALL public.upsert_user (
364
- '{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"teacher123", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json
364
+ '{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"Teacher1234!", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json
365
365
  );
366
366
 
367
367
  CALL public.upsert_user (
368
- '{"id":0, "role":"student", "email":"student@example.com", "password":"student123", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json
368
+ '{"id":0, "role":"student", "email":"student@example.com", "password":"Student1234!", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json
369
369
  );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sveltekit-auth-example",
3
3
  "description": "SvelteKit Authentication Example",
4
- "version": "5.6.1",
4
+ "version": "5.8.3",
5
5
  "author": "Nate Stuyvesant",
6
6
  "license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
7
7
  "repository": {
@@ -49,28 +49,31 @@
49
49
  "@sveltejs/adapter-node": "^5.5.4",
50
50
  "@sveltejs/kit": "^2.54.0",
51
51
  "@sveltejs/mcp": "^0.1.21",
52
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
52
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
53
53
  "@tailwindcss/vite": "^4.2.1",
54
54
  "@types/bootstrap": "5.2.10",
55
55
  "@types/google.accounts": "^0.0.18",
56
56
  "@types/jsonwebtoken": "^9.0.10",
57
57
  "@types/pg": "^8.18.0",
58
+ "@vitest/coverage-v8": "4.1.0",
58
59
  "eslint": "^10.0.3",
59
60
  "eslint-config-prettier": "^10.1.8",
60
61
  "eslint-plugin-svelte": "^3.15.2",
61
62
  "globals": "^17.4.0",
63
+ "jsdom": "^28.1.0",
62
64
  "playwright": "^1.58.2",
63
65
  "prettier": "^3.8.1",
64
66
  "prettier-plugin-sql": "^0.19.2",
65
67
  "prettier-plugin-svelte": "^3.5.1",
66
68
  "prettier-plugin-tailwindcss": "^0.7.2",
67
- "svelte": "^5.53.10",
69
+ "svelte": "^5.53.11",
68
70
  "svelte-check": "^4.4.5",
69
71
  "tslib": "^2.8.1",
70
72
  "typescript": "^5.9.3",
71
73
  "typescript-eslint": "^8.57.0",
72
- "vite": "^7.3.1",
73
- "vitest": "^4.0.18",
74
+ "vite": "^8.0.0",
75
+ "vite-plugin-devtools-json": "^1.0.0",
76
+ "vitest": "^4.1.0",
74
77
  "vitest-browser-svelte": "^2.0.2"
75
78
  },
76
79
  "packageManager": "yarn@4.13.0"
package/src/app.d.ts CHANGED
@@ -3,75 +3,139 @@
3
3
  // See https://kit.svelte.dev/docs/types#app
4
4
  // for information about these interfaces
5
5
  // and what to do when importing types
6
- declare namespace App {
7
- /** Per-request server-side locals populated by `hooks.server.ts`. */
8
- interface Locals {
9
- /** The authenticated user, or `undefined` when no valid session exists. */
10
- user: User | undefined
11
- }
6
+ declare global {
7
+ declare namespace App {
8
+ /** Per-request server-side locals populated by `hooks.server.ts`. */
9
+ interface Locals {
10
+ /** The authenticated user, or `undefined` when no valid session exists. */
11
+ user: User | undefined
12
+ }
13
+
14
+ // interface Platform {}
15
+
16
+ /** Error response returned by the Brevo email API. */
17
+ interface BrevoErrorResponse {
18
+ /** Optional machine‑readable error code. */
19
+ code?: string
20
+ /** Human‑readable error message. */
21
+ message?: string
22
+ }
23
+
24
+ /** Successful response returned by the Brevo email API. */
25
+ interface BrevoSuccessResponse {
26
+ /** Message identifier assigned by Brevo. */
27
+ messageId: string
28
+ }
12
29
 
13
- // interface Platform {}
30
+ /** Basic email address with optional display name. */
31
+ interface EmailAddress {
32
+ /** Email address string. */
33
+ email: string
34
+ /** Optional display name associated with the address. */
35
+ name?: string
36
+ }
14
37
 
15
- /** Private environment variables (server-side only). */
16
- interface PrivateEnv {
17
- // $env/static/private
18
- /** PostgreSQL connection string. */
19
- DATABASE_URL: string
20
- /** Public-facing domain used to construct email links (e.g. `https://example.com`). */
21
- DOMAIN: string
22
- /** Secret key used to sign and verify JWTs. */
23
- JWT_SECRET: string
24
- /** SendGrid API key. */
25
- SENDGRID_KEY: string
26
- /** Default sender email address for outgoing mail. */
27
- SENDGRID_SENDER: string
38
+ /** Payload used when sending email via Brevo. */
39
+ interface EmailMessageBrevo {
40
+ /** Primary recipients. */
41
+ to: EmailAddress[]
42
+ /** Carbon‑copy recipients. */
43
+ cc?: EmailAddress[]
44
+ /** Blind carbon‑copy recipients. */
45
+ bcc?: EmailAddress[]
46
+ /** Sender address. */
47
+ sender: EmailAddress
48
+ /** Optional reply‑to address. */
49
+ replyTo?: EmailAddress
50
+ /** Subject line for the email. */
51
+ subject: string
52
+ /** Additional SMTP or provider‑specific headers. */
53
+ headers?: Record<string, string>
54
+ /** Tag names applied to the message. */
55
+ tags?: string[]
56
+ /** HTML content of the email. */
57
+ htmlContent?: string
58
+ /** Plain‑text content of the email. */
59
+ textContent?: string
60
+ /** Attachments encoded as base64 strings. */
61
+ attachment?: Array<{ content: string; name: string }>
62
+ }
63
+
64
+ /** Private environment variables (server-side only). */
65
+ interface PrivateEnv {
66
+ // $env/static/private
67
+ /** PostgreSQL connection string. */
68
+ DATABASE_URL: string
69
+ /** Public-facing domain used to construct email links (e.g. `https://example.com`). */
70
+ DOMAIN: string
71
+ /** Secret key used to sign and verify JWTs. */
72
+ JWT_SECRET: string
73
+ /** Brevo (Sendinblue) API key. */
74
+ BREVO_KEY: string
75
+ /** Default sender email address for outgoing mail. */
76
+ EMAIL: string
77
+ /** Cloudflare Turnstile secret key used to verify challenge tokens server-side. */
78
+ TURNSTILE_SECRET_KEY: string
79
+ }
80
+
81
+ /** Public environment variables (safe to expose to the client). */
82
+ interface PublicEnv {
83
+ // $env/static/public
84
+ /** Google OAuth 2.0 client ID for Google Sign-In. */
85
+ PUBLIC_GOOGLE_CLIENT_ID: string
86
+ /** Cloudflare Turnstile site key rendered in the browser widget. */
87
+ PUBLIC_TURNSTILE_SITE_KEY: string
88
+ }
28
89
  }
29
90
 
30
- /** Public environment variables (safe to expose to the client). */
31
- interface PublicEnv {
32
- // $env/static/public
33
- /** Google OAuth 2.0 client ID for Google Sign-In. */
34
- PUBLIC_GOOGLE_CLIENT_ID: string
91
+ /** Result returned by the `authenticate` and `register` SQL functions. */
92
+ interface AuthenticationResult {
93
+ /** HTTP status code to use when the operation fails. */
94
+ statusCode: NumericRange<400, 599>
95
+ /** Human-readable status message. */
96
+ status: string
97
+ /** The authenticated or registered user, if successful. */
98
+ user: User
99
+ /** The newly created session ID. */
100
+ sessionId: string
35
101
  }
36
- }
37
102
 
38
- /** Result returned by the `authenticate` and `register` SQL functions. */
39
- interface AuthenticationResult {
40
- /** HTTP status code to use when the operation fails. */
41
- statusCode: NumericRange<400, 599>
42
- /** Human-readable status message. */
43
- status: string
44
- /** The authenticated or registered user, if successful. */
45
- user: User
46
- /** The newly created session ID. */
47
- sessionId: string
48
- }
103
+ /** Raw login credentials submitted by the user. */
104
+ interface Credentials {
105
+ email: string
106
+ password: string
107
+ }
49
108
 
50
- /** Raw login credentials submitted by the user. */
51
- interface Credentials {
52
- email: string
53
- password: string
54
- }
109
+ /** Persistent properties stored in the database for a user account. */
110
+ interface UserProperties {
111
+ id: number
112
+ /** ISO-8601 datetime at which the current session expires. */
113
+ expires?: string
114
+ role: 'student' | 'teacher' | 'admin'
115
+ password?: string
116
+ firstName?: string
117
+ lastName?: string
118
+ email?: string
119
+ phone?: string
120
+ }
55
121
 
56
- /** Persistent properties stored in the database for a user account. */
57
- interface UserProperties {
58
- id: number
59
- /** ISO-8601 datetime at which the current session expires. */
60
- expires?: string
61
- role: 'student' | 'teacher' | 'admin'
62
- password?: string
63
- firstName?: string
64
- lastName?: string
65
- email?: string
66
- phone?: string
67
- }
122
+ /** A user record, or `undefined`/`null` when unauthenticated. */
123
+ type User = UserProperties | undefined | null
68
124
 
69
- /** A user record, or `undefined`/`null` when unauthenticated. */
70
- type User = UserProperties | undefined | null
125
+ /** A database session paired with its associated user. */
126
+ interface UserSession {
127
+ /** UUID session identifier stored in the `session` cookie. */
128
+ id: string
129
+ user: User
130
+ }
71
131
 
72
- /** A database session paired with its associated user. */
73
- interface UserSession {
74
- /** UUID session identifier stored in the `session` cookie. */
75
- id: string
76
- user: User
132
+ interface Window {
133
+ turnstile?: {
134
+ render: (container: HTMLElement, options: Record<string, unknown>) => string
135
+ reset: (widgetId: string) => void
136
+ remove: (widgetId: string) => void
137
+ }
138
+ }
77
139
  }
140
+
141
+ export {}
package/src/app.html CHANGED
@@ -11,6 +11,12 @@
11
11
  async
12
12
  defer
13
13
  ></script>
14
+ <script
15
+ nonce="%sveltekit.nonce%"
16
+ src="https://challenges.cloudflare.com/turnstile/v0/api.js"
17
+ async
18
+ defer
19
+ ></script>
14
20
  %sveltekit.head%
15
21
  </head>
16
22
  <body data-sveltekit-preload-data="hover">
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { error } from '@sveltejs/kit'
3
+
4
+ vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
5
+
6
+ import { handle } from './hooks.server'
7
+ import { query } from '$lib/server/db'
8
+
9
+ const mockQuery = vi.mocked(query)
10
+
11
+ /** Build a minimal event object for the hook. */
12
+ function makeEvent({
13
+ pathname = '/page',
14
+ sessionCookie = undefined as string | undefined,
15
+ ip = '1.2.3.4'
16
+ } = {}) {
17
+ const cookieStore = new Map<string, string>()
18
+ if (sessionCookie) cookieStore.set('session', sessionCookie)
19
+
20
+ return {
21
+ url: new URL(`http://localhost${pathname}`),
22
+ cookies: {
23
+ get: (name: string) => cookieStore.get(name),
24
+ delete: vi.fn()
25
+ },
26
+ locals: {} as Record<string, unknown>,
27
+ getClientAddress: () => ip
28
+ } as unknown as Parameters<typeof handle>[0]['event']
29
+ }
30
+
31
+ const resolve = vi.fn(async (event: unknown) => new Response('ok', { status: 200 }))
32
+
33
+ beforeEach(() => {
34
+ vi.resetAllMocks()
35
+ resolve.mockResolvedValue(new Response('ok', { status: 200 }))
36
+ })
37
+
38
+ // ── Static asset bypass ────────────────────────────────────────────────────────
39
+
40
+ describe('static asset bypass', () => {
41
+ it('calls resolve immediately for /_app/ paths without touching the db', async () => {
42
+ const event = makeEvent({ pathname: '/_app/immutable/app.js' })
43
+
44
+ await handle({ event, resolve })
45
+
46
+ expect(resolve).toHaveBeenCalledOnce()
47
+ expect(mockQuery).not.toHaveBeenCalled()
48
+ })
49
+ })
50
+
51
+ // ── Rate limiting ──────────────────────────────────────────────────────────────
52
+
53
+ describe('rate limiting', () => {
54
+ it('allows requests under the limit', async () => {
55
+ const event = makeEvent({ pathname: '/auth/login', ip: '10.0.0.1' })
56
+
57
+ await expect(handle({ event, resolve })).resolves.not.toThrow()
58
+ })
59
+
60
+ it('throws 429 after exceeding the request limit for a rate-limited path', async () => {
61
+ const ip = '10.0.0.99'
62
+
63
+ // Exhaust the 20-request allowance
64
+ for (let i = 0; i < 20; i++) {
65
+ await handle({ event: makeEvent({ pathname: '/auth/login', ip }), resolve }).catch(() => {})
66
+ }
67
+
68
+ await expect(
69
+ handle({ event: makeEvent({ pathname: '/auth/login', ip }), resolve })
70
+ ).rejects.toMatchObject({ status: 429 })
71
+ })
72
+
73
+ it('does not rate-limit non-auth paths', async () => {
74
+ const ip = '10.0.0.2'
75
+
76
+ for (let i = 0; i < 25; i++) {
77
+ await handle({ event: makeEvent({ pathname: '/about', ip }), resolve })
78
+ }
79
+
80
+ expect(resolve).toHaveBeenCalledTimes(25)
81
+ })
82
+ })
83
+
84
+ // ── Session handling ───────────────────────────────────────────────────────────
85
+
86
+ describe('session handling', () => {
87
+ it('attaches user to locals when a valid session cookie is present', async () => {
88
+ const user = { id: 1, role: 'admin' }
89
+ mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: user }] } as any)
90
+
91
+ const event = makeEvent({ sessionCookie: 'valid-session-uuid' })
92
+
93
+ await handle({ event, resolve })
94
+
95
+ expect(event.locals.user).toEqual(user)
96
+ })
97
+
98
+ it('does not call query when no session cookie is present', async () => {
99
+ const event = makeEvent()
100
+
101
+ await handle({ event, resolve })
102
+
103
+ expect(mockQuery).not.toHaveBeenCalled()
104
+ })
105
+
106
+ it('deletes the session cookie when the session is not found in the db', async () => {
107
+ mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: undefined }] } as any)
108
+
109
+ const event = makeEvent({ sessionCookie: 'stale-session-uuid' })
110
+
111
+ await handle({ event, resolve })
112
+
113
+ expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
114
+ })
115
+
116
+ it('deletes the session cookie when no session cookie is present and locals.user is unset', async () => {
117
+ const event = makeEvent()
118
+
119
+ await handle({ event, resolve })
120
+
121
+ expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
122
+ })
123
+
124
+ it('does not delete the session cookie when a valid session exists', async () => {
125
+ const user = { id: 2, role: 'student' }
126
+ mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: user }] } as any)
127
+
128
+ const event = makeEvent({ sessionCookie: 'valid-session-uuid' })
129
+
130
+ await handle({ event, resolve })
131
+
132
+ expect(event.cookies.delete).not.toHaveBeenCalled()
133
+ })
134
+
135
+ it('uses the named prepared statement for session lookup', async () => {
136
+ mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: { id: 3 } }] } as any)
137
+
138
+ const event = makeEvent({ sessionCookie: 'some-uuid' })
139
+
140
+ await handle({ event, resolve })
141
+
142
+ expect(mockQuery).toHaveBeenCalledWith(
143
+ expect.stringContaining('get_and_update_session'),
144
+ ['some-uuid'],
145
+ 'get-and-update-session'
146
+ )
147
+ })
148
+ })
149
+
150
+ // ── Response pass-through ──────────────────────────────────────────────────────
151
+
152
+ describe('response', () => {
153
+ it('returns the response from resolve', async () => {
154
+ const fakeResponse = new Response('hello', { status: 200 })
155
+ resolve.mockResolvedValue(fakeResponse)
156
+
157
+ const event = makeEvent()
158
+
159
+ const result = await handle({ event, resolve })
160
+
161
+ expect(result).toBe(fakeResponse)
162
+ })
163
+ })
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte'
3
+ import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'
4
+
5
+ interface Props {
6
+ token: string
7
+ theme?: 'light' | 'dark' | 'auto'
8
+ }
9
+
10
+ let { token = $bindable(''), theme = 'auto' }: Props = $props()
11
+
12
+ let container: HTMLDivElement | undefined = $state()
13
+ let widgetId: string | undefined
14
+
15
+ /** Resets the Turnstile widget and clears the token. Call this after a failed submission. */
16
+ export function reset() {
17
+ if (widgetId !== undefined && typeof window !== 'undefined' && window.turnstile) {
18
+ window.turnstile.reset(widgetId)
19
+ token = ''
20
+ }
21
+ }
22
+
23
+ onMount(() => {
24
+ const tryRender = () => {
25
+ if (!container || !window.turnstile) {
26
+ setTimeout(tryRender, 50)
27
+ return
28
+ }
29
+ widgetId = window.turnstile.render(container, {
30
+ sitekey: PUBLIC_TURNSTILE_SITE_KEY,
31
+ theme,
32
+ callback: (t: string) => {
33
+ token = t
34
+ },
35
+ 'expired-callback': () => {
36
+ token = ''
37
+ },
38
+ 'error-callback': () => {
39
+ token = ''
40
+ }
41
+ })
42
+ }
43
+ tryRender()
44
+ })
45
+
46
+ onDestroy(() => {
47
+ if (widgetId !== undefined && window.turnstile) {
48
+ window.turnstile.remove(widgetId)
49
+ }
50
+ })
51
+ </script>
52
+
53
+ <div bind:this={container}></div>