@netlify/agent-runner-cli 2.16.0-server.1 → 2.16.0-server.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ # Advanced Identity Patterns
2
+
3
+ ## Password Recovery
4
+
5
+ Three-step flow: request recovery email, handle the callback, then set a new password.
6
+
7
+ ```typescript
8
+ import { requestPasswordRecovery, handleAuthCallback, updateUser, AuthError } from '@netlify/identity'
9
+
10
+ // Step 1: Send recovery email
11
+ async function handleForgotPassword(email: string) {
12
+ try {
13
+ await requestPasswordRecovery(email)
14
+ showSuccess('Check your email for a password reset link.')
15
+ } catch (error) {
16
+ if (error instanceof AuthError) showError(error.message)
17
+ }
18
+ }
19
+
20
+ // Step 2: handleAuthCallback() returns { type: 'recovery', user } — show password reset form
21
+ // (See the handleAuthCallback switch in SKILL.md)
22
+
23
+ // Step 3: Set new password
24
+ async function handlePasswordReset(newPassword: string) {
25
+ try {
26
+ await updateUser({ password: newPassword })
27
+ showSuccess('Password updated.')
28
+ } catch (error) {
29
+ if (error instanceof AuthError) showError(error.message)
30
+ }
31
+ }
32
+ ```
33
+
34
+ The recovery callback fires a `'recovery'` auth event, not `'login'`. The user is authenticated but should be prompted to set a new password before navigating away.
35
+
36
+ ## Invite Acceptance
37
+
38
+ When a user clicks an invite link, `handleAuthCallback()` returns `{ type: 'invite', user: null, token }`. Use the token to accept the invite and set a password.
39
+
40
+ ```typescript
41
+ import { acceptInvite, AuthError } from '@netlify/identity'
42
+
43
+ async function handleAcceptInvite(token: string, password: string) {
44
+ try {
45
+ const user = await acceptInvite(token, password)
46
+ showSuccess(`Welcome, ${user.email}! Your account is ready.`)
47
+ } catch (error) {
48
+ if (error instanceof AuthError) showError(error.message)
49
+ }
50
+ }
51
+ ```
52
+
53
+ ## Email Change
54
+
55
+ When a user verifies an email change, `handleAuthCallback()` returns `{ type: 'email_change', user }` with the change already applied — the returned `user` carries the new email. The user must be logged in when clicking the verification link.
56
+
57
+ Like recovery (and unlike invite acceptance), there is no token to consume and no follow-up call — `handleAuthCallback()` does the work. Handle it in the `handleAuthCallback()` switch (see SKILL.md) and confirm the new address.
58
+
59
+ ```typescript
60
+ // In the handleAuthCallback() switch (see SKILL.md):
61
+ case 'email_change':
62
+ showSuccess(`Email updated to ${result.user?.email}`)
63
+ break
64
+ ```
65
+
66
+ If you process the verification token yourself instead of letting `handleAuthCallback()` read the URL hash, call `verifyEmailChange(token)` directly — it applies the change and returns the updated user.
67
+
68
+ ## Session Hydration
69
+
70
+ `hydrateSession()` bridges server-set cookies to the browser session. Call it on page load when using server-side login (e.g., login inside a Netlify Function followed by a redirect).
71
+
72
+ ```typescript
73
+ import { hydrateSession } from '@netlify/identity'
74
+
75
+ const user = await hydrateSession()
76
+ if (user) {
77
+ // Browser session is now in sync with server-set cookies
78
+ }
79
+ ```
80
+
81
+ `getUser()` auto-hydrates from the `nf_jwt` cookie if no browser session exists, so explicit `hydrateSession()` is only needed when you want to restore the full session (including token refresh timers) after a server-side login.
82
+
83
+ ## SSR Integration Patterns
84
+
85
+ For SSR frameworks, the recommended pattern is:
86
+
87
+ - **Browser-side** for auth mutations: `login()`, `signup()`, `logout()`, `oauthLogin()`
88
+ - **Server-side** for reading auth state: `getUser()`, `getSettings()`, `getIdentityConfig()`
89
+
90
+ Browser-side auth mutations set the `nf_jwt` cookie and localStorage, and emit `onAuthChange` events. The server reads the cookie on the next request.
91
+
92
+ The library also supports server-side mutations (`login()`, `signup()`, `logout()` inside Netlify Functions), but these require the Netlify Functions runtime to set cookies. After a server-side mutation, use a full page navigation so the browser sends the new cookie.
93
+
94
+ Always use `window.location.href` (not framework router navigation) after server-side auth mutations in Next.js, TanStack Start, and SvelteKit. Remix `redirect()` is safe because Remix actions return real HTTP responses.
95
+
96
+ ## Full API Reference
97
+
98
+ For the complete API reference — all function signatures, type definitions, OAuth helpers, admin operations, session management, auth events, and framework-specific examples — read the package README:
99
+
100
+ ```
101
+ node_modules/@netlify/identity/README.md
102
+ ```
103
+
104
+ The README is shipped with the npm package and is always in sync with the installed version.
@@ -0,0 +1,24 @@
1
+ # Netlify Identity — authorization and session gotchas
2
+
3
+ Where role-based access actually gets enforced, and why a role change doesn't take effect immediately.
4
+
5
+ ## Admin operations run only in the Functions runtime
6
+
7
+ Identity's admin API — creating users, updating a user's roles or metadata, deleting users (the `admin.*` operations) — requires a privileged admin token that is available **only in the Netlify Functions runtime**. It is not exposed to browser code and is not available in Edge Functions. Do all role assignment and user administration from inside a modern v2 Function (or an Identity event function), never from the client and never from an edge function. A "promote this user to admin" button in the UI must call a Function endpoint that performs the change server-side — it cannot call the admin API directly from the browser.
8
+
9
+ You don't configure or read the admin token yourself — the Netlify Functions runtime provides it automatically to `admin.*` calls made inside a Function, so there is no env var to set and no `Netlify.env.get(...)` to call for it. Run the `admin.*` operations server-side in a Function; never hardcode an admin token, ship one in the client bundle, or expose the admin operations to the browser. Exposing an admin capability client-side would let any visitor grant themselves the `admin` role.
10
+
11
+ ## Redirect gating only covers CDN document requests
12
+
13
+ `conditions = { Role = [...] }` redirects are enforced by the CDN **only when it serves a document (navigation) request** — a fresh HTTP request for the path. They are a coarse page-level perimeter, not real authorization:
14
+
15
+ - **SPA client-side navigation bypasses them.** When a client-side router (React, Vue, SvelteKit, etc.) navigates to `/admin` in the browser, no new document request reaches the CDN, so the redirect rule never runs and the route renders regardless of the user's role.
16
+ - **Anything in the client bundle is downloadable by anyone.** Role-gated content compiled into the JavaScript bundle ships to every visitor who can load the page; hiding a component behind a client-side role check does not protect the data inside it.
17
+
18
+ So use redirect gating for coarse routing only. Enforce anything sensitive **server-side on every request** — a Netlify Function (or the API it calls) that resolves the user with `getUser()` and checks the server-controlled `app_metadata.roles` — never a client-side route guard or a hidden UI element as the only gate.
19
+
20
+ ## Role changes don't affect live sessions until the JWT refreshes
21
+
22
+ Roles are baked into the `nf_jwt` when the token is issued, and that JWT stays valid until it expires (about an hour). Changing a user's roles — via the dashboard, the admin API, or an Identity event function — does **not** update tokens already held by signed-in users. A user you just promoted keeps seeing the old view, and a user whose role you just revoked keeps their access, until their token refreshes (`AUTH_EVENTS.TOKEN_REFRESH`) or they log out and log back in. Both redirect `Role` conditions and function-side `app_metadata.roles` checks read the current token, so both see the stale roles until then.
23
+
24
+ Don't expect a role change to take effect mid-session. When it needs to apply right away, direct the user to log out and back in (or otherwise refresh their token) so a new `nf_jwt` carrying the updated roles is issued.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@netlify/agent-runner-cli",
3
3
  "type": "module",
