@netlify/agent-runner-cli 2.17.0-PAW-612.0 → 2.18.0-smtp.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,360 @@
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:
31
-
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.
35
-
36
- For local development, use `netlify dev` so the Identity endpoint is available.
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).
37
15
 
38
- ## Quick Start
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.
39
17
 
40
- Log in from the browser:
41
-
42
- ```typescript
43
- import { login, getUser } from '@netlify/identity'
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.
44
19
 
45
- const user = await login('jane@example.com', 'password123')
46
- console.log(`Hello, ${user.name}`)
47
-
48
- // Later, check auth state
49
- const currentUser = await getUser()
50
- ```
51
-
52
- Protect a Netlify Function:
53
-
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
- }
64
- ```
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.
65
21
 
66
- ## Error Handling
22
+ ## Client auth (browser)
67
23
 
68
- `@netlify/identity` throws two error classes:
24
+ ```ts
25
+ import { signup, login, logout, getUser, oauthLogin, handleAuthCallback } from '@netlify/identity'
69
26
 
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`).
27
+ // Register — confirmation email sent by default (unless autoconfirm is on)
28
+ const user = await signup('jane@example.com', 'securepassword', { full_name: 'Jane Doe' })
74
29
 
75
- `getUser()` and `isAuthenticated()` never throw - they return `null` and `false` respectively on failure.
30
+ // Log in / out
31
+ await login('jane@example.com', 'securepassword')
32
+ await logout()
76
33
 
77
- ### Try/Catch Pattern
34
+ // Current user or null
35
+ const current = await getUser()
36
+ if (current) console.log(`Logged in as ${current.email}`)
78
37
 
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
110
- }
38
+ // External provider — redirects the browser; provider is one of
39
+ // 'google' | 'github' | 'gitlab' | 'bitbucket'
40
+ oauthLogin('github')
111
41
  ```
112
42
 
113
- ### Common Status Codes
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.
114
44
 
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 |
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.
121
46
 
122
- ## Authentication Flows
47
+ ```ts
48
+ import { handleAuthCallback, acceptInvite } from '@netlify/identity'
123
49
 
124
- ### Login
125
-
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
- }
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)
143
59
  }
144
60
  ```
145
61
 
146
- ### Signup
62
+ For recovery tokens you can call `recoverPassword()` directly instead. Refresh a session with `refreshSession()`.
147
63
 
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.
64
+ Don't hard-code which providers exist. Call `getSettings()` at startup and render the signup form and OAuth buttons from what it returns.
150
65
 
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
- ```
66
+ ## Server-side auth (Netlify Functions & Edge Functions)
171
67
 
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).
68
+ Server-side `getUser()`/`login()`/`admin.*` require modern **v2 functions** (`export default`). The v1 `export { handler }` form is not supported.
176
69
 
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
- ```
70
+ `getUser()` works in both runtimes. **`admin.*` runs ONLY in Netlify Functions. It does not run in the browser or in Edge Functions.**
226
71
 
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.
355
-
356
- ```typescript
357
- import { getSettings } from '@netlify/identity'
84
+ The Edge Function form is identical but imports `Context` from `@netlify/edge-functions`.
358
85
 
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
- ```
385
-
386
- The README is shipped with the npm package and is always in sync with the installed version.
387
-
388
- ## SSR Integration Patterns
102
+ ### CSRF: required for server-side auth endpoints
389
103
 
390
- For SSR frameworks, the recommended pattern is:
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.
391
105
 
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.).
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). |
447
132
 
448
- ### Example: Assign Default Role on Signup
449
-
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.
491
-
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.
495
-
496
- ## Roles and Authorization
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.
497
160
 
498
- ### First Admin User
161
+ ## Roles & the JWT
499
162
 
500
- The first admin user cannot be created through code alone. You must direct the user to set it up through the Netlify UI:
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.
501
165
 
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
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()`.
506
167
 
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.
168
+ Set roles for existing users via `admin.updateUser()` in a Netlify Function. Set them at signup via the `userSignup` event handler above.
508
169
 
509
- ### Metadata Types
170
+ Deep guides for SSR/session hydration and authorization live in `references/advanced-patterns.md` and `references/authorization-and-sessions.md`.
510
171
 
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: { ... } })`.
172
+ ## CDN-edge RBAC (redirect rules)
514
173
 
