@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.
- package/dist/bin-local.js +240 -239
- package/dist/bin.js +308 -307
- package/dist/index.d.ts +67 -18
- package/dist/index.js +247 -246
- package/dist/interactions-mcp-server.js +87 -40
- package/dist/skills/netlify-identity/SKILL.md +246 -568
- package/dist/skills/netlify-identity/references/advanced-patterns.md +104 -0
- package/dist/skills/netlify-identity/references/authorization-and-sessions.md +24 -0
- package/package.json +2 -1
- package/scripts/build-skills.js +40 -18
|
@@ -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.
|
|
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": {
|
package/scripts/build-skills.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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)
|