4
- "version": "2.16.0-server.1",
4
+ "version": "2.16.0-server.3",
5
5
  "description": "CLI tool for running Netlify agents",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -48,6 +48,7 @@
48
48
  "check:types": "tsc --noEmit",
49
49
  "prompts": "LOG=0 vitest run --config vitest.prompts.config.js",
50
50
  "site:build": "npm run prompts && node site/build.mjs",
51
+ "site:dev": "npm run site:build && netlify dev --dir site/dist",
51
52
  "postinstall": "node scripts/postinstall.js"
52
53
  },
53
54
  "engines": {
@@ -1,4 +1,5 @@
1
- import { appendFile, cp, mkdir, readdir, readFile, rm } from 'node:fs/promises'
1
+ import { existsSync } from 'node:fs'
2
+ import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
3
  import { createRequire } from 'node:module'
3
4
  import path from 'node:path'
4
5
  import process from 'node:process'
@@ -8,34 +9,55 @@ const localSkillsDir = new URL('../src/skills/', import.meta.url)
8
9
  const sharedPackagePath = createRequire(import.meta.url).resolve('@netlify/skills/package.json')
9
10
  const sharedSkillsDir = path.join(path.dirname(sharedPackagePath), 'skills')
10
11
  export const bundledSkillsDir = fileURLToPath(new URL('../dist/skills/', import.meta.url))
11
- const selectedSharedSkills = [
12
- { name: 'netlify-access-control' },
13
- {
14
- name: 'netlify-forms',
15
- instructions: new URL('../src/skill-instructions/netlify-forms.md', import.meta.url),
16
- assets: new URL('../src/skill-assets/netlify-forms/', import.meta.url),
17
- },
18
- {
19
- name: 'netlify-image-cdn',
20
- instructions: new URL('../src/skill-instructions/netlify-image-cdn.md', import.meta.url),
21
- },
22
- ]
12
+ const skillOverridesDir = fileURLToPath(new URL('../src/skill-overrides/', import.meta.url))
13
+ export const selectedSharedSkills = ['netlify-access-control', 'netlify-forms', 'netlify-image-cdn', 'netlify-identity']
14
+
15
+ const readOptionalFile = async (filePath) => {
16
+ try {
17
+ return await readFile(filePath, 'utf8')
18
+ } catch (error) {
19
+ if (error.code === 'ENOENT') return undefined
20
+ throw error
21
+ }
22
+ }
23
+
24
+ export const applySkillReplacements = (content, replacements) => {
25
+ let result = content
26
+ for (const { from, to } of replacements) {
27
+ if (!from || result.split(from).length !== 2) {
28
+ throw new Error(`Expected exactly one occurrence of shared skill text: ${JSON.stringify(from)}`)
29
+ }
30
+ result = result.replace(from, () => to)
31
+ }
32
+ return result
33
+ }
23
34
 
24
35
  export const buildSkills = async (targetDir = bundledSkillsDir) => {
25
36
  await rm(targetDir, { recursive: true, force: true })
26
37
  await cp(localSkillsDir, targetDir, { recursive: true })
27
38
 
28
- for (const { name, instructions, assets } of selectedSharedSkills) {
39
+ for (const name of selectedSharedSkills) {
29
40
  await cp(path.join(sharedSkillsDir, name), path.join(targetDir, name), {
30
41
  recursive: true,
31
42
  force: false,
32
43
  errorOnExist: true,
33
44
  })
34
- if (instructions) {
35
- await appendFile(path.join(targetDir, name, 'SKILL.md'), `\n\n${await readFile(instructions, 'utf8')}`)
45
+ const overrideDir = path.join(skillOverridesDir, name)
46
+ const replacements = await readOptionalFile(path.join(overrideDir, 'replacements.json'))
47
+ const instructions = await readOptionalFile(path.join(overrideDir, 'instructions.md'))
48
+ if (instructions !== undefined || replacements !== undefined) {
49
+ const skillPath = path.join(targetDir, name, 'SKILL.md')
50
+ let content = await readFile(skillPath, 'utf8')
51
+ if (replacements !== undefined) {
52
+ content = applySkillReplacements(content, JSON.parse(replacements))
53
+ }
54
+ if (instructions !== undefined) {
55
+ content += `\n\n${instructions}`
56
+ }
57
+ await writeFile(skillPath, content)
36
58
  }
37
- if (assets) {
38
- const assetsDir = fileURLToPath(assets)
59
+ const assetsDir = path.join(overrideDir, 'assets')
60
+ if (existsSync(assetsDir)) {
39
61
  for (const entry of await readdir(assetsDir, { recursive: true, withFileTypes: true })) {
40
62
  if (!entry.isFile()) continue
41
63
  const source = path.join(entry.parentPath, entry.name)