515
- ### Role-Based Redirects
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.**
516
175
 
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
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:
575
306
 
576
- **Cause:** Stale access token.
577
-
578
- **Fix:**
579
-
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.
594
-
595
- **Fix:**
596
-
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`
602
-
603
- ### `MissingIdentityError`
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.
604
315
 
605
- **Cause:** Identity is not configured in the current environment.
316
+ ### Look up one user's account
606
317
 
607
- **Fix:**
318
+ When the user reports that a specific person can't sign up or log in, read that person's Identity account with the
319
+ Netlify API before guessing. You can look up one user by email or by ID. Listing users, searching, changing users, and
320
+ the Identity audit log are not available to you; those calls return 401 or 404.
608
321
 
609
- 1. Ensure Identity is enabled on the project
610
- 2. Use `netlify dev` for local development so the Identity endpoint is available
322
+ `$SITE_ID` is the project ID from your instructions. First read the project's Identity instance ID from the
323
+ `identity_instance_id` field of the site object into `IDENTITY_ID`. If it prints `null`, Identity is not enabled on the
324
+ project; stop here.
611
325
 
612
- ```typescript
613
- if (error instanceof MissingIdentityError) {
614
- showError('Identity is not enabled. Run "netlify dev" or enable Identity in project settings.')
615
- }
326
+ ```bash
327
+ IDENTITY_ID=$(curl -s -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" "https://api.netlify.com/api/v1/sites/$SITE_ID" \
328
+ | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).identity_instance_id')
329
+ echo "$IDENTITY_ID"
616
330
  ```
617
331
 
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:**
332
+ Look up by email (matching ignores case and trailing spaces; partial emails return 404):
623
333
 
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
637
-
638
- **Cause:** Browser-side session is not bootstrapped from server-set cookies.
639
-
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.
642
-
643
- ```typescript
644
- import { hydrateSession, updateUser } from '@netlify/identity'
645
-
646
- // On page load after server-side login
647
- await hydrateSession()
648
-
649
- // Now account operations work
650
- await updateUser({ data: { full_name: 'Jane' } })
334
+ ```bash
335
+ curl -s -X POST -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" -H "Content-Type: application/json" \
336
+ -d '{"email": "jane@example.com"}' \
337
+ "https://api.netlify.com/api/v1/sites/$SITE_ID/identity/$IDENTITY_ID/users/lookup"
651
338
  ```
652
339
 
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"
340
+ Look up by ID:
662
341
 
663
- **Cause:** An account operation (`updateUser`, `verifyEmailChange`) was called without an authenticated user.
664
-
665
- **Fix:** Check `getUser()` before calling account operations. If `null`, redirect to login.
666
-
667
- ```typescript
668
- const user = await getUser()
669
- if (!user) {
670
- redirectToLogin()
671
- return
672
- }
673
- await updateUser({ data: { full_name: 'Jane' } })
342
+ ```bash
343
+ curl -s -H "Authorization: Bearer $NETLIFY_AUTH_TOKEN" \
344
+ "https://api.netlify.com/api/v1/sites/$SITE_ID/identity/$IDENTITY_ID/users/$USER_ID"
674
345
  ```
675
346
 
676
- ### Stale session - user deleted server-side
347
+ Once the site read has returned an `identity_instance_id`, a 404 from these lookups means no user with that email or ID
348
+ exists on this project. The response contains:
677
349
 
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.
350
+ | Field | What it tells you |
351
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
352
+ | `confirmed_at` | Empty: the user never confirmed their email or accepted their invite, so login fails. |
353
+ | `invited_at` | Set: the user was invited. With an empty `confirmed_at`, the invite is still pending. |
354
+ | `app_metadata.provider` | `email` for password signups; `google`, `github`, etc. for OAuth. An OAuth user has no password, so the password form rejects them. |
355
+ | `app_metadata.roles` | Compare with the roles your redirects or code require. |
356
+ | `last_sign_in_at` | When the user last logged in successfully. |
357
+ | `recovery_sent_at` | When a password reset email was last requested. |
358
+ | `email_change_sent_at` | When an email change was last requested. |
680
359
 
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.
360
+ These reads don't show whether the login flow in the code works. Check the code and the preview for that.