@netlify/agent-runner-cli 2.17.0-PAW-612.0 → 2.18.0-smtp-3.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.
@@ -1,682 +1,378 @@
1
1
  ---
2
2
  name: netlify-identity
3
- description: Use whenever the task involves authentication, signups, logins, password recovery, user accounts, role-based access control, OAuth providers, protected routes, protecting Netlify Functions, server-side auth in SSR frameworks, or Netlify Identity. Always use `@netlify/identity`. Never use `netlify-identity-widget` or `gotrue-js` as they are deprecated. Make sure to always run `node scripts/enable.cjs` located in this skill's directory after implementing Netlify Identity.
3
+ description: Add user authentication to a Netlify site with @netlify/identity, covering signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task involves adding a login or signup form, gating content to members or roles, "auth middleware" or verifying users in Netlify Functions or Edge Functions, handling OAuth or email-confirmation callbacks, accepting user invites, assigning roles at signup, customizing Identity emails, or finding out why a specific user can't sign up or log in. For locking a whole site to your company or employees-only access, use netlify-access-control instead.
4
4
  ---
5
5
 
6
6
  # Netlify Identity
7
7
 
8
- Netlify Identity is a user management service for signups, logins, password recovery, user metadata, and role-based
9
- access control. It is built on [GoTrue](https://github.com/netlify/gotrue) and issues JSON Web Tokens (JWTs).
10
-
11
- ## Enable Feature
12
-
13
- After implementing Netlify Identity, you **must** run the enable script to activate the feature on the deploy. The script
14
- is located in this skill's `scripts/` directory:
15
-
16
- ```bash
17
- node scripts/enable.cjs
18
- ```
19
-
20
- **NEVER use `netlify-identity-widget` or `gotrue-js`. Always use `@netlify/identity`.** It provides a unified, headless
21
- TypeScript API that works in both browser and server contexts (Netlify Functions, Edge Functions, SSR frameworks). It
22
- replaces all previous Identity client libraries.
23
-
24
- ## Setup
8
+ Use `@netlify/identity` (npm). For new projects it replaces the legacy `netlify-identity-widget` and `gotrue-js`. Do not reach for those.
25
9
 
26
10
  ```bash
27
11
  npm install @netlify/identity
28
12
  ```
29
13
 
30
- Identity is enabled on the site when you run the enable script above. Default settings:
14
+ Framework examples (Next.js/Astro/Remix/SvelteKit) and the full API reference are in the [`@netlify/identity` README on npm](https://www.npmjs.com/package/@netlify/identity).
31
15
 
32
- - **Registration** - Open (anyone can sign up). Change to Invite only in **Project configuration > Identity** if needed.
33
- - **Autoconfirm** - Off (new signups require email confirmation). Enable in **Project configuration > Identity** to skip
34
- confirmation during development.
16
+ > **Identity does not run under `netlify dev`.** Test all auth flows on a deploy. Deploy Previews work. Local dev will not complete signup/login/OAuth.
35
17
 
36
- For local development, use `netlify dev` so the Identity endpoint is available.
18
+ > **Identity config is dashboard-only. There is no public API for Identity settings.** Never curl `api.netlify.com` to flip or read Identity settings, never read tokens from local Netlify config, and never probe undocumented endpoints. The one exception is reading the site's `identity_instance_id` and a single user's account with the lookup described under Additional requirements. Configure Identity at `https://app.netlify.com/projects/{site_name}/identity`. Enable it using this skill's `scripts/enable.cjs`, as described under Additional requirements.
37
19
 
38
- ## Quick Start
20
+ > **Never build a from-scratch OAuth flow alongside Identity.** No provider app registration in code, no `client_id`/`secret` in source, and no custom callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`. Raw OAuth beside Identity is the most common source of rework.
39
21
 
40
- Log in from the browser:
22
+ ## Client auth (browser)
41
23
 
42
- ```typescript
43
- import { login, getUser } from '@netlify/identity'
24
+ ```ts
25
+ import { signup, login, logout, getUser, oauthLogin, handleAuthCallback } from '@netlify/identity'
44
26
 
45
- const user = await login('jane@example.com', 'password123')
46
- console.log(`Hello, ${user.name}`)
27
+ // Register — confirmation email sent by default (unless autoconfirm is on)
28
+ const user = await signup('jane@example.com', 'securepassword', { full_name: 'Jane Doe' })
47
29
 
48
- // Later, check auth state
49
- const currentUser = await getUser()
50
- ```
30
+ // Log in / out
31
+ await login('jane@example.com', 'securepassword')
32
+ await logout()
51
33
 
52
- Protect a Netlify Function:
34
+ // Current user or null
35
+ const current = await getUser()
36
+ if (current) console.log(`Logged in as ${current.email}`)
53
37
 
54
- ```typescript
55
- // netlify/functions/protected.mts
56
- import { getUser } from '@netlify/identity'
57
- import type { Context } from '@netlify/functions'
58
-
59
- export default async (req: Request, context: Context) => {
60
- const user = await getUser()
61
- if (!user) return new Response('Unauthorized', { status: 401 })
62
- return Response.json({ id: user.id, email: user.email })
63
- }
38
+ // External provider — redirects the browser; provider is one of
39
+ // 'google' | 'github' | 'gitlab' | 'bitbucket'
40
+ oauthLogin('github')
64
41
  ```
65
42
 
66
- ## Error Handling
67
-
68
- `@netlify/identity` throws two error classes:
43
+ > **`handleAuthCallback()` is mandatory on your landing page.** Without it, OAuth redirects, email-confirmation links, password-recovery links, and invite links never complete. Call it on page load.
69
44
 
70
- - **`AuthError`** - Thrown by auth operations (login, signup, logout, etc.). Has `message`, optional `status` (HTTP
71
- status code from GoTrue), and optional `cause` (original error).
72
- - **`MissingIdentityError`** - Thrown when Identity is not configured in the current environment (site doesn't have
73
- Identity enabled, or not running via `netlify dev`).
45
+ > **Invites are NOT logged in by `handleAuthCallback()`.** For an invite it returns `type === 'invite'` with the token in `result.token` and `result.user === null`. The account does not exist yet. You must collect a password and call `acceptInvite(result.token, password)`. If you skip this step, invited users never get an account.
74
46
 
75
- `getUser()` and `isAuthenticated()` never throw - they return `null` and `false` respectively on failure.
47
+ ```ts
48
+ import { handleAuthCallback, acceptInvite } from '@netlify/identity'
76
49
 
77
- ### Try/Catch Pattern
78
-
79
- ```typescript
80
- import { login, AuthError, MissingIdentityError } from '@netlify/identity'
81
-
82
- try {
83
- const user = await login(email, password)
84
- } catch (error) {
85
- if (error instanceof MissingIdentityError) {
86
- // Identity not configured - show setup instructions
87
- showError('Identity is not enabled on this site.')
88
- return
89
- }
90
- if (error instanceof AuthError) {
91
- switch (error.status) {
92
- case 401:
93
- showError('Invalid email or password.')
94
- break
95
- case 403:
96
- showError('Signups are not allowed for this site.')
97
- break
98
- case 422:
99
- showError('Invalid input. Check your email and password.')
100
- break
101
- case 404:
102
- showError('User not found.')
103
- break
104
- default:
105
- showError(error.message)
106
- }
107
- return
108
- }
109
- throw error
50
+ const result = await handleAuthCallback() // falsy if no token in URL hash
51
+ if (result?.type === 'invite' && result.token) {
52
+ // Invited user has no account yet. Ask them to choose a password first.
53
+ const password = (document.getElementById('password') as HTMLInputElement).value
54
+ const user = await acceptInvite(result.token, password)
55
+ console.log('Account created:', user.email)
56
+ } else if (result) {
57
+ // OAuth, email confirmation, recovery, or email change
58
+ console.log(result.type, result.user?.email)
110
59
  }
111
60
  ```
112
61
 
113
- ### Common Status Codes
62
+ For recovery tokens you can call `recoverPassword()` directly instead. Refresh a session with `refreshSession()`.
114
63
 
115
- | Status | Meaning |
116
- |--------|---------|
117
- | 401 | Invalid credentials or expired token |
118
- | 403 | Action not allowed (e.g., signups disabled) |
119
- | 422 | Validation error (e.g., weak password, malformed email) |
120
- | 404 | User or resource not found |
64
+ Don't hard-code which providers exist. Call `getSettings()` at startup and render the signup form and OAuth buttons from what it returns.
121
65
 
122
- ## Authentication Flows
66
+ ## Server-side auth (Netlify Functions & Edge Functions)
123
67
 
124
- ### Login
68
+ Server-side `getUser()`/`login()`/`admin.*` require modern **v2 functions** (`export default`). The v1 `export { handler }` form is not supported.
125
69
 
126
- ```typescript
127
- import { login, AuthError } from '@netlify/identity'
128
-
129
- let loading = false
130
-
131
- async function handleLogin(email: string, password: string) {
132
- loading = true
133
- try {
134
- const user = await login(email, password)
135
- showSuccess(`Welcome back, ${user.name ?? user.email}`)
136
- } catch (error) {
137
- if (error instanceof AuthError) {
138
- showError(error.status === 401 ? 'Invalid email or password.' : error.message)
139
- }
140
- } finally {
141
- loading = false
142
- }
143
- }
144
- ```
145
-
146
- ### Signup
147
-
148
- After signup, check `user.emailVerified` to determine if the user was auto-confirmed (logged in immediately) or needs
149
- to confirm their email first.
150
-
151
- ```typescript
152
- import { signup, AuthError } from '@netlify/identity'
153
-
154
- async function handleSignup(email: string, password: string, name: string) {
155
- try {
156
- const user = await signup(email, password, { full_name: name })
157
- if (user.emailVerified) {
158
- // Autoconfirm is ON - user is logged in
159
- showSuccess('Account created. You are now logged in.')
160
- } else {
161
- // Autoconfirm is OFF - confirmation email sent, user is NOT logged in
162
- showSuccess('Check your email to confirm your account.')
163
- }
164
- } catch (error) {
165
- if (error instanceof AuthError) {
166
- showError(error.status === 403 ? 'Signups are not allowed.' : error.message)
167
- }
168
- }
169
- }
170
- ```
171
-
172
- When autoconfirm is off, the confirmation email contains a link that redirects the user back to the site with
173
- `#confirmation_token=<token>` in the URL hash. `handleAuthCallback()` processes this automatically - it calls
174
- `confirmEmail()` under the hood, logs the user in, and returns `{ type: 'confirmation', user }`. This is why
175
- `handleAuthCallback()` must be called on page load (see the OAuth section below for the full switch).
70
+ `getUser()` works in both runtimes. **`admin.*` runs ONLY in Netlify Functions. It does not run in the browser or in Edge Functions.**
176
71
 
177
- ### OAuth
178
-
179
- OAuth is a two-step flow: `oauthLogin(provider)` redirects away from the site, then `handleAuthCallback()` processes
180
- the redirect when the user returns.
181
-
182
- ```typescript
183
- import { oauthLogin } from '@netlify/identity'
184
-
185
- // Step 1: Redirect to OAuth provider (this navigates away - never returns)
186
- function handleOAuthClick(provider: 'google' | 'github' | 'gitlab' | 'bitbucket') {
187
- oauthLogin(provider)
188
- }
189
- ```
190
-
191
- ```typescript
192
- import { handleAuthCallback, AuthError } from '@netlify/identity'
193
-
194
- // Step 2: Process the redirect on page load
195
- async function processCallback() {
196
- try {
197
- const result = await handleAuthCallback()
198
- if (!result) return // No callback hash present - normal page load
199
-
200
- switch (result.type) {
201
- case 'oauth':
202
- showSuccess(`Logged in as ${result.user?.email}`)
203
- break
204
- case 'confirmation':
205
- showSuccess('Email confirmed. You are now logged in.')
206
- break
207
- case 'recovery':
208
- // User is authenticated but must set a new password
209
- showPasswordResetForm(result.user)
210
- break
211
- case 'invite':
212
- // User must set a password to accept the invite
213
- showInviteAcceptForm(result.token)
214
- break
215
- case 'email_change':
216
- showSuccess('Email address updated.')
217
- break
218
- }
219
- } catch (error) {
220
- if (error instanceof AuthError) {
221
- showError(error.message)
222
- }
223
- }
224
- }
225
- ```
226
-
227
- Always call `handleAuthCallback()` on page load in any app that uses OAuth, password recovery, invites, or email
228
- confirmation. It handles all callback types via the URL hash.
229
-
230
- ### Password Recovery
231
-
232
- Three-step flow: request recovery email, handle the callback, then set a new password.
233
-
234
- ```typescript
235
- import { requestPasswordRecovery, handleAuthCallback, updateUser, AuthError } from '@netlify/identity'
236
-
237
- // Step 1: Send recovery email
238
- async function handleForgotPassword(email: string) {
239
- try {
240
- await requestPasswordRecovery(email)
241
- showSuccess('Check your email for a password reset link.')
242
- } catch (error) {
243
- if (error instanceof AuthError) showError(error.message)
244
- }
245
- }
246
-
247
- // Step 2: handleAuthCallback() returns { type: 'recovery', user } - show password reset form
248
- // (See the handleAuthCallback switch above)
249
-
250
- // Step 3: Set new password
251
- async function handlePasswordReset(newPassword: string) {
252
- try {
253
- await updateUser({ password: newPassword })
254
- showSuccess('Password updated.')
255
- } catch (error) {
256
- if (error instanceof AuthError) showError(error.message)
257
- }
258
- }
259
- ```
260
-
261
- Note: The recovery callback fires a `'recovery'` auth event, not `'login'`. The user is authenticated but should be
262
- prompted to set a new password before navigating away.
263
-
264
- ### Invite Acceptance
265
-
266
- When a user clicks an invite link, `handleAuthCallback()` returns `{ type: 'invite', user: null, token }`. Use the
267
- token to accept the invite and set a password.
268
-
269
- ```typescript
270
- import { acceptInvite, AuthError } from '@netlify/identity'
271
-
272
- async function handleAcceptInvite(token: string, password: string) {
273
- try {
274
- const user = await acceptInvite(token, password)
275
- showSuccess(`Welcome, ${user.email}! Your account is ready.`)
276
- } catch (error) {
277
- if (error instanceof AuthError) showError(error.message)
278
- }
279
- }
280
- ```
281
-
282
- ### Email Change
283
-
284
- When a user verifies an email change, `handleAuthCallback()` returns `{ type: 'email_change', user }`. This requires an
285
- active browser session - the user must be logged in when clicking the verification link.
286
-
287
- ```typescript
288
- import { verifyEmailChange, AuthError } from '@netlify/identity'
289
-
290
- // If you need to verify programmatically with a token:
291
- async function handleEmailChangeVerification(token: string) {
292
- try {
293
- const user = await verifyEmailChange(token)
294
- showSuccess(`Email updated to ${user.email}`)
295
- } catch (error) {
296
- if (error instanceof AuthError) showError(error.message)
297
- }
298
- }
299
- ```
300
-
301
- ### Session Hydration
302
-
303
- `hydrateSession()` bridges server-set cookies to the browser session. Call it on page load when using server-side login
304
- (e.g., login inside a Netlify Function followed by a redirect).
305
-
306
- ```typescript
307
- import { hydrateSession } from '@netlify/identity'
72
+ ```ts
73
+ // netlify/functions/me.ts — verify user
74
+ import { getUser } from '@netlify/identity'
75
+ import type { Context } from '@netlify/functions'
308
76
 
309
- // On page load
310
- const user = await hydrateSession()
311
- if (user) {
312
- // Browser session is now in sync with server-set cookies
77
+ export default async (req: Request, context: Context) => {
78
+ const user = await getUser()
79
+ if (!user) return new Response('Unauthorized', { status: 401 })
80
+ return Response.json({ id: user.id, email: user.email })
313
81
  }
314
82
  ```
315
83
 
316
- Note: `getUser()` auto-hydrates from the `nf_jwt` cookie if no browser session exists, so explicit `hydrateSession()`
317
- is only needed when you want to restore the full session (including token refresh timers) after a server-side login.
318
-
319
- ## Auth Events
320
-
321
- Subscribe to auth state changes with `onAuthChange`. Returns an unsubscribe function. No-op on server.
322
-
323
- ```typescript
324
- import { onAuthChange, AUTH_EVENTS } from '@netlify/identity'
325
-
326
- const unsubscribe = onAuthChange((event, user) => {
327
- switch (event) {
328
- case AUTH_EVENTS.LOGIN:
329
- console.log('User logged in:', user?.email)
330
- break
331
- case AUTH_EVENTS.LOGOUT:
332
- console.log('User logged out')
333
- break
334
- case AUTH_EVENTS.TOKEN_REFRESH:
335
- // Token auto-refreshed in background
336
- break
337
- case AUTH_EVENTS.USER_UPDATED:
338
- console.log('User profile updated:', user?.email)
339
- break
340
- case AUTH_EVENTS.RECOVERY:
341
- // Recovery token processed - prompt for new password
342
- console.log('Password recovery initiated')
343
- break
344
- }
345
- })
346
-
347
- // Later: unsubscribe()
348
- ```
349
-
350
- Auth events are automatically detected across browser tabs via the storage event listener - no extra setup needed.
351
-
352
- ## Settings-Driven UI
353
-
354
- Fetch the project's Identity settings to conditionally render signup forms and OAuth buttons.
84
+ The Edge Function form is identical but imports `Context` from `@netlify/edge-functions`.
355
85
 
356
- ```typescript
357
- import { getSettings } from '@netlify/identity'
358
-
359
- const settings = await getSettings()
360
- // settings.autoconfirm - boolean, whether email confirmation is skipped
361
- // settings.disableSignup - boolean, whether registration is closed
362
- // settings.providers - Record<AuthProvider, boolean>, e.g. { google: true, github: true, ... }
86
+ ### Role checks
363
87
 
364
- // Conditionally render signup
365
- if (!settings.disableSignup) {
366
- showSignupForm()
367
- }
88
+ ```ts
89
+ // netlify/functions/admin-users.ts
90
+ import { getUser, admin } from '@netlify/identity'
91
+ import type { Context } from '@netlify/functions'
368
92
 
369
- // Conditionally render OAuth buttons
370
- for (const [provider, enabled] of Object.entries(settings.providers)) {
371
- if (enabled) {
372
- showOAuthButton(provider)
373
- }
93
+ export default async (req: Request, context: Context) => {
94
+ const user = await getUser()
95
+ if (!user) return new Response('Unauthorized', { status: 401 })
96
+ if (!user.roles.includes('admin')) return new Response('Forbidden', { status: 403 })
97
+ const users = await admin.listUsers()
98
+ return Response.json({ users })
374
99
  }
375
100
  ```
376
101
 
377
- ## Full API Reference
378
-
379
- For the complete API reference - all function signatures, type definitions, OAuth helpers, admin operations, session
380
- management, auth events, and framework-specific integration examples - read the package README:
381
-
382
- ```
383
- node_modules/@netlify/identity/README.md
384
- ```
102
+ ### CSRF: required for server-side auth endpoints
385
103
 
386
- The README is shipped with the npm package and is always in sync with the installed version.
104
+ > Any endpoint that runs `login()`, `signup()`, or `logout()` server-side **must** call `verifyRequestOrigin(req)` at the top of the handler. It throws a 403 on origin mismatch.
387
105
 
388
- ## SSR Integration Patterns
389
-
390
- For SSR frameworks, the recommended pattern is:
391
-
392
- - **Browser-side** for auth mutations: `login()`, `signup()`, `logout()`, `oauthLogin()`
393
- - **Server-side** for reading auth state: `getUser()`, `getSettings()`, `getIdentityConfig()`
394
-
395
- Browser-side auth mutations set the `nf_jwt` cookie and localStorage, and emit `onAuthChange` events. The
396
- server reads the cookie on the next request.
397
-
398
- The library also supports server-side mutations (`login()`, `signup()`, `logout()` inside Netlify Functions), but these
399
- require the Netlify Functions runtime to set cookies. After a server-side mutation, use a full page navigation so the
400
- browser sends the new cookie.
401
-
402
- Always use `window.location.href` (not framework router navigation) after server-side auth mutations in Next.js,
403
- TanStack Start, and SvelteKit. Remix `redirect()` is safe because Remix actions return real HTTP responses.
404
-
405
- ## Netlify SSO Users (`context.user`)
406
-
407
- Netlify Identity is for your site's own end users. Separately, on private projects and sites protected by Netlify team
408
- login, Netlify Functions, Edge Functions, and Netlify Server code can read the Netlify team member who signed in through
409
- Netlify's SSO from the request context as `context.user` (`id`, `email`, `expiresAt`). Netlify verifies the visitor at
410
- the edge and sets it itself, so it can't be spoofed with request headers.
411
-
412
- Use `context.user` instead of Netlify Identity when the only people who should reach the site are members of the
413
- Netlify team (internal dashboards, audit logs, approval workflows): there is no signup, login UI, or `@netlify/identity`
414
- setup to build, and you don't run the enable script for it. Use `getUser()` from `@netlify/identity` when the site has
415
- its own user accounts.
416
-
417
- ```typescript
418
- // netlify/functions/whoami.mts
106
+ ```ts
107
+ // netlify/functions/login.ts
108
+ import { login, verifyRequestOrigin } from '@netlify/identity'
419
109
  import type { Context } from '@netlify/functions'
420
110
 
421
111
  export default async (req: Request, context: Context) => {
422
- if (!context.user) return new Response('Unauthorized', { status: 401 })
423
- return Response.json({ id: context.user.id, email: context.user.email })
112
+ verifyRequestOrigin(req)
113
+ const { email, password } = await req.json()
114
+ await login(email, password)
115
+ return new Response(null, { status: 302, headers: { Location: '/dashboard' } })
424
116
  }
425
117
  ```
426
118
 
427
- See the [changelog post](https://www.netlify.com/changelog/2026-10-07-context-user-in-functions/), and the
428
- **netlify-functions**, **netlify-edge-functions**, and **netlify-server** skills for runtime-specific details.
119
+ ## Identity event functions
429
120
 
430
- ## Identity Event Functions
121
+ The platform calls your handler when an Identity event occurs. Export a default object with a method per event. File: `netlify/functions/identity.mts`.
431
122
 
432
- Subscribe to Identity lifecycle events by exporting an object whose properties are named event handlers. See the
433
- **netlify-functions** skill for the full event-handler pattern.
123
+ > Typed handlers (`UserSignupEvent`, `event.deny()`) require `@netlify/functions` ≥ 5.2.0. Older installs must use the legacy filename convention (`identity-signup.ts`, etc.). See `references/authorization-and-sessions.md`.
434
124
 
435
- **Available identity handlers:**
436
-
437
- | Handler | Trigger |
125
+ | Handler | Fires when |
438
126
  |---|---|
439
- | `userValidate` | A user attempts to sign up. Can deny the signup. |
440
- | `userSignup` | A user completes signup. Can deny or mutate. |
441
- | `userLogin` | A user logs in. Can deny or mutate. |
442
- | `userModified` | A user's profile is updated. Can deny or mutate. |
443
- | `userDeleted` | A user is deleted. Notification only. |
444
-
445
- Each handler receives a typed event with a parsed `user` object (camelCase fields: `appMetadata`, `userMetadata`,
446
- `confirmedAt`, etc.).
447
-
448
- ### Example: Assign Default Role on Signup
127
+ | `userValidate` | Signup attempt, before account creation. Block bad signups here. |
128
+ | `userSignup` | Signup completes (after email confirmation if enabled). Assign roles, sync, welcome. |
129
+ | `userLogin` | User logs in. Track/last-seen/block. |
130
+ | `userModified` | Profile updated. |
131
+ | `userDeleted` | User deleted (notification only). |
449
132
 
450
- Return `{ user: ... }` to substitute the user record before it's persisted.
133
+ Event `user` fields are camelCase (`appMetadata`, `userMetadata`, `confirmedAt`).
451
134
 
452
135
  ```typescript
453
- // netlify/functions/identity.mts
454
- import type { UserSignupEvent } from '@netlify/functions'
136
+ // netlify/functions/identity.mts — deny a signup
137
+ import type { UserValidateEvent } from "@netlify/functions"
455
138
 
456
139
  export default {
457
- userSignup(event: UserSignupEvent) {
458
- return {
459
- user: {
460
- ...event.user,
461
- appMetadata: {
462
- ...event.user.appMetadata,
463
- roles: ['member'],
464
- },
465
- },
466
- }
140
+ userValidate(event: UserValidateEvent) {
141
+ if (!event.user.email?.endsWith("@example.com")) return event.deny()
467
142
  },
468
143
  }
469
144
  ```
470
145
 
471
- ### Deny an Action
472
-
473
- Call `event.deny()` to reject a signup, login, validation, or modification. The end user receives a 401. Do NOT throw —
474
- `event.deny()` is the canonical denial mechanism and does not produce an error in observability.
475
-
476
146
  ```typescript
477
- import type { UserValidateEvent } from '@netlify/functions'
147
+ // netlify/functions/identity.mts — assign roles at signup
148
+ import type { UserSignupEvent } from "@netlify/functions"
478
149
 
479
150
  export default {
480
- userValidate(event: UserValidateEvent) {
481
- if (!event.user.email?.endsWith('@example.com')) {
482
- return event.deny()
483
- }
151
+ userSignup(event: UserSignupEvent) {
152
+ return { user: { ...event.user, appMetadata: { ...event.user.appMetadata, roles: ["member"] } } }
484
153
  },
485
154
  }
486
155
  ```
487
156
 
488
- If multiple functions subscribe to the same event, the first to call `event.deny()` aborts the chain.
489
-
490
- For bulk user management or role changes outside lifecycle events, use the `admin` API instead of Identity event functions.
157
+ - `event.deny()` rejects the action. The end user gets a `401`, and no error appears in observability. The first handler to call it aborts the chain, and later subscribers are not invoked. Legacy filename functions signal denial with a non-2xx `Response` instead.
158
+ - Return `{ user: {...} }` to modify the record before it is persisted. This is the canonical way to set roles at signup. Roles ride in the JWT, so a role change takes effect on the user's **next login or token refresh, not immediately**. See Roles & the JWT below.
159
+ - Background mode: `export const config: Config = { background: true }`. The action completes immediately and the handler runs asynchronously.
491
160
 
492
- **Legacy filename convention:** The previous syntax — files named `identity-validate.ts`, `identity-signup.ts`, or
493
- `identity-login.ts` (with a `-background` suffix for background mode), exporting `handler` and signaling denial via a
494
- non-2xx response — still works. New functions should prefer the typed handler syntax above.
161
+ ## Roles & the JWT
495
162
 
496
- ## Roles and Authorization
163
+ - `user.roles` is read from `app_metadata.roles` and carried in the JWT (cookie `nf_jwt`; refresh via `nf_refresh`).
164
+ - `user_metadata` holds the user-editable profile (`full_name`, `email`). `app_metadata` holds app data, including `roles`, and is not user-editable.
497
165
 
498
- ### First Admin User
166
+ > **Role changes are NOT immediate.** They take effect on next login or token refresh. Changing roles does not invalidate the current JWT. Force a refresh with `refreshSession()`.
499
167
 
500
- The first admin user cannot be created through code alone. You must direct the user to set it up through the Netlify UI:
168
+ Set roles for existing users via `admin.updateUser()` in a Netlify Function. Set them at signup via the `userSignup` event handler above.
501
169
 
502
- 1. Go to **Identity** in the project sidebar in the Netlify dashboard
503
- 2. Click **Invite users** and enter the admin user's email address
504
- 3. After the user accepts the invite, click the user in the Identity list to open their detail page
505
- 4. In the **Roles** field, add the `admin` role and save
170
+ Deep guides for SSR/session hydration and authorization live in `references/advanced-patterns.md` and `references/authorization-and-sessions.md`.
506
171
 
507
- Once the first admin exists, subsequent users can be managed programmatically using Identity event functions (e.g., assigning roles in the `userSignup` handler) or role-based redirects.
172
+ ## CDN-edge RBAC (redirect rules)
508
173
 
509
- ### Metadata Types
174
+ Enforced at the edge with no origin round trip. A mismatched role gets a 404 unless you add a fallback. **Always pair a role-gated rule with a fallback.**
510
175
 
511
- - **`app_metadata.roles`** - Server-controlled. Only settable via the Netlify UI, admin API, or Identity event functions.
512
- Do not allow users to set their own roles.
513
- - **`user_metadata`** - User-controlled. Users can update this via `updateUser({ data: { ... } })`.
514
-
515
- ### Role-Based Redirects
516
-
517
- Use `netlify.toml` to restrict paths by role:
176
+ `_redirects`:
177
+ ```
178
+ /admin/* /admin/:splat 200! Role=admin
179
+ /admin/* /login 401!
180
+ # Multiple roles chained with commas:
181
+ /private/* /private/:splat 200! Role=editor,admin
182
+ ```
518
183
 
184
+ `netlify.toml`:
519
185
  ```toml
520
186
  [[redirects]]
521
187
  from = "/admin/*"
522
188
  to = "/admin/:splat"
189
+ force = true
523
190
  status = 200
524
- conditions = { Role = ["admin"] }
525
-
526
- [[redirects]]
527
- from = "/admin/*"
528
- to = "/"
529
- status = 302
191
+ conditions = {Role = ["editor", "admin"]}
530
192
  ```
531
193
 
532
- Rules are evaluated top-to-bottom. The first redirect matches users with the `admin` role; everyone else falls through
533
- to the second rule and is redirected away.
194
+ Use redirect rules for path-based gating. Use function-based `user.roles` checks for custom authorization logic.
534
195
 
535
- **How it works:** The `nf_jwt` cookie is read by the CDN to evaluate `conditions = { Role = [...] }`. Without this
536
- cookie, role-based redirects will not work.
196
+ ## Configuration (dashboard-only)
537
197
 
538
- ## Common Errors & Solutions
198
+ Base: `https://app.netlify.com/projects/{site_name}/identity`. Enable Identity using this skill's `scripts/enable.cjs`, as described under Additional requirements. Identity requires HTTPS, so set up SSL before integrating on a custom domain.
539
199
 
540
- ### "Signups not allowed for this instance" (403)
200
+ - **Registration** (`?tab=registration#registration-preferences`): **Open** (default; anyone can sign up) or **Invite only** (all users, including external-provider logins, must be invited first).
201
+ - **Confirmation / autoconfirm** (`?tab=emails#confirmation-template`): check the box to skip email verification.
202
+ - **External providers** (`?tab=registration#external-providers`): Google/GitHub/GitLab/Bitbucket. For branded OAuth (your app name instead of "Netlify Identity"), register your app with the provider, get a client ID and secret, and enter them **in the Netlify settings UI**, not in code.
203
+ - **Invitations** (`?tab=users`): enter addresses to send invites. The link carries `invite_token`. Complete it with `handleAuthCallback()` + `acceptInvite(token, password)` as shown above.
204
+ - **Password recovery**: on the user page, use **Send reset password email**. The link carries `recovery_token`.
541
205
 
542
- **Cause:** Registration is set to Invite only.
206
+ ### Emails (Pro plans or higher)
543
207
 
544
- **Fix:**
208
+ The default sender is `no-reply@netlify.com`. A custom SMTP sender and custom templates both require **Pro plans or higher**.
545
209
 
546
- 1. Change to Open in **Project configuration > Identity**
547
- 2. Or invite users from the Identity tab in the Netlify UI
210
+ Template variables (Go syntax): `{{ .Email }}`, `{{ .NewEmail }}` (email-change only), `{{ .SiteURL }}`, `{{ .ConfirmationURL }}`, `{{ .Token }}`.
548
211
 
549
- ```typescript
550
- if (error instanceof AuthError && error.status === 403) {
551
- showError('Signups are disabled. Contact the site admin for an invite.')
552
- }
212
+ Custom-link hash fragments per action:
553
213
  ```
554
-
555
- ### Invalid credentials (401)
556
-
557
- **Cause:** Wrong email or password on login.
558
-
559
- **Fix:** Show a user-facing error and let the user retry. Do not reveal whether the email or password was wrong.
560
-
561
- ```typescript
562
- if (error instanceof AuthError && error.status === 401) {
563
- showError('Invalid email or password.')
564
- }
214
+ {{ .SiteURL }}/path/#invite_token={{ .Token }}
215
+ {{ .SiteURL }}/path/#confirmation_token={{ .Token }}
216
+ {{ .SiteURL }}/path/#recovery_token={{ .Token }}
217
+ {{ .SiteURL }}/path/#email_change_token={{ .Token }}
565
218
  ```
566
219
 
567
- ### "Email not confirmed"
568
-
569
- **Cause:** User tries to log in before confirming their email (autoconfirm is off).
570
-
571
- **Fix:** Tell the user to check their inbox. Optionally provide a way to resend the confirmation email via `signup()`
572
- with the same credentials.
573
-
574
- ### "Token expired" / 401 on API calls
575
-
576
- **Cause:** Stale access token.
577
-
578
- **Fix:**
220
+ Custom template constraints:
221
+ - Inline CSS only.
222
+ - Absolute image links.
223
+ - **No `<html>`/`<head>`/`<body>` tags.**
224
+ - Ensure your build doesn't alter Go template variables.
225
+
226
+ ### Audit log (Pro plans or higher)
227
+
228
+ `?tab=audit-log`. Search with a scoped term: `author:[string]` or `action:[string]`.
229
+
230
+ Action names: `login`, `logout`, `user_signedup`, `user_deleted`, `user_modified`, `token_revoked`, `token_refreshed`, `user_recovery_requested`, `user_invited`.
231
+
232
+ ## External JWT providers (Enterprise)
233
+
234
+ Available on **Enterprise plans**. You may use Netlify Identity OR an external JWT provider, **not both at once**. You cannot authenticate third-party JWTs while Netlify Identity is enabled.
235
+
236
+ - Roles path: Netlify Identity uses `app_metadata.roles`; an external provider uses `app_metadata.authorization.roles`. For a custom path, contact support.
237
+ - The JWT header must be `{"alg": "HS256", "typ": "JWT"}` (HS256 required). The payload `exp` is required and must be a future Unix Epoch time.
238
+ - Set the JWT secret at `Project configuration > General > Visitor access > JWT secret`. Project-level settings override team-level defaults.
239
+
240
+ ## On failure — stop, don't guess
241
+
242
+ If callbacks 404, `/.netlify/identity/*` is unreachable, or an OAuth flow never returns:
243
+ 1. Surface the error.
244
+ 2. Give the dashboard URL (`https://app.netlify.com/projects/{site_name}/identity`).
245
+ 3. Name the setting to check (registration preference, external provider config, confirmation/autoconfirm).
246
+ 4. Then stop. Do not invent recovery commands.
247
+
248
+ Remember: Identity does not work under `netlify dev`. Confirm you are testing on a deploy.
249
+
250
+ Site-gating requests ("lock this site to my company", employees-only) route to the netlify-access-control skill first. Identity is the app-level user layer only. Restricting who can view the whole project uses [project visibility](https://docs.netlify.com/manage/security/secure-access-to-sites/project-visibility) or [Password Protection](https://docs.netlify.com/manage/security/secure-access-to-sites/password-protection), not Identity.
251
+
252
+ <!-- gap: getSettings() signature/return shape is not documented in the intermediate. -->
253
+ <!-- gap: handleAuthCallback() result.type strings other than 'invite' are not enumerated. -->
254
+
255
+ <!-- system: agent-context/identity/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
256
+ # Netlify house rules (identity)
257
+
258
+ These are org conventions, not docs facts — merged into the rendered skill by
259
+ ctx-gen and never generated. Owned by the skills maintainer.
260
+
261
+ 1. Deep guides live in this skill: `references/advanced-patterns.md`
262
+ (SSR/session hydration) and `references/authorization-and-sessions.md`.
263
+ 2. Identity does not work under `netlify dev` — test auth flows on deploys
264
+ (Deploy Previews work).
265
+ 3. Identity configuration has no public API — it is dashboard-only. Never curl
266
+ `api.netlify.com` to flip or inspect Identity settings, never read auth
267
+ tokens from `~/Library/Preferences/netlify/config.json`, never probe for
268
+ undocumented endpoints. The one exception is reading the site's
269
+ `identity_instance_id` and a single user's account with the lookup described
270
+ under Additional requirements.
271
+ 4. On failure (callback 404s, `/.netlify/identity/*` unreachable, OAuth flow
272
+ doesn't return), surface the error, the dashboard URL, and the setting to
273
+ check — then stop. Do not invent recovery commands.
274
+ 5. Never build a from-scratch third-party OAuth flow when Identity is in play —
275
+ no provider app registration, no `client_id`/`secret` in code, no custom
276
+ callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`;
277
+ raw OAuth beside Identity is the single most common source of rework.
278
+ 6. Server-side `getUser()`/`login()`/`admin.*` require modern v2 functions
279
+ (`export default`) — v1 `export { handler }` is not supported. Typed
280
+ Identity event handlers (`UserSignupEvent`, `event.deny()`) require
281
+ `@netlify/functions` ≥ 5.2.0; older installs use the legacy filenames.
282
+ 7. Don't hard-code which auth providers exist — call `getSettings()` at
283
+ startup and render the signup form and OAuth buttons from what it returns.
284
+ 8. Site-gating requests ("lock this site to my company", employees-only)
285
+ route to the netlify-access-control skill first — Identity is the
286
+ app-level user layer only.
287
+ 9. Any answer that assigns or changes roles — at signup, via `admin.*`, or in
288
+ the dashboard — must say the change takes effect on the user's next login
289
+ or token refresh, not immediately. Keep that sentence next to the code that
290
+ sets the role, not only in a separate JWT section: an agent answering a
291
+ signup question reads the signup example and stops, and it has shipped
292
+ answers that omit the delay.
293
+ 10. Before setting up Identity for an internal tool, ask who signs in. If
294
+ they can already pass the project's Netlify login (a private project or
295
+ team login protection), the app has a verified user without Identity:
296
+ read `context.user` in functions and edge functions and skip the second
297
+ login. Identity is for users outside that Netlify login. The two never
298
+ merge: `context.user` has no roles or `app_metadata`, and `getUser()`
299
+ never returns it.
300
+
301
+
302
+ ## Additional requirements
303
+
304
+ After implementing Netlify Identity, you MUST run `scripts/enable.cjs` from this skill using Node. Use the absolute path
305
+ to the script and keep the project's directory as your working directory:
579
306
 
580
- 1. Always use `getUser()` before authenticated requests - it auto-hydrates from cookies
581
- 2. In the browser, the library auto-refreshes tokens via `startTokenRefresh()`
582
- 3. On the server, call `refreshSession()` in middleware to handle near-expiry tokens
583
-
584
- ```typescript
585
- const newToken = await refreshSession()
586
- if (newToken) {
587
- // Token was refreshed - retry the request
588
- }
307
+ ```bash
308
+ node /absolute/path/to/netlify-identity/scripts/enable.cjs
589
309
  ```
590
310
 
591
- ### Identity event function not triggering
592
-
593
- **Cause:** Export shape does not match the expected pattern.
311
+ Replace the example path with the installed location of this skill. The script creates
312
+ `.netlify/features/netlify-identity` in the project's repository. The deployment workflow uses that marker to request
313
+ Identity enablement before deploying. Do not ask the user to enable Identity in the Netlify UI. Registration settings,
314
+ external providers, and other Identity configuration can still be managed in the dashboard.
594
315
 
595
- **Fix:**
316
+ ### Read the project's Identity configuration
596
317
 
597
- 1. Use the typed handler syntax: default export an object with `userSignup`, `userLogin`, `userValidate`, `userModified`,
598
- or `userDeleted` methods
599
- 2. Place the function in `netlify/functions/` with `.mts` or `.mjs` extension
600
- 3. If using the legacy filename convention, verify the filename matches the event exactly (`identity-signup.mts`,
601
- `identity-validate.mts`, or `identity-login.mts`) and that the file exports a named `handler`
318
+ You can read the project's Identity settings — registration preferences, external providers, email templates and the
319
+ outgoing email (SMTP) settings — through the service-instance route. Read `$IDENTITY_ID` as described below, then:
602
320
 
603
- ### `MissingIdentityError`
604
-
605
- **Cause:** Identity is not configured in the current environment.
606
-
607
- **Fix:**
608
-
609
- 1. Ensure Identity is enabled on the project
610
- 2. Use `netlify dev` for local development so the Identity endpoint is available
611
-
612
- ```typescript
613
- if (error instanceof MissingIdentityError) {
614
- showError('Identity is not enabled. Run "netlify dev" or enable Identity in project settings.')
615
- }
321
+ ```bash
322
+ curl -s -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \
323
+ "https://api.netlify.com/api/v1/sites/$SITE_ID/services/identity/instances/$IDENTITY_ID"
616
324
  ```
617
325
 
618
- ### `AuthError` on server - missing Netlify runtime
619
-
620
- **Cause:** Server-side `login()`, `signup()`, or `logout()` require the Netlify Functions runtime to set cookies.
621
-
622
- **Fix:**
623
-
624
- 1. Deploy to Netlify to use server-side auth mutations
625
- 2. Or use `netlify dev` for local development
626
-
627
- ### "User not found" after OAuth login
628
-
629
- **Cause:** OAuth provider is not enabled for the project.
630
-
631
- **Fix:**
632
-
633
- 1. Enable the provider in **Project configuration > Identity > External providers**
634
- 2. Users are created automatically on first OAuth login
635
-
636
- ### Account operations fail after server-side login
326
+ The settings are under `config.config`; outgoing email is at `config.config.smtp` (`admin_email`, `host`, `port`,
327
+ `user`). Secrets are filtered out of this response: the SMTP password is never returned, so you cannot check whether one
328
+ is set or whether it is correct. Use this read to find out what is already configured instead of raising a setup request
329
+ to discover it.
637
330
 
638
- **Cause:** Browser-side session is not bootstrapped from server-set cookies.
331
+ Note the route: `/services/identity/instances/$IDENTITY_ID`, not `/identity/$IDENTITY_ID`. The latter is the
332
+ administration endpoint and returns 401 for you.
639
333
 
640
- **Fix:** Call `hydrateSession()` on page load to bridge server-set cookies to the browser session. Then use
641
- `updateUser()`, `verifyEmailChange()`, or other account operations.
334
+ ### Look up one user's account
642
335
 
643
- ```typescript
644
- import { hydrateSession, updateUser } from '@netlify/identity'
336
+ When the user reports that a specific person can't sign up or log in, read that person's Identity account with the
337
+ Netlify API before guessing. You can look up one user by email or by ID. Listing users, searching, changing users, and
338
+ the Identity audit log are not available to you; those calls return 401 or 404.
645
339
 
646
- // On page load after server-side login
647
- await hydrateSession()
340
+ `$SITE_ID` is the project ID from your instructions. First read the project's Identity instance ID from the
341
+ `identity_instance_id` field of the site object into `IDENTITY_ID`. If it prints `null`, Identity is not enabled on the
342
+ project; stop here.
648
343
 
649
- // Now account operations work
650
- await updateUser({ data: { full_name: 'Jane' } })
344
+ ```bash
345
+ IDENTITY_ID=$(curl -s -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" "https://api.netlify.com/api/v1/sites/$SITE_ID" \
346
+ | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).identity_instance_id')
347
+ echo "$IDENTITY_ID"
651
348
  ```
652
349
 
653
- ### "Email change verification requires an active browser session"
654
-
655
- **Cause:** `verifyEmailChange()` was called without an active session. The user must be logged in when clicking the
656
- email change verification link.
657
-
658
- **Fix:** Ensure the user is logged in before processing the `email_change` callback. If the session expired, prompt
659
- the user to log in first.
660
-
661
- ### "No user is currently logged in"
350
+ Look up by email (matching ignores case and trailing spaces; partial emails return 404):
662
351
 
663
- **Cause:** An account operation (`updateUser`, `verifyEmailChange`) was called without an authenticated user.
352
+ ```bash
353
+ curl -s -X POST -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" -H "Content-Type: application/json" \
354
+ -d '{"email": "jane@example.com"}' \
355
+ "https://api.netlify.com/api/v1/sites/$SITE_ID/identity/$IDENTITY_ID/users/lookup"
356
+ ```
664
357
 
665
- **Fix:** Check `getUser()` before calling account operations. If `null`, redirect to login.
358
+ Look up by ID:
666
359
 
667
- ```typescript
668
- const user = await getUser()
669
- if (!user) {
670
- redirectToLogin()
671
- return
672
- }
673
- await updateUser({ data: { full_name: 'Jane' } })
360
+ ```bash
361
+ curl -s -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \
362
+ "https://api.netlify.com/api/v1/sites/$SITE_ID/identity/$IDENTITY_ID/users/$USER_ID"
674
363
  ```
675
364
 
676
- ### Stale session - user deleted server-side
365
+ Once the site read has returned an `identity_instance_id`, a 404 from these lookups means no user with that email or ID
366
+ exists on this project. The response contains:
677
367
 
678
- **Cause:** `getUser()` returns `null` when the `nf_jwt` cookie is gone but localStorage still has a stale
679
- session. This happens when a user is deleted via the admin API or Netlify UI.
368
+ | Field | What it tells you |
369
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
370
+ | `confirmed_at` | Empty: the user never confirmed their email or accepted their invite, so login fails. |
371
+ | `invited_at` | Set: the user was invited. With an empty `confirmed_at`, the invite is still pending. |
372
+ | `app_metadata.provider` | `email` for password signups; `google`, `github`, etc. for OAuth. An OAuth user has no password, so the password form rejects them. |
373
+ | `app_metadata.roles` | Compare with the roles your redirects or code require. |
374
+ | `last_sign_in_at` | When the user last logged in successfully. |
375
+ | `recovery_sent_at` | When a password reset email was last requested. |
376
+ | `email_change_sent_at` | When an email change was last requested. |
680
377
 
681
- **Fix:** `getUser()` handles this gracefully - it returns `null` and the stale localStorage entry is ignored. Always
682
- check for `null` before using the user object.
378
+ These reads don't show whether the login flow in the code works. Check the code and the preview for